Compare commits

...

2 Commits

Author SHA1 Message Date
khannurien
f3f07ed1e3 v3: every doc opens with the role, then the mechanism, then the details — the docs were written bottom-up: what a thing does first ("Declares the three contexts WSProvider fills…"), details that only make sense once its purpose is known next, and the reason it exists last ("…so that a feed doesn't re-render"), so a reader had nothing to hang the first sentences on. The README's rule caused it, since a module doc "opened with what the module does"; it now opens with the module's role in the app, and the value-side phrasing of a type ("Returned to Y by X") comes once the role is said, with WSContext as the before and after. WSContext.ts is rewritten that way throughout, and so are the docs elsewhere that buried their purpose in a trailing "so that" (the migrations, og, image-proxy, public-address, useFading, useFeedCache, the drafts), opened on a generic verb that hid it ("Holds", "Declares", "Loads", "Defines", "Types": the contexts and providers, the shared types, the models, the hooks), or were label fragments ("The page of a dump…", "A row of tabs."). A function whose verb is its action ("Stores session"), a props or result type, and a protocol message ("Sent by X when Y") already say their role and are left as they are. Only comments changed, and the catalogs only move their references; deno fmt, deno lint and doc:lint are clean.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 49s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 22s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:42:08 +00:00
khannurien
2af64721c7 v3: the invite tree spaces every row the same — a child sat 0.2rem under its parent but siblings 0.4rem apart, since each row's spacing was split into top and bottom padding and a last child dropped its bottom half. Every row now has one gap above it (--invite-tree-gap), the connectors run through it from the same variable, and the user link is a block, so a theme's line height can't push the avatar off the connector's center.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:28:43 +00:00
106 changed files with 483 additions and 395 deletions

View File

@@ -188,9 +188,20 @@ one of:
Add an example only when the call isn't obvious. Add an example only when the call isn't obvious.
3. **Place in the chain**: where its input comes from and where its output goes. 3. **Place in the chain**: where its input comes from and where its output goes.
A type's doc is phrased from the value's side, naming the producer and the **A doc opens with the role, then the mechanism, then the details.** Its first
consumer: "Returned to Y by X when Z", "Passed to Y by X". It is never "What X sentence says what part of the app the thing serves or what problem it solves,
returns to Y" or "What X passes to Y". so a reader who has never seen it knows why it exists. How it does that (what
feeds it, what reads it, how it's split) comes next, and the details last. Each
sentence makes sense given only the ones before it. Not "Declares the three
contexts `WSProvider` fills from the WebSocket. They are split so that a feed
doesn't re-render with every chat message", which leaves the reason for last,
but "Live updates from the server reach the pages through three contexts, split
so that a feed doesn't re-render with every chat message. `WSProvider` fills
them from its WebSocket."
Once the role is said, a type's doc is phrased from the value's side, naming the
producer and the consumer: "Returned to Y by X when Z", "Passed to Y by X". It
is never "What X returns to Y" or "What X passes to Y".
Don't pack a doc into a label followed by a colon and a list ("X: one thing, Don't pack a doc into a label followed by a colon and a list ("X: one thing,
plus some other thing"). Write sentences with a subject and a verb. plus some other thing"). Write sentences with a subject and a verb.
@@ -209,13 +220,13 @@ and locals. None of these count as a doc:
When a comment is there, it is as short as it can be, often one sentence, and it When a comment is there, it is as short as it can be, often one sentence, and it
is written in full sentences. Labels and fragments ("Upvotes on dumps.") don't is written in full sentences. Labels and fragments ("Upvotes on dumps.") don't
count. A module doc is a few lines at most. It opens with what the module does, count. A module doc is a few lines at most. It opens with the module's role in
then says who calls it and what it hands back, and it doesn't repeat what its the app, then says how it plays it, who calls it and what it hands back, and it
exports' docs say. `@throws` stays on whatever throws to its caller, with the doesn't repeat what its exports' docs say. `@throws` stays on whatever throws to
statuses of an `APIException` and when each happens. A router's module doc lists its caller, with the statuses of an `APIException` and when each happens. A
its endpoints (method, path, who may call it), since the code states them router's module doc lists its endpoints (method, path, who may call it), since
nowhere in one place. A summary never ends in a colon: the reference shows it the code states them nowhere in one place. A summary never ends in a colon: the
alone. reference shows it alone.
**`//` comments** go wherever the code isn't obvious. They explain why, not **`//` comments** go wherever the code isn't obvious. They explain why, not
what: a workaround, an ordering constraint, a surprising limitation, a security what: a workaround, an ordering constraint, a surprising limitation, a security

View File

@@ -1,7 +1,8 @@
/** /**
* Reads the server's settings from the `GERBEUR_*` environment variables, once * Lets a deployment configure the server through the `GERBEUR_*` environment
* at import, and holds the fixed limits the backend applies. The deno tasks * variables, which it reads once at import, and holds the fixed limits the
* load `.env` with `--env-file`, so changing a setting needs a restart. * backend applies. The deno tasks load `.env` with `--env-file`, so changing a
* setting needs a restart.
* *
* @module * @module
*/ */
@@ -55,10 +56,10 @@ export const PUBLIC_URL: string =
Deno.env.get("GERBEUR_PUBLIC_URL")?.trim().replace(/\/$/, "") || Deno.env.get("GERBEUR_PUBLIC_URL")?.trim().replace(/\/$/, "") ||
`http://localhost:${PORT}`; `http://localhost:${PORT}`;
/** /**
* Holds the URL the frontend is served from (`GERBEUR_FRONTEND_URL`), without * Points password reset links and the Open Graph tags' page URLs at the
* a trailing slash, when it isn't {@link PUBLIC_URL}, as with a separate Vite * frontend when it isn't served from {@link PUBLIC_URL}, as with a separate
* server. It is an allowed origin, and password reset links and the Open Graph * Vite server, and allows it as an origin. It is `GERBEUR_FRONTEND_URL`,
* tags' page URLs point to it. * without a trailing slash.
*/ */
export const FRONTEND_URL: string = export const FRONTEND_URL: string =
Deno.env.get("GERBEUR_FRONTEND_URL")?.trim().replace(/\/$/, "") || Deno.env.get("GERBEUR_FRONTEND_URL")?.trim().replace(/\/$/, "") ||

View File

@@ -1,7 +1,7 @@
/** /**
* Adds `dumps.custom_thumbnail_mime`, so a poster can upload a thumbnail that * Lets a poster upload a thumbnail that replaces the fetched one. It adds
* replaces the fetched one. It holds the uploaded image's type, and NULL means * `dumps.custom_thumbnail_mime`, which holds the uploaded image's type; NULL
* the dump has none. * means the dump has none.
* *
* @module * @module
*/ */

View File

@@ -1,8 +1,8 @@
/** /**
* Adds `dump_backlinks`, which records the links from a dump's note or * Lets a dump page list the dumps related to it. It adds `dump_backlinks`,
* comments to other dumps, so a dump page can list the dumps related to it. * which records the links from a dump's note or comments to other dumps and
* backlink-service fills it, and the index on `dumps.url` lets it find the * which backlink-service fills, and an index on `dumps.url` to find the dumps
* dumps an external link was posted as. * an external link was posted as.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Adds `users.role`, which replaces the `is_admin` flag so that moderators can * Makes room for moderators between users and admins. It adds `users.role`,
* exist between users and admins. 0005 then drops the flag. * which replaces the `is_admin` flag, and 0005 then drops the flag.
* *
* @module * @module
*/ */

View File

@@ -1,8 +1,8 @@
/** /**
* Adds `chat_messages.reply_to_id`, so a message can reply to another. It has * Lets a chat message reply to another, through `chat_messages.reply_to_id`.
* no foreign key on purpose: when the replied-to message is deleted, the id * The column has no foreign key on purpose: when the replied-to message is
* stays and chat-service's join reads null, so the chat shows "deleted * deleted, the id stays and chat-service's join reads null, so the chat shows
* message" where the quote was. * "deleted message" where the quote was.
* *
* @module * @module
*/ */

View File

@@ -1,8 +1,8 @@
/** /**
* Adds the `start` parameter to the YouTube embed URLs stored before the * Makes the YouTube dumps posted with a start time, before the YouTube provider
* YouTube provider carried it over. `rich_content.embedUrl` is computed when a * kept it, start there instead of at 0:00. `rich_content.embedUrl` is computed
* dump is posted, and used to drop the source URL's `t` (`?t=1265`, * when a dump is posted, and it used to drop the source URL's `t` (`?t=1265`,
* `?t=1h2m3s`), so those videos played from 0:00. * `?t=1h2m3s`); this adds the `start` parameter back to the stored embed URLs.
* *
* It reads the time from the stored source URL and fetches nothing. It keeps * It reads the time from the stored source URL and fetches nothing. It keeps
* its own copy of the parsing, so that it migrates every database alike should * its own copy of the parsing, so that it migrates every database alike should

View File

@@ -1,10 +1,11 @@
/** /**
* Moves site icons out of `rich_content.thumbnailUrl` into the new * Stops older dumps from showing a site's icon as their thumbnail. The metadata
* `faviconUrl`. The metadata fetch used to fall back on the page's icon, and * fetch used to fall back on the page's icon, and then on a guessed
* then on a guessed `/favicon.ico`, so most thumbnails were a 16×16 icon, or a * `/favicon.ico`, so most thumbnails were a 16×16 icon, or a 404, that the UI
* 404, that the UI stretched over a 128×72 box. It now stops at real artwork, * stretched over a 128×72 box. It now stops at real artwork, and a missing
* and a missing `thumbnailUrl` means none, which the frontend draws a * `thumbnailUrl` means none, which the frontend draws a placeholder for. This
* placeholder for. This gives the older rows that meaning. * gives the older rows that meaning, by moving their icons out of
* `rich_content.thumbnailUrl` into the new `faviconUrl`.
* *
* It judges the stored URL alone and fetches nothing, so it doesn't fill * It judges the stored URL alone and fetches nothing, so it doesn't fill
* `accentColor`. The frontend derives a hue from the hostname when it is * `accentColor`. The frontend derives a hue from the hostname when it is

View File

@@ -1,6 +1,7 @@
/** /**
* Adds `dumps.url_canonical`, the key the create form's "already dumped" hint * Lets the create form warn that a link was already dumped. It adds
* looks a URL up by, and fills it for every URL dump already posted. * `dumps.url_canonical`, the key the hint looks a URL up by, and fills it for
* every URL dump already posted.
* *
* It fills the key from each stored URL and fetches nothing. Its helpers are a * It fills the key from each stored URL and fetches nothing. Its helpers are a
* copy of lib/canonical-url.ts as it stood when this shipped: importing the * copy of lib/canonical-url.ts as it stood when this shipped: importing the

View File

@@ -1,7 +1,7 @@
/** /**
* Adds `users.tokens_valid_after`, so that changing a password ends every * Lets a password change end every session opened before it. Tokens are
* session opened before. Tokens are stateless JWTs, so lib/auth.ts refuses a * stateless JWTs, so this adds `users.tokens_valid_after`, and lib/auth.ts
* token issued before that time instead. * refuses a token issued before that time.
* *
* @module * @module
*/ */

View File

@@ -1,8 +1,10 @@
/** /**
* Adds `attachments.resource_type`, the kind of thing an attachment's * Serves an attachment only to those who may see what it was pasted into, and
* `resource_id` names. Without it, an attachment couldn't tell whose it was: * deletes it once that is gone. It adds `attachments.resource_type`, the kind
* one pasted into a private dump was served to anyone with its URL, and one * of thing an attachment's `resource_id` names. Without it, an attachment
* whose dump, comment or message was deleted was kept for good. * couldn't tell whose it was: one pasted into a private dump was served to
* anyone with its URL, and one whose dump, comment or message was deleted was
* kept for good.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Defines the exception that carries an HTTP error from any layer up to the * Lets any layer fail a request with an HTTP status, which the error middleware
* error middleware. * turns into the response.
* *
* @module * @module
*/ */

View File

@@ -1,9 +1,10 @@
/** /**
* Reduces a URL to a key shared by every link to the same page or video. * Spots reposts and links to known dumps, by reducing a URL to a key shared by
* `dump-service` stores it in `dumps.url_canonical` when a * every link to the same page or video. `dump-service` stores it in
* dump is posted or edited, and `GET /api/dumps/by-url` matches on it * `dumps.url_canonical` when a dump is posted or edited, `GET
* to warn the create form of a repost. The key is never fetched or shown, so it * /api/dumps/by-url` matches on it to warn the create form of a repost, and
* may be lossy: it forces `https`, drops `www.`, tracking parameters and * backlink-service compares links by it. The key is never fetched or shown, so
* it may be lossy: it forces `https`, drops `www.`, tracking parameters and
* timestamps. * timestamps.
* *
* Migration 0013 backfilled existing rows with a frozen copy of these rules. * Migration 0013 backfilled existing rows with a frozen copy of these rules.

View File

@@ -1,7 +1,7 @@
/** /**
* Reads the page a list route's query string asks for, and cuts the rows a * Pages every list route the same way. It reads the page a list route's query
* service fetched into that page. The routes send the page as * string asks for, and cuts the rows a service fetched into that page, which
* `PaginatedData`. * the routes send as `PaginatedData`.
* *
* @module * @module
*/ */

View File

@@ -1,8 +1,8 @@
/** /**
* Answers a link to a dump, a profile or a public playlist with `index.html` * Gives links to the site rich previews in chat apps and social sites, which
* carrying that page's Open Graph and Twitter card tags, for the link previews * don't run the app. It answers a link to a dump, a profile or a public
* of chat apps and social sites, which don't run the app. main.ts installs it * playlist with `index.html` carrying that page's Open Graph and Twitter card
* before the routers. * tags. main.ts installs it before the routers.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,7 @@
/** /**
* Types the payloads of session and password reset tokens, and guards them. * Checks what a verified token says before it is trusted. It types the payloads
* `lib/jwt.ts` signs the tokens and runs the guards when it verifies one. * of session and password reset tokens, and `lib/jwt.ts` runs their guards when
* it verifies one.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Types the rows the category service reads from `categories`, and turns them * Turns the rows the category service reads from `categories` into the {@link
* into the {@link Category} the category routes send. * Category} the category routes send.
* *
* @module * @module
*/ */

View File

@@ -1,5 +1,5 @@
/** /**
* Maps the rows of `chat_messages` to the {@link ChatMessage} the API sends. * Turns the rows of `chat_messages` into the {@link ChatMessage} the API sends.
* Only `services/chat-service.ts` uses it, on the rows of its one query. * Only `services/chat-service.ts` uses it, on the rows of its one query.
* *
* @module * @module

View File

@@ -1,6 +1,6 @@
/** /**
* Types the rows the comment service reads from `comments`, and turns them into * Turns the rows the comment service reads from `comments` into the {@link
* the {@link Comment} the comment routes send. * Comment} the comment routes send.
* *
* @module * @module
*/ */

View File

@@ -1,7 +1,8 @@
/** /**
* Maps the `dumps` table to the API's {@link Dump}. Every service that reads * Turns the rows of `dumps` into the API's {@link Dump} the same way for every
* dumps selects them with the column lists here, checks each row with * service that reads dumps: each selects them with the column lists here,
* {@link isDumpRow} and converts it with {@link dumpRowToApi}. * checks each row with {@link isDumpRow} and converts it with {@link
* dumpRowToApi}.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Types and guards the rows of `follows`, which `follow-service.ts` reads to * Checks the rows `follow-service.ts` reads from `follows` to answer what a
* answer what a user follows. * user follows.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Types and guards the rows of `invites`, which `invite-service.ts` reads to * Checks the rows `invite-service.ts` reads from `invites` to vet an invite
* check an invite before sign-up. * before sign-up.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Types and guards the rows of `notifications`, and converts them to the * Turns the rows of `notifications`, once checked, into the {@link
* {@link Notification} that `GET /api/notifications` answers. * Notification} that `GET /api/notifications` answers.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Types and guards the rows of `password_reset_tokens`, which * Checks the rows `password-reset-service.ts` reads from
* `password-reset-service.ts` reads to make each reset link work once. * `password_reset_tokens` to make each reset link work once.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Types the rows the playlist and follow services read from `playlists`, and * Turns the rows the playlist and follow services read from `playlists` into
* turns them into the {@link Playlist} the API sends. * the {@link Playlist} the API sends.
* *
* @module * @module
*/ */

View File

@@ -1,7 +1,6 @@
/** /**
* Checks the rows of a list query, for the services. Every service answers a * Makes a damaged row fail its query with a 500 rather than silently vanish
* row that doesn't have its table's shape with a 500 rather than leaving it * from a list. Every service checks the rows of its list queries with it.
* out, so that damage shows instead of making items silently vanish.
* *
* @module * @module
*/ */

View File

@@ -1,7 +1,7 @@
/** /**
* Types and guards the rows of `users`, and converts them to the {@link User} * Turns the rows of `users`, once checked, into the {@link User} that
* that `user-service.ts` returns. A route turns a `User` into what it may send * `user-service.ts` returns. A route turns a `User` into what it may send with
* with {@link toPublicUser} or {@link toSelfUser}. * {@link toPublicUser} or {@link toSelfUser}.
* *
* @module * @module
*/ */

View File

@@ -1,8 +1,9 @@
/** /**
* Fetches and caches the remote images `GET /api/proxy-image` serves. The * Shows the thumbnails a browser can't load from their own site: those on plain
* frontend routes a thumbnail through it when the image is on plain http, * http, which an https page would block, and those behind hotlink protection.
* which an https page would block, or when loading it directly failed, often * It fetches and caches the images `GET /api/proxy-image` serves, which the
* to hotlink protection. * frontend routes a thumbnail through when it is on http or when loading it
* directly failed.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,7 @@
/** /**
* Declares what a link provider implements. `rich-content-service.ts` asks its * Lets each kind of site have its own link previews. A provider implements this
* providers in turn and fetches a link's preview from the first that matches. * interface, and `rich-content-service.ts` asks its providers in turn and
* fetches a link's preview from the first that matches.
* *
* @module * @module
*/ */

View File

@@ -1,8 +1,8 @@
/** /**
* Tells private addresses from public ones, so that a user-supplied URL can't * Keeps a user-supplied URL from making the server fetch what only it can
* make the server fetch what only it can reach: loopback, the LAN, container * reach: loopback, the LAN, container networks or the cloud metadata endpoint
* networks or the cloud metadata endpoint at 169.254.169.254. `fetch.ts` checks * at 169.254.169.254. It tells private addresses from public ones, and
* every hop with it. * `fetch.ts` checks every hop with it.
* *
* @module * @module
*/ */

View File

@@ -1,7 +1,7 @@
/** /**
* Resolves a YouTube playlist to its entries' titles and lengths, for * Gives the global player a tracklist for a YouTube playlist, which the embed
* `GET /api/youtube/playlist`. The embed plays the list but doesn't expose * plays but doesn't list. It resolves the playlist to its entries' titles and
* them, and the global player needs them to draw a tracklist. * lengths, for `GET /api/youtube/playlist`.
* *
* They are read from the `ytInitialData` JSON of the public playlist page, * They are read from the `ytInitialData` JSON of the public playlist page,
* whose undocumented shape changes, so both the current `lockupViewModel` rows * whose undocumented shape changes, so both the current `lockupViewModel` rows

View File

@@ -1,7 +1,7 @@
/** /**
* Serves the avatar endpoints. The profile page uploads the avatar, and the * Stores and serves the users' avatars. The profile page uploads the avatar,
* client's `Avatar` component loads it, with the user's `updatedAt` as a * and the client's `Avatar` component loads it, with the user's `updatedAt` as
* cache-busting version. * a cache-busting version.
* *
* - `POST /api/avatars/me` — signed in. Replaces the caller's avatar (multipart * - `POST /api/avatars/me` — signed in. Replaces the caller's avatar (multipart
* `file`, at most 5 MB), sends `user_updated` to everyone, and answers the * `file`, at most 5 MB), sends `user_updated` to everyone, and answers the

View File

@@ -1,5 +1,6 @@
/** /**
* The category routes. * Serves the categories dumps are filed under and their feeds, and changes them
* for those with `category:manage`.
* *
* - `GET /api/categories` — public. Answers every category, in order. * - `GET /api/categories` — public. Answers every category, in order.
* - `GET /api/categories/:slug` — public. Answers one category. * - `GET /api/categories/:slug` — public. Answers one category.

View File

@@ -1,6 +1,6 @@
/** /**
* Serves the invite endpoints. Any signed-in user can invite someone, from * Lets any signed-in user invite someone, from their profile page. The invite
* their profile page, and the invite link opens the `/register` page. * link opens the `/register` page.
* *
* - `POST /api/invites` — signed in. Makes an invite and answers its token. * - `POST /api/invites` — signed in. Makes an invite and answers its token.
* - `GET /api/invites/:token` — public. Checks that an invite still works, * - `GET /api/invites/:token` — public. Checks that an invite still works,

View File

@@ -31,8 +31,8 @@ import { authenticateMedia } from "../lib/auth.ts";
const router: Router = new Router({ prefix: "/api/thumbnails" }); const router: Router = new Router({ prefix: "/api/thumbnails" });
/** /**
* Holds whether ffmpeg runs, probed once per process: probing on every uncached * Spares a process per uncached thumbnail request: whether ffmpeg runs is
* thumbnail request cost a process per card. * probed once per process, and kept here.
*/ */
let ffmpegProbe: Promise<boolean> | undefined; let ffmpegProbe: Promise<boolean> | undefined;
@@ -146,8 +146,8 @@ function imagePreview(srcPath: string, outPath: string): Promise<void> {
} }
/** /**
* Holds the generations under way, by output path. A feed full of cards asking * Lets a feed full of cards asking for the same new still at once wait on a
* for the same new still at once then waits on a single ffmpeg run. * single ffmpeg run. It holds the generations under way, by output path.
*/ */
const inFlight = new Map<string, Promise<void>>(); const inFlight = new Map<string, Promise<void>>();

View File

@@ -1,7 +1,8 @@
/** /**
* Records the images pasted into Markdown bodies, and binds each one to the * Ties each image pasted into a Markdown body to the dump, comment, playlist or
* resource whose body links it. `routes/attachments.ts` stores and serves the * message that links it, so the image is served only to those who may see that,
* files; the services that save a body call {@link linkAttachments}. * and purged at startup once it is gone. `routes/attachments.ts` stores and
* serves the files; the services that save a body call {@link linkAttachments}.
* *
* @module * @module
*/ */

View File

@@ -52,9 +52,9 @@ const RECENT_TTL_MS = 5 * 60 * 1000;
/** How many previews are kept. The oldest written goes first. */ /** How many previews are kept. The oldest written goes first. */
const RECENT_MAX = 200; const RECENT_MAX = 200;
/** /**
* Holds the previews fetched in the last few minutes, by URL. The create form * Makes posting a link fast after the create form previewed it, by keeping the
* previews a link and then posts it, and without this the second fetch of the * previews fetched in the last few minutes, by URL. Without it, the second
* page was the slow part of posting. Only successes are kept. * fetch of the page was the slow part of posting. Only successes are kept.
*/ */
const recent = new Map< const recent = new Map<
string, string,

View File

@@ -1,7 +1,8 @@
/** /**
* Defines the envelope every JSON endpoint answers with, and {@link Wire}, the * Lets the client tell success from failure the same way for every JSON
* form a type takes once it has crossed JSON. The routes build the envelope, * endpoint, through the envelope they all answer with. It also defines {@link
* and the frontend's API helpers unwrap it. * Wire}, the form a type takes once it has crossed JSON. The routes build the
* envelope, and the frontend's API helpers unwrap it.
* *
* @module * @module
*/ */

View File

@@ -1,7 +1,8 @@
/** /**
* Declares the categories that dumps are filed under, and the bodies that * Categories file dumps by topic, and give each topic its own feed on the
* create and change them. Only admins manage categories, through the API, since * index. This module types them for the server and the client, with the bodies
* no page does it. The client lists them in its `CategoriesProvider`. * that create and change them. Only admins manage categories, through the API,
* since no page does it. The client lists them in its `CategoriesProvider`.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Defines a message of the site-wide chat, as the server sends it to the * The site-wide chat lets signed-in users talk live. This module types one of
* chatbox. * its messages, as the server sends it to the chatbox.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,7 @@
/** /**
* Declares the comments posted under a dump, and the bodies that post and edit * Comments let users discuss a dump under it, in threads. This module types
* them. The dump page's `CommentThread` renders them as a tree, by `parentId`. * them, with the bodies that post and edit them. The dump page's
* `CommentThread` renders them as a tree, by `parentId`.
* *
* @module * @module
*/ */

View File

@@ -1,8 +1,8 @@
/** /**
* Defines the dump, the link or file a user posts, as the API sends it, with * A dump is what users post: a link or a file, with a title and a note. This
* its link preview and the bodies that create and edit it. The backend's * module types it as the API sends it, with its link preview and the bodies
* `dump-service.ts` produces these types and the frontend's dump pages and * that create and edit it. The backend's `dump-service.ts` produces these types
* create form consume them. * and the frontend's dump pages and create form consume them.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,7 @@
/** /**
* Types what the signed-in user follows, as the client's `FollowProvider` * Following a user or a playlist puts their dumps in the index's "followed"
* loads it to show follow buttons pressed. * tab. This module types what the signed-in user follows, as the client's
* `FollowProvider` loads it to show the Follow buttons pressed.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Defines what counts as an @mention, so that the server notifies the same * Makes the server notify the same users the chat highlights and the editor
* users the chat highlights and the editor suggests. * suggests, by defining once what counts as an @mention.
* *
* @module * @module
*/ */

View File

@@ -1,7 +1,9 @@
/** /**
* Types the notifications `api/services/notification-service.ts` makes, and * Notifications tell a user when someone follows them or their playlists,
* the data each kind carries. The client's `/notifications` page lists them * upvotes, comments, likes or mentions them, or posts where they follow. This
* and turns each into a sentence and a link. * module types the ones `api/services/notification-service.ts` makes, and the
* data each kind carries. The client's `/notifications` page lists them and
* turns each into a sentence and a link.
* *
* @module * @module
*/ */

View File

@@ -1,7 +1,8 @@
/** /**
* Maps each role to what it may do. The server enforces the table * Keeps the server and the client agreed on what each role may do. The server
* (`api/lib/permissions.ts`, `api/routes/ws.ts`) and the client reads the same * enforces this table (`api/lib/permissions.ts`, `api/routes/ws.ts`) and the
* one to hide what a role can't do (`src/utils/auth/permissions.ts`). * client reads the same one to hide what a role can't do
* (`src/utils/auth/permissions.ts`).
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,7 @@
/** /**
* Defines what the Bandcamp and YouTube routes return to the frontend's * Lets the global player play a Bandcamp release with its own controls and list
* player when it plays a Bandcamp release or a YouTube playlist. * a YouTube playlist's tracks. This module types what the Bandcamp and YouTube
* routes return to it.
* *
* @module * @module
*/ */

View File

@@ -1,7 +1,7 @@
/** /**
* Declares playlists, the ordered collections of dumps their owners curate, and * Playlists are ordered collections of dumps their owners curate. This module
* the bodies that create, edit and reorder them. The client shows them on * types them, with the bodies that create, edit and reorder them. The client
* `/playlists/:playlistId`, on profiles and in search results. * shows them on `/playlists/:playlistId`, on profiles and in search results.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Declares the results of the site search, which the `Search` page shows under * The site search finds dumps, users and playlists at once. This module types
* one tab per kind. * its results, which the `Search` page shows under one tab per kind.
* *
* @module * @module
*/ */

View File

@@ -1,7 +1,8 @@
/** /**
* Types users as the API sends them, their roles, and the bodies and answers * A user crosses the wire in several shapes: as anyone sees them, as they see
* of the `/api/users` and `/api/invites` endpoints, for the server and the * themselves, and in the bodies and answers of the `/api/users` and
* client alike. * `/api/invites` endpoints. This module types them all, with the roles, for the
* server and the client alike.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Holds the limits on what users submit, shared so that the forms check the * Makes the forms check the same limits on what users submit as the server
* same limits the server enforces. * enforces, by holding them in one place.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,7 @@
/** /**
* Defines the messages of the WebSocket between `WSProvider` in the client and * Live updates, votes, likes and the chat travel over one WebSocket between
* `routes/ws.ts` in the server, as JSON objects told apart by `type`. * `WSProvider` in the client and `routes/ws.ts` in the server. This module
* types its messages, JSON objects told apart by `type`.
* *
* The server's messages go out from `services/ws-service.ts`, and `WSProvider` * The server's messages go out from `services/ws-service.ts`, and `WSProvider`
* handles them. Their Dates arrive as strings, so the client reads them as * handles them. Their Dates arrive as strings, so the client reads them as

View File

@@ -5729,7 +5729,13 @@ body.has-player .chat-fab {
} }
/* ── Invite tree ── */ /* ── Invite tree ── */
/* Every row but the very first has the same gap above it, a child's as a
sibling's: the gap is the node's top padding, so the connectors can run
through it. --invite-tree-avatar-center is where a row's avatar is centered,
from the top of its node. */
.invite-tree { .invite-tree {
--invite-tree-gap: 0.5rem;
--invite-tree-avatar-center: calc(var(--invite-tree-gap) + 12px);
list-style: none; list-style: none;
padding: 0; padding: 0;
margin: 0; margin: 0;
@@ -5737,60 +5743,54 @@ body.has-player .chat-fab {
.invite-tree-node { .invite-tree-node {
position: relative; position: relative;
padding: 0.2rem 0; padding-top: var(--invite-tree-gap);
} }
.invite-tree-node:last-child { .invite-tree-node:first-child:not(.invite-tree-node .invite-tree-node) {
padding-bottom: 0; padding-top: 0;
} }
/* Child list: indented so connector lines have room on the left. /* A child list is indented so that the vertical connector, at its left edge,
The vertical line sits at the list's left:0, centered under the parent is centered under the parent's 24px avatar (12px center - 1px half-line). */
avatar (avatar center 12px - 1px half-line = 11px). */
.invite-tree-node > .invite-tree { .invite-tree-node > .invite-tree {
margin-left: 11px; margin-left: 11px;
padding-left: calc(12px + 0.5rem); padding-left: calc(12px + 0.5rem);
} }
/* Connectors are owned per child node (not by the list) so they always anchor /* Each child draws its own connectors, so they anchor to its avatar however
to that child's own avatar center, regardless of how deep its subtree is. deep its subtree is. The vertical one drops from the row above to its
--avatar-center = node top padding (0.2rem) + half the 24px avatar (12px). */ avatar center, and on through its subtree when a sibling follows. */
.invite-tree-node > .invite-tree > .invite-tree-node { .invite-tree-node .invite-tree-node::before {
--avatar-center: calc(0.2rem + 12px);
}
/* Vertical segment: drops from the top of the child node to its avatar center,
joining the line coming down from the parent / previous sibling. */
.invite-tree-node > .invite-tree > .invite-tree-node::before {
content: ""; content: "";
position: absolute; position: absolute;
left: calc(-12px - 0.5rem); left: calc(-12px - 0.5rem);
top: 0; top: 0;
height: var(--avatar-center); height: var(--invite-tree-avatar-center);
width: 2px; width: 2px;
background: var(--color-border-subtle); background: var(--color-border-subtle);
} }
/* Non-last children extend the vertical segment through their full height .invite-tree-node .invite-tree-node:not(:last-child)::before {
(including any subtree) so it reaches the next sibling. */
.invite-tree-node > .invite-tree > .invite-tree-node:not(:last-child)::before {
height: auto; height: auto;
bottom: 0; bottom: 0;
} }
/* Horizontal connector: from the vertical line to the child's avatar. */ /* The horizontal one runs from the vertical line to the avatar. */
.invite-tree-node > .invite-tree > .invite-tree-node::after { .invite-tree-node .invite-tree-node::after {
content: ""; content: "";
position: absolute; position: absolute;
left: calc(-12px - 0.5rem); left: calc(-12px - 0.5rem);
top: calc(var(--avatar-center) - 1px); /* straddle center with the 2px bar */ top: calc(var(--invite-tree-avatar-center) - 1px);
width: calc(12px + 0.5rem); width: calc(12px + 0.5rem);
height: 2px; height: 2px;
background: var(--color-border-subtle); background: var(--color-border-subtle);
} }
/* A block, not inline: in a line box, the theme's line height would add space
under the avatar and move it off --invite-tree-avatar-center. */
.invite-tree-user { .invite-tree-user {
display: inline-flex; display: flex;
width: fit-content;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
color: var(--color-text); color: var(--color-text);

View File

@@ -1,6 +1,6 @@
/** /**
* The modal that posts a dump from a link or a file, then offers to add it to * Renders the modal that posts a dump from a link or a file, then offers to add
* the poster's playlists. Every "new dump" button opens it. * it to the poster's playlists. Every "new dump" button opens it.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* The panels of the dump creation modal, and the indicator showing which one * Shows which panel of the dump creation modal is open, and declares the panels
* is open. * in the order they are walked.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* The title field of the dump creation modal, read-only until the poster * Renders the title field of the dump creation modal, read-only until the
* chooses to edit it. * poster chooses to edit it.
* *
* @module * @module
*/ */

View File

@@ -1,5 +1,6 @@
/** /**
* The tab bar of the index page, with the category switcher before the tabs. * Renders the tab bar of the index page, with the category switcher before the
* tabs.
* *
* @module * @module
*/ */

View File

@@ -1,5 +1,5 @@
/** /**
* A row of tabs. * Renders the row of tabs of the index, profile and search pages.
* *
* @module * @module
*/ */

View File

@@ -1,5 +1,6 @@
/** /**
* Reads which Bandcamp player this deployment uses, for `usePlayRichContent`. * Lets a deployment choose between Bandcamp's embed and the site's own player.
* It reads the choice for `usePlayRichContent`.
* *
* The server writes `GERBEUR_BANDCAMP_PLAYER` into index.html's * The server writes `GERBEUR_BANDCAMP_PLAYER` into index.html's
* `bandcamp-player` `<meta>` tag (`api/lib/index-html.ts`, and the Vite config * `bandcamp-player` `<meta>` tag (`api/lib/index-html.ts`, and the Vite config

View File

@@ -1,6 +1,6 @@
/** /**
* Declares the session's React context. `AuthProvider` fills it and `useAuth` * Shares the session with every component. `AuthProvider` fills this context
* reads it; nothing else should touch it. * and `useAuth` reads it; nothing else should touch it.
* *
* @module * @module
*/ */

View File

@@ -1,5 +1,5 @@
/** /**
* Holds the session for the whole app and keeps it in step with the other tabs * Keeps the user signed in across the whole app, in step with the other tabs
* and with the server. `App` mounts it near the root. * and with the server. `App` mounts it near the root.
* *
* @module * @module

View File

@@ -1,6 +1,6 @@
/** /**
* Declares the categories' React context. `CategoriesProvider` fills it and * Shares the site's categories with every component. `CategoriesProvider` fills
* `useCategories` reads it. * this context and `useCategories` reads it.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Loads the site's categories once for the whole app, and again when the * Spares every page fetching the site's categories: it loads them once for the
* category manager changes them. `App` mounts it. * whole app, and again when the category manager changes them. `App` mounts it.
* *
* @module * @module
*/ */

View File

@@ -1,7 +1,6 @@
/** /**
* Declares the context through which the header's chat button and the floating * Lets the header's chat button and the floating one open the same chat modal,
* chat button open the one chat modal, which `ChatProvider` owns. Read it with * which `ChatProvider` owns. Read it with `useChat`.
* `useChat`.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,7 @@
/** /**
* Provides `ChatContext` and renders the chat modal while it is open. `App` * Owns the one chat modal: it renders it while it is open, and provides
* mounts it inside the router, around every page. * `ChatContext` to the buttons that open it. `App` mounts it inside the router,
* around every page.
* *
* @module * @module
*/ */

View File

@@ -1,8 +1,9 @@
/** /**
* Defines the player's two contexts, one for the queue and the controls and one * Shares the player docked at the bottom of every page with the whole app. The
* for the playhead alone. PlayerProvider provides both around the whole app; * cards and previews that start playback read and drive it, and GlobalPlayer
* GlobalPlayer draws the player, and the cards and previews that start playback * draws it. It is two contexts, one for the queue and the controls and one for
* read and drive it. * the playhead alone, which changes several times a second. PlayerProvider
* provides both around the whole app.
* *
* @module * @module
*/ */

View File

@@ -1,7 +1,8 @@
/** /**
* Holds the player's queue, playback state and controls, saves them across * Keeps playback going from page to page and across reloads. It holds the
* reloads and keeps streams' signed URLs fresh. App mounts it around the whole * player's queue, playback state and controls, saves them as they change and
* app, and GlobalPlayer draws what it holds. * keeps streams' signed URLs fresh. App mounts it around the whole app, and
* GlobalPlayer draws what it holds.
* *
* @module * @module
*/ */

View File

@@ -1,7 +1,6 @@
/** /**
* Declares the context that holds the users and playlists the signed-in user * Tells the Follow buttons what the signed-in user follows. `FollowProvider`
* follows, for the Follow buttons. `FollowProvider` provides it and * provides this context and `useFollows` reads it.
* `useFollows` reads it.
* *
* @module * @module
*/ */
@@ -42,7 +41,10 @@ export const defaultFollowValue: FollowContextValue = {
isLoaded: false, isLoaded: false,
}; };
/** Holds the signed-in user's follows. Read it with `useFollows`. */ /**
* Tells the Follow buttons what the signed-in user follows. Read it with
* `useFollows`.
*/
export const FollowContext: React.Context<FollowContextValue> = createContext< export const FollowContext: React.Context<FollowContextValue> = createContext<
FollowContextValue FollowContextValue
>( >(

View File

@@ -1,6 +1,7 @@
/** /**
* Provides `FollowContext`, from `GET /api/follows/status`. `App` mounts it * Keeps what the signed-in user follows for the Follow buttons, loaded from
* around every page. * `GET /api/follows/status` and again when another tab changes it. `App` mounts
* it around every page.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Declares the theme's React context. `ThemeProvider` fills it and `useTheme` * Shares the chosen theme with every component. `ThemeProvider` fills this
* reads it. * context and `useTheme` reads it.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,7 @@
/** /**
* Holds the chosen style and color scheme, stores them in localStorage and * Lets each browser pick the site's style and color scheme. It holds the
* applies them to the document. `App` mounts it outermost. * choice, stores it in localStorage and applies it to the document. `App`
* mounts it outermost.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,7 @@
/** /**
* Declares the context through which `Avatar` learns of avatars changed since * Shows a user's new avatar everywhere as soon as they change it, without a
* the page loaded. `WSProvider` fills it from `user_updated` messages. * reload. `WSProvider` fills this context from `user_updated` messages, and
* `Avatar` reads it.
* *
* @module * @module
*/ */
@@ -21,8 +22,9 @@ export interface AvatarOverride {
} }
/** /**
* Holds an {@link AvatarOverride} by user id. It is apart from `WSContext`, so * Shows changed avatars without a reload, as an {@link AvatarOverride} by user
* the many avatars of a page don't re-render on every vote or comment. * id. It is apart from `WSContext`, so the many avatars of a page don't
* re-render on every vote or comment.
*/ */
export const AvatarOverrideContext: React.Context< export const AvatarOverrideContext: React.Context<
Record<string, AvatarOverride> Record<string, AvatarOverride>

View File

@@ -1,7 +1,10 @@
/** /**
* Declares the three contexts `WSProvider` fills from the WebSocket, and the * Live updates from the server reach the pages through the three contexts
* types they carry. They are split by how often each part changes, so that a * declared here: votes and likes, dumps, playlists, comments, profiles and
* feed doesn't re-render with every chat message or presence change. * notifications in one, the chat in another, who is online in the third. They
* are split so that a feed doesn't re-render with every chat message or
* presence change. `WSProvider` fills them from its WebSocket, and components
* read them with `useWS`, `useWSChat` and `useWSPresence`.
* *
* @module * @module
*/ */
@@ -16,8 +19,9 @@ import type { Playlist } from "../../../shared/playlist.ts";
import type { PublicUser } from "../../../shared/user.ts"; import type { PublicUser } from "../../../shared/user.ts";
/** /**
* Set as `lastVoteEvent` by `WSProvider` on a `votes_update`. The profile * Keeps a user's upvoted list live, on their profile and on `UserUpvoted`.
* pages read it to add or drop a dump from a user's upvoted list. * `WSProvider` sets it as `lastVoteEvent` on each `votes_update`, and
* `useUpvotedList` adds or drops the dump.
*/ */
export interface VoteEvent { export interface VoteEvent {
dumpId: string; dumpId: string;
@@ -26,9 +30,11 @@ export interface VoteEvent {
} }
/** /**
* Set as `lastPlaylistEvent` by `WSProvider` on each `playlist_*` message. * Keeps the playlists on screen up to date, in lists, on a playlist's page and
* The pages that show playlists apply it, some through `usePlaylistListSync`. * on a dump's page. `WSProvider` sets it as `lastPlaylistEvent` on each
* Which fields are set depends on `type`. * `playlist_*` message; lists apply it through `usePlaylistListSync`, a
* playlist's page through `usePlaylist`. Which fields are set depends on
* `type`.
*/ */
export interface PlaylistEvent { export interface PlaylistEvent {
type: "created" | "updated" | "deleted" | "dumps_updated"; type: "created" | "updated" | "deleted" | "dumps_updated";
@@ -47,9 +53,9 @@ export interface PlaylistEvent {
} }
/** /**
* Set as `lastCommentEvent` by `WSProvider` on each `comment_*` message. The * Keeps comment threads and comment counts live. `WSProvider` sets it as
* `Dump` page applies it to its thread, and `useDumpListSync` to its comment * `lastCommentEvent` on each `comment_*` message; the `Dump` page applies it
* counts. * to its thread, and `useDumpListSync` to the counts on dump cards.
*/ */
export interface CommentEvent { export interface CommentEvent {
type: "created" | "deleted" | "updated"; type: "created" | "deleted" | "updated";
@@ -61,16 +67,18 @@ export interface CommentEvent {
} }
/** /**
* Set as `lastUserEvent` by `WSProvider` on a `user_updated`, which the * Keeps a profile page live while its user edits their profile. `WSProvider`
* server sends on any change to a profile. `useProfile` applies it. * sets it as `lastUserEvent` on each `user_updated`, which the server sends on
* any change to a profile, and `useProfile` applies it.
*/ */
export interface UserEvent { export interface UserEvent {
user: PublicUser; user: PublicUser;
} }
/** /**
* Chosen in `ChatModal` for the reply bar, and kept on the pending message so * Quotes the message a chat message replies to. `ChatModal` shows it in the
* it shows the reply before the server's echo. Only `id` goes to the server. * reply bar once the user picks it, and it stays on the pending message so
* the quote shows before the server's echo. Only `id` goes to the server.
*/ */
export interface ChatReplyTarget { export interface ChatReplyTarget {
id: string; id: string;
@@ -79,9 +87,9 @@ export interface ChatReplyTarget {
} }
/** /**
* Held by `WSProvider` for a chat message this client sent and the server * Shows a chat message the moment the user sends it, and lets them retry it if
* hasn't echoed back yet. `ChatModal` shows it, as `PendingItem`, after the * it fails. `WSProvider` holds it until the server echoes the message back, and
* messages the server has. * `ChatModal` shows it, as `PendingItem`, after the messages the server has.
*/ */
export interface PendingChatMessage { export interface PendingChatMessage {
/** /**
@@ -102,8 +110,8 @@ export interface PendingChatMessage {
} }
/** /**
* Set as `chatError` by `WSProvider` when the server refuses a chat edit or * Tells the user the server refused their chat edit or delete. `WSProvider`
* delete. `ChatModal` shows it for a few seconds. * sets it as `chatError`, and `ChatModal` shows it for a few seconds.
*/ */
export interface ChatError { export interface ChatError {
/** Tells apart two errors with the same message. */ /** Tells apart two errors with the same message. */
@@ -112,60 +120,75 @@ export interface ChatError {
} }
/** /**
* Provided by `WSProvider` through {@link WSContext}, read with `useWS`. The * Carries everything live but the chat and presence, for pages to lay over
* `last*Event` fields hold only the latest event of their kind: a consumer * what they fetched. `WSProvider` provides it through {@link WSContext}, and
* reacts in an effect when one changes, and an event that another replaces * components read it with `useWS`. Each `last*Event` field holds only the
* within the same render is missed. After a reconnect, `connectionEpoch` tells * latest event of its kind: a consumer reacts in an effect when one changes,
* pages to fetch what they missed. * and an event that another replaces within the same render is missed. After a
* reconnect, `connectionEpoch` tells pages to fetch what they missed.
*/ */
export interface WSContextValue { export interface WSContextValue {
wsStatus: "connecting" | "connected" | "disconnected"; wsStatus: "connecting" | "connected" | "disconnected";
/** Set while the socket is down, for the banner that says so. */ /** Set while the socket is down, for the banner that says so. */
wsErrorMessage: string | null; wsErrorMessage: string | null;
/** /**
* Counts known to the socket, by dump id: pushed by the server, or changed * Shows vote counts live. It holds, by dump id, the counts the server pushed
* optimistically. A missing dump shows its fetched `voteCount`. * or this tab changed optimistically; a dump missing from it shows its
* fetched `voteCount`.
*/ */
voteCounts: Record<string, number>; voteCounts: Record<string, number>;
/** Reset from the server's `welcome` on every connection. */ /**
* Marks the dumps this user upvoted, for their vote buttons. The server's
* `welcome` resets it on every connection.
*/
myVotes: Set<string>; myVotes: Set<string>;
/** As `voteCounts`, for comments. */ /** As `voteCounts`, for comments. */
likeCounts: Record<string, number>; likeCounts: Record<string, number>;
/** As `myVotes`, for comments. */ /** As `myVotes`, for comments. */
myLikes: Set<string>; myLikes: Set<string>;
/** /**
* Dumps that arrived this session, newest first and at most 100: the ones * Shows new dumps on the index as they are posted, above the feed `Index`
* the server announced, which are public, and the ones this user posted, * fetched. It holds, newest first and at most 100, the ones the server
* which may be private. `Index` puts them above the feed it fetched. * announced this session, which are public, and the ones this user posted,
* which may be private.
*/ */
recentDumps: Dump[]; recentDumps: Dump[];
/** /**
* It holds the dumps deleted this session, or made private by someone else, * Takes deleted dumps off every list at once. It holds the dumps deleted this
* for the lists to filter out. A dump made public again leaves it. * session, or made private by someone else; a dump made public again leaves
* it.
*/ */
deletedDumpIds: Set<string>; deletedDumpIds: Set<string>;
lastVoteEvent: VoteEvent | null; lastVoteEvent: VoteEvent | null;
/** /**
* It holds the dump as its latest `dump_created` or `dump_updated` has it, * Keeps a dump up to date in every list that shows it. It holds the dump as
* for the lists to update it in place, or put it back where it was. * its latest `dump_created` or `dump_updated` has it, and a list updates it
* in place, or puts it back where it was.
*/ */
lastDumpEvent: Dump | null; lastDumpEvent: Dump | null;
lastPlaylistEvent: PlaylistEvent | null; lastPlaylistEvent: PlaylistEvent | null;
deletedPlaylistIds: Set<string>; deletedPlaylistIds: Set<string>;
lastCommentEvent: CommentEvent | null; lastCommentEvent: CommentEvent | null;
lastUserEvent: UserEvent | null; lastUserEvent: UserEvent | null;
/** Read from the `welcome`, then counted up by each new notification. */ /**
* Drives the bell's badge. It comes from the `welcome`, then each new
* notification counts it up.
*/
unreadNotificationCount: number; unreadNotificationCount: number;
/** Read by the bell, which rings, and by the notifications page. */ /**
* Makes the bell ring, and puts the notification on the notifications page
* as it arrives.
*/
lastNotification: Notification | null; lastNotification: Notification | null;
/** /**
* Incremented on each reconnect, never on the first connection, so pages * Tells pages to fetch again what they missed while the socket was down. It
* fetch again what they missed while the socket was down. * goes up on each reconnect, never on the first connection.
*/ */
connectionEpoch: number; connectionEpoch: number;
/** /**
* Incremented when the user follows or unfollows something in another tab * Keeps the Follow buttons right when the user follows or unfollows in
* (or this one), so `FollowProvider` fetches the follow status again. * another tab. It goes up on every follow change, in any tab, and
* `FollowProvider` fetches the follow status again.
*/ */
followsVersion: number; followsVersion: number;
/** /**
@@ -182,8 +205,9 @@ export interface WSContextValue {
/** Withdraws a like, as {@link WSContextValue.castVote} casts a vote. */ /** Withdraws a like, as {@link WSContextValue.castVote} casts a vote. */
removeCommentLike: (commentId: string, shownCount?: number) => void; removeCommentLike: (commentId: string, shownCount?: number) => void;
/** /**
* Adds a dump this user just posted to `recentDumps`, without waiting for * Shows a dump this user just posted on the index without waiting for its
* its broadcast, which a private dump never gets. `DumpCreateModal` calls it. * broadcast, which a private dump never gets. `DumpCreateModal` calls it,
* and it adds the dump to `recentDumps`.
*/ */
injectDump: (dump: Dump) => void; injectDump: (dump: Dump) => void;
/** /**
@@ -194,36 +218,43 @@ export interface WSContextValue {
} }
/** /**
* Provided by `WSProvider` through {@link WSChatContext}, read with * Carries the chat's live side, which `ChatModal` lays over the history it
* `useWSChat`. The socket sends chat messages only to signed-in users, so for * fetches and the chat buttons show as a badge. `WSProvider` provides it
* a visitor it stays empty. * through {@link WSChatContext}, and components read it with `useWSChat`. The
* socket sends chat only to signed-in users, so for a visitor it stays empty.
*/ */
export interface WSChatContextValue { export interface WSChatContextValue {
/** /**
* The messages received this session, at most 200, for `ChatModal` to merge * Shows chat messages as they arrive. It holds the ones received this
* with the history it fetches. A reconnect empties it while the chat is * session, at most 200, which `ChatModal` merges with the history it
* closed, since `ChatModal` fetches everything again on open. * fetches. A reconnect empties it while the chat is closed, since `ChatModal`
* fetches everything again on open.
*/ */
chatMessages: ChatMessage[]; chatMessages: ChatMessage[];
/** /**
* Messages deleted this session, which `ChatModal` filters out of its * Takes the messages deleted this session out of the history `ChatModal`
* fetched history. * fetched.
*/ */
deletedChatIds: Set<string>; deletedChatIds: Set<string>;
/** /**
* Messages from others after the read marker. The server keeps the marker, * Drives the chat buttons' unread badge. It counts the messages from others
* so the count agrees across tabs and devices. * after the read marker, which the server keeps, so the count agrees across
* tabs and devices.
*/ */
unreadChatCount: number; unreadChatCount: number;
/** /**
* The time of the newest message the user has seen, which the server keeps. * Places the "new" divider when the chat opens. It holds the time of the
* Null until the `welcome`, and for a visitor. * newest message the user has seen, which the server keeps, and is null
* until the `welcome`, and for a visitor.
*/ */
chatReadAt: Date | null; chatReadAt: Date | null;
pendingChatMessages: PendingChatMessage[]; pendingChatMessages: PendingChatMessage[];
/** Only for edits and deletes. A failed send shows on its pending message. */ /** Only for edits and deletes. A failed send shows on its pending message. */
chatError: ChatError | null; chatError: ChatError | null;
/** Includes the user when they are typing: `ChatModal` filters them out. */ /**
* Shows who is typing. It includes the user themselves, whom `ChatModal`
* filters out.
*/
typingUsers: OnlineUser[]; typingUsers: OnlineUser[];
/** Trims `body` and sends it as a pending message. Ignores a blank body. */ /** Trims `body` and sends it as a pending message. Ignores a blank body. */
sendChatMessage: (body: string, replyTo?: ChatReplyTarget) => void; sendChatMessage: (body: string, replyTo?: ChatReplyTarget) => void;
@@ -244,10 +275,10 @@ export interface WSChatContextValue {
*/ */
markChatRead: (at: Date) => void; markChatRead: (at: Date) => void;
/** /**
* Replaces the live buffer's messages between `from` and `to` with * Repairs the live buffer after a reconnect. It replaces its messages between
* `messages`, which `ChatModal` fetched after a reconnect. A message missing * `from` and `to` with `messages`, which `ChatModal` fetched. A message
* from them was deleted while the socket was down. A failed send found among * missing from them was deleted while the socket was down. A failed send
* them did reach the server, and leaves the pending list. * found among them did reach the server, and leaves the pending list.
*/ */
reconcileChat: (messages: ChatMessage[], from: Date, to: Date) => void; reconcileChat: (messages: ChatMessage[], from: Date, to: Date) => void;
/** /**
@@ -257,23 +288,27 @@ export interface WSChatContextValue {
*/ */
setChatTyping: (typing: boolean) => void; setChatTyping: (typing: boolean) => void;
/** /**
* Tells the server whether the user is reading the chat: open, in a visible * Spares the user a notification for a mention they read live. It tells the
* tab. The server sends no notification for a mention the user reads live, * server whether the chat is open in a visible tab; while it is, the server
* and this client doesn't count the message unread. * sends no notification for a mention, and this client doesn't count the
* message unread.
*/ */
setChatOpen: (open: boolean) => void; setChatOpen: (open: boolean) => void;
} }
/** /**
* Provided by `WSProvider` through {@link WSPresenceContext}, read with * Carries who is online, for `PresenceRow`. `WSProvider` provides it through
* `useWSPresence`. * {@link WSPresenceContext}, and components read it with `useWSPresence`.
*/ */
export interface WSPresenceContextValue { export interface WSPresenceContextValue {
/** The signed-in users with a socket open, each once. */ /** The signed-in users with a socket open, each once. */
onlineUsers: OnlineUser[]; onlineUsers: OnlineUser[];
} }
/** Given to a consumer outside `WSProvider`, as if it were still connecting. */ /**
* Lets a component render outside `WSProvider`, as if the socket were
* connecting.
*/
export const defaultWSValue: WSContextValue = { export const defaultWSValue: WSContextValue = {
wsStatus: "connecting", wsStatus: "connecting",
wsErrorMessage: null, wsErrorMessage: null,
@@ -301,7 +336,7 @@ export const defaultWSValue: WSContextValue = {
clearUnreadNotifications: () => {}, clearUnreadNotifications: () => {},
}; };
/** Given to a consumer outside `WSProvider`: an empty chat. */ /** Lets a component render outside `WSProvider`, with an empty chat. */
export const defaultWSChatValue: WSChatContextValue = { export const defaultWSChatValue: WSChatContextValue = {
chatMessages: [], chatMessages: [],
deletedChatIds: new Set(), deletedChatIds: new Set(),
@@ -322,25 +357,25 @@ export const defaultWSChatValue: WSChatContextValue = {
setChatOpen: () => {}, setChatOpen: () => {},
}; };
/** Given to a consumer outside `WSProvider`: nobody is online. */ /** Lets a component render outside `WSProvider`, with nobody online. */
export const defaultWSPresenceValue: WSPresenceContextValue = { export const defaultWSPresenceValue: WSPresenceContextValue = {
onlineUsers: [], onlineUsers: [],
}; };
/** /**
* Holds the connection, the votes and likes, and the latest live event of * Carries the live state pages lay over what they fetched: the connection, the
* each kind. Read it with `useWS`. * votes and likes, and the latest event of each kind. Read it with `useWS`.
*/ */
export const WSContext: React.Context<WSContextValue> = createContext< export const WSContext: React.Context<WSContextValue> = createContext<
WSContextValue WSContextValue
>(defaultWSValue); >(defaultWSValue);
/** Holds the chat's live stream and read state. Read it with `useWSChat`. */ /** Carries the chat's live stream and read state. Read it with `useWSChat`. */
export const WSChatContext: React.Context<WSChatContextValue> = createContext< export const WSChatContext: React.Context<WSChatContextValue> = createContext<
WSChatContextValue WSChatContextValue
>( >(
defaultWSChatValue, defaultWSChatValue,
); );
/** Holds who is online. Read it with `useWSPresence`. */ /** Carries who is online. Read it with `useWSPresence`. */
export const WSPresenceContext: React.Context<WSPresenceContextValue> = export const WSPresenceContext: React.Context<WSPresenceContextValue> =
createContext< createContext<
WSPresenceContextValue WSPresenceContextValue

View File

@@ -1,8 +1,9 @@
/** /**
* Holds the app's one WebSocket to `/ws` and turns its messages into the state * Brings the server's live updates into the app. It holds the one WebSocket to
* of `WSContext`, `WSChatContext`, `WSPresenceContext` and * `/ws` and turns its messages into the state of `WSContext`, `WSChatContext`,
* `AvatarOverrideContext`. `App` mounts it around every page. The protocol is * `WSPresenceContext` and `AvatarOverrideContext`. `App` mounts it around every
* `shared/ws.ts`, and the server's side is `api/routes/ws.ts`. * page. The protocol is `shared/ws.ts`, and the server's side is
* `api/routes/ws.ts`.
* *
* @module * @module
*/ */
@@ -94,9 +95,9 @@ const CHAT_READ_THROTTLE = 1_000;
const CHAT_ECHO_WINDOW = 60_000; const CHAT_ECHO_WINDOW = 60_000;
/** /**
* Held by {@link useOptimisticToggles} for an item whose toggles the server * Lets a failed toggle restore what was shown before the first click the server
* hasn't all acknowledged yet. `count` and `on` are as before the first of * hasn't acknowledged. {@link useOptimisticToggles} holds one for each such
* them, for a failure to restore. * item; `count` and `on` are as they were before that click.
*/ */
interface PendingToggle { interface PendingToggle {
timeout: ReturnType<typeof setTimeout>; timeout: ReturnType<typeof setTimeout>;

View File

@@ -1,5 +1,5 @@
/** /**
* Reads `ChatContext`, for the buttons that open the chat. * Lets a button open the chat, by reading `ChatContext`.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Loads a user's dumps or upvoted dumps, a page at a time, for the profile * Feeds the profile subpages that list all of a user's dumps, or all they
* subpages that list them in full. * upvoted, loading them a page at a time.
* *
* @module * @module
*/ */

View File

@@ -1,7 +1,7 @@
/** /**
* Keeps the state of `TextEditor`'s emoji picker, which opens on a typed * Lets a user insert an emoji by typing `:query` or from the editor's button.
* `:query` or from the editor's button. `TextEditor` renders the picker and * It keeps the state of `TextEditor`'s emoji picker, and `TextEditor` renders
* handles its keys. * the picker and handles its keys.
* *
* @module * @module
*/ */

View File

@@ -1,7 +1,7 @@
/** /**
* Keeps the state of `TextEditor`'s `@mention` suggestions, which it fetches * Suggests users to @mention as the user types in `TextEditor`. It keeps the
* from `GET /api/users/search`. `TextEditor` renders them in a * state of the suggestions, fetched from `GET /api/users/search`, and
* `MentionDropdown`. * `TextEditor` renders them in a `MentionDropdown`.
* *
* @module * @module
*/ */

View File

@@ -1,5 +1,6 @@
/** /**
* Stores, in this browser, which feed tab the index opens on. * Lets a user choose which feed tab the index opens on, remembered in this
* browser.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Times the fade-out of items leaving a list, so that a removal made by * Lets a removal made by mistake be undone before the item leaves the list, by
* mistake can be undone before the item is gone. * timing the fade-out of items leaving a list.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,7 @@
/** /**
* Keeps a feed's loaded pages and scroll position in sessionStorage, so that * Shows a feed as it was left when the user comes back to it, instead of
* going back to a feed page shows it as it was left instead of refetching it. * fetching it again. It keeps the feed's loaded pages and scroll position in
* sessionStorage.
* *
* @module * @module
*/ */

View File

@@ -1,7 +1,7 @@
/** /**
* Loads a list a page at a time. Every list the API pages goes through * Gives every paged list of the app the same loading. Every list the API pages
* {@link useLoadMore} for its next pages, and a list that loads on its own * goes through {@link useLoadMore} for its next pages, and a list that loads on
* through {@link usePagedList}, from its first page on. * its own through {@link usePagedList}, from its first page on.
* *
* @module * @module
*/ */

View File

@@ -1,5 +1,7 @@
/** /**
* react-hook-form, with the shared lifecycle of a form that posts to the API. * Gives every form that posts to the API the same submit, which shows a failed
* request as the form's root error. It is react-hook-form's `useForm` plus that
* `submit`.
* *
* @module * @module
*/ */
@@ -15,7 +17,7 @@ import {
import { friendlyFetchError } from "../../utils/shared/api.ts"; import { friendlyFetchError } from "../../utils/shared/api.ts";
/** What {@link useApiForm} returns: the form, and `submit`. */ /** Returned by {@link useApiForm}, the form with `submit` added. */
export interface UseApiFormResult<T extends FieldValues> export interface UseApiFormResult<T extends FieldValues>
extends UseFormReturn<T> { extends UseFormReturn<T> {
/** /**

View File

@@ -1,6 +1,7 @@
/** /**
* Loads a playlist with its dumps for its page, keeps them live, and makes the * Everything a playlist's page shows and changes goes through this hook. It
* changes its owner or a moderator makes there. * loads the playlist with its dumps, keeps them live, and makes the changes its
* owner or a moderator makes there.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,7 @@
/** /**
* Loads which of the signed-in user's playlists hold a dump, and adds the dump * Lets the user add a dump to their playlists or take it out, in
* to them or takes it out. * `AddToPlaylistModal` and the dump form's last step. It loads which of their
* playlists hold the dump.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,7 @@
/** /**
* Loads a profile's user with the first page of their dumps, upvotes and * Everything a profile page shows comes through this hook. It loads the user
* playlists, and keeps them live. * with the first page of their dumps, upvotes and playlists, and keeps them
* live.
* *
* @module * @module
*/ */

View File

@@ -1,5 +1,6 @@
/** /**
* Loads one thing from the API, once per key, when first asked to. * Lets a component load one thing from the API the first time it's needed, and
* keep it for as long as its key holds, as the profile's lazy tabs do.
* *
* @module * @module
*/ */

View File

@@ -1,5 +1,6 @@
/** /**
* Reads `FollowContext`, for the Follow buttons. * Tells a Follow button what the user follows, and follows or unfollows, by
* reading `FollowContext`.
* *
* @module * @module
*/ */

View File

@@ -1,7 +1,7 @@
/** /**
* Reads the live state that `WSProvider` keeps from the WebSocket. Each hook * Gives components the server's live updates. Each hook reads one of the three
* reads one of its three contexts, so that a component re-renders only with * contexts `WSProvider` fills, so that a component re-renders only with the
* the part it uses. Outside `WSProvider`, they return inert defaults. * part it uses. Outside `WSProvider`, they return inert defaults.
* *
* @module * @module
*/ */

View File

@@ -200,7 +200,7 @@ msgstr "Already dumped by {0} {1}"
msgid "Already have an account? <0>Log in</0>" msgid "Already have an account? <0>Log in</0>"
msgstr "Already have an account? <0>Log in</0>" msgstr "Already have an account? <0>Log in</0>"
#: src/utils/shared/api.ts:34 #: src/utils/shared/api.ts:35
msgid "An unexpected error occurred." msgid "An unexpected error occurred."
msgstr "An unexpected error occurred." msgstr "An unexpected error occurred."
@@ -228,8 +228,8 @@ msgstr "Auto"
msgid "Back to login" msgid "Back to login"
msgstr "Back to login" msgstr "Back to login"
#: src/contexts/ws/WSProvider.tsx:394 #: src/contexts/ws/WSProvider.tsx:395
#: src/contexts/ws/WSProvider.tsx:760 #: src/contexts/ws/WSProvider.tsx:761
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects." msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
msgstr "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects." msgstr "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
@@ -341,7 +341,7 @@ msgstr "Color scheme"
msgid "Confirm new password" msgid "Confirm new password"
msgstr "Confirm new password" msgstr "Confirm new password"
#: src/contexts/ws/WSProvider.tsx:753 #: src/contexts/ws/WSProvider.tsx:754
msgid "Connection lost — this message may not have been sent" msgid "Connection lost — this message may not have been sent"
msgstr "Connection lost — this message may not have been sent" msgstr "Connection lost — this message may not have been sent"
@@ -357,7 +357,7 @@ msgstr "Copy"
msgid "Could not change password" msgid "Could not change password"
msgstr "Could not change password" msgstr "Could not change password"
#: src/utils/shared/api.ts:32 #: src/utils/shared/api.ts:33
msgid "Could not connect to the server. Check your connection and try again." msgid "Could not connect to the server. Check your connection and try again."
msgstr "Could not connect to the server. Check your connection and try again." msgstr "Could not connect to the server. Check your connection and try again."
@@ -699,7 +699,7 @@ msgstr "Follow some public playlists to see their dumps here."
msgid "Follow some users to see their dumps here." msgid "Follow some users to see their dumps here."
msgstr "Follow some users to see their dumps here." msgstr "Follow some users to see their dumps here."
#: src/components/feed/FeedTabBar.tsx:48 #: src/components/feed/FeedTabBar.tsx:49
#: src/components/profile/ProfileSettingsTab.tsx:44 #: src/components/profile/ProfileSettingsTab.tsx:44
#: src/pages/UserPublicProfile.tsx:191 #: src/pages/UserPublicProfile.tsx:191
msgid "Followed" msgid "Followed"
@@ -745,7 +745,7 @@ msgstr "Go home"
msgid "Go to login" msgid "Go to login"
msgstr "Go to login" msgstr "Go to login"
#: src/components/feed/FeedTabBar.tsx:44 #: src/components/feed/FeedTabBar.tsx:45
#: src/components/profile/ProfileSettingsTab.tsx:41 #: src/components/profile/ProfileSettingsTab.tsx:41
msgid "Hot" msgid "Hot"
msgstr "Hot" msgstr "Hot"
@@ -789,7 +789,7 @@ msgstr "invited by"
msgid "Invitees" msgid "Invitees"
msgstr "Invitees" msgstr "Invitees"
#: src/components/feed/FeedTabBar.tsx:46 #: src/components/feed/FeedTabBar.tsx:47
#: src/components/profile/ProfileSettingsTab.tsx:43 #: src/components/profile/ProfileSettingsTab.tsx:43
msgid "Journal" msgid "Journal"
msgstr "Journal" msgstr "Journal"
@@ -814,7 +814,7 @@ msgstr "Link"
msgid "Link or file" msgid "Link or file"
msgstr "Link or file" msgstr "Link or file"
#: src/contexts/ws/WSProvider.tsx:759 #: src/contexts/ws/WSProvider.tsx:760
msgid "Live updates are temporarily disconnected. Trying to reconnect…" msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Live updates are temporarily disconnected. Trying to reconnect…" msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
@@ -919,7 +919,7 @@ msgid "new"
msgstr "new" msgstr "new"
#: src/components/chat/ChatModal.tsx:669 #: src/components/chat/ChatModal.tsx:669
#: src/components/feed/FeedTabBar.tsx:45 #: src/components/feed/FeedTabBar.tsx:46
#: src/components/profile/ProfileSettingsTab.tsx:42 #: src/components/profile/ProfileSettingsTab.tsx:42
msgid "New" msgid "New"
msgstr "New" msgstr "New"
@@ -993,7 +993,7 @@ msgstr "No playlists match \"{q}\"."
msgid "No playlists yet." msgid "No playlists yet."
msgstr "No playlists yet." msgstr "No playlists yet."
#: src/contexts/ws/WSProvider.tsx:928 #: src/contexts/ws/WSProvider.tsx:929
msgid "No reply from the server — this message may not have been sent" msgid "No reply from the server — this message may not have been sent"
msgstr "No reply from the server — this message may not have been sent" msgstr "No reply from the server — this message may not have been sent"
@@ -1351,7 +1351,7 @@ msgstr "Some images couldn't be uploaded."
#: src/components/form/FormError.tsx:31 #: src/components/form/FormError.tsx:31
#: src/components/layout/PageError.tsx:29 #: src/components/layout/PageError.tsx:29
#: src/contexts/ws/WSProvider.tsx:728 #: src/contexts/ws/WSProvider.tsx:729
#: src/pages/PlaylistDetail.tsx:119 #: src/pages/PlaylistDetail.tsx:119
msgid "Something went wrong" msgid "Something went wrong"
msgstr "Something went wrong" msgstr "Something went wrong"
@@ -1380,7 +1380,7 @@ msgstr "That file is over {limit}."
msgid "the chat" msgid "the chat"
msgstr "the chat" msgstr "the chat"
#: src/hooks/playlist/usePlaylist.ts:160 #: src/hooks/playlist/usePlaylist.ts:161
msgid "The dump couldn't be removed. Try again." msgid "The dump couldn't be removed. Try again."
msgstr "The dump couldn't be removed. Try again." msgstr "The dump couldn't be removed. Try again."
@@ -1388,7 +1388,7 @@ msgstr "The dump couldn't be removed. Try again."
msgid "The image couldn't be uploaded." msgid "The image couldn't be uploaded."
msgstr "The image couldn't be uploaded." msgstr "The image couldn't be uploaded."
#: src/hooks/playlist/usePlaylist.ts:306 #: src/hooks/playlist/usePlaylist.ts:307
msgid "The new order couldn't be saved. Try again." msgid "The new order couldn't be saved. Try again."
msgstr "The new order couldn't be saved. Try again." msgstr "The new order couldn't be saved. Try again."
@@ -1448,7 +1448,7 @@ msgstr "Type a message…"
msgid "Undo" msgid "Undo"
msgstr "Undo" msgstr "Undo"
#: src/utils/shared/api.ts:21 #: src/utils/shared/api.ts:22
msgid "Unexpected response format" msgid "Unexpected response format"
msgstr "Unexpected response format" msgstr "Unexpected response format"
@@ -1580,7 +1580,7 @@ msgstr "You can post it anyway."
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content." msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
msgstr "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content." msgstr "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
#: src/contexts/ws/WSProvider.tsx:909 #: src/contexts/ws/WSProvider.tsx:910
msgid "You're offline — not sent" msgid "You're offline — not sent"
msgstr "You're offline — not sent" msgstr "You're offline — not sent"

View File

@@ -200,7 +200,7 @@ msgstr "Déjà recommandé par {0} {1}"
msgid "Already have an account? <0>Log in</0>" msgid "Already have an account? <0>Log in</0>"
msgstr "Vous avez déjà un compte ? <0>Se connecter</0>" msgstr "Vous avez déjà un compte ? <0>Se connecter</0>"
#: src/utils/shared/api.ts:34 #: src/utils/shared/api.ts:35
msgid "An unexpected error occurred." msgid "An unexpected error occurred."
msgstr "Une erreur inattendue s'est produite." msgstr "Une erreur inattendue s'est produite."
@@ -228,8 +228,8 @@ msgstr "Auto"
msgid "Back to login" msgid "Back to login"
msgstr "Retour à la connexion" msgstr "Retour à la connexion"
#: src/contexts/ws/WSProvider.tsx:394 #: src/contexts/ws/WSProvider.tsx:395
#: src/contexts/ws/WSProvider.tsx:760 #: src/contexts/ws/WSProvider.tsx:761
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects." msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion." msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion."
@@ -341,7 +341,7 @@ msgstr "Thème de couleur"
msgid "Confirm new password" msgid "Confirm new password"
msgstr "Confirmer le nouveau mot de passe" msgstr "Confirmer le nouveau mot de passe"
#: src/contexts/ws/WSProvider.tsx:753 #: src/contexts/ws/WSProvider.tsx:754
msgid "Connection lost — this message may not have been sent" msgid "Connection lost — this message may not have been sent"
msgstr "Connexion perdue — ce message n'a peut-être pas été envoyé" msgstr "Connexion perdue — ce message n'a peut-être pas été envoyé"
@@ -357,7 +357,7 @@ msgstr "Copier"
msgid "Could not change password" msgid "Could not change password"
msgstr "Impossible de changer le mot de passe" msgstr "Impossible de changer le mot de passe"
#: src/utils/shared/api.ts:32 #: src/utils/shared/api.ts:33
msgid "Could not connect to the server. Check your connection and try again." msgid "Could not connect to the server. Check your connection and try again."
msgstr "Impossible de joindre le serveur. Vérifiez votre connexion et réessayez." msgstr "Impossible de joindre le serveur. Vérifiez votre connexion et réessayez."
@@ -699,7 +699,7 @@ msgstr "Suivez des collections publiques pour voir leurs recos ici."
msgid "Follow some users to see their dumps here." msgid "Follow some users to see their dumps here."
msgstr "Suivez des utilisateurs pour voir leurs recos ici." msgstr "Suivez des utilisateurs pour voir leurs recos ici."
#: src/components/feed/FeedTabBar.tsx:48 #: src/components/feed/FeedTabBar.tsx:49
#: src/components/profile/ProfileSettingsTab.tsx:44 #: src/components/profile/ProfileSettingsTab.tsx:44
#: src/pages/UserPublicProfile.tsx:191 #: src/pages/UserPublicProfile.tsx:191
msgid "Followed" msgid "Followed"
@@ -745,7 +745,7 @@ msgstr "Accueil"
msgid "Go to login" msgid "Go to login"
msgstr "Aller à la connexion" msgstr "Aller à la connexion"
#: src/components/feed/FeedTabBar.tsx:44 #: src/components/feed/FeedTabBar.tsx:45
#: src/components/profile/ProfileSettingsTab.tsx:41 #: src/components/profile/ProfileSettingsTab.tsx:41
msgid "Hot" msgid "Hot"
msgstr "Tendances" msgstr "Tendances"
@@ -789,7 +789,7 @@ msgstr "invité par"
msgid "Invitees" msgid "Invitees"
msgstr "Invités" msgstr "Invités"
#: src/components/feed/FeedTabBar.tsx:46 #: src/components/feed/FeedTabBar.tsx:47
#: src/components/profile/ProfileSettingsTab.tsx:43 #: src/components/profile/ProfileSettingsTab.tsx:43
msgid "Journal" msgid "Journal"
msgstr "Journal" msgstr "Journal"
@@ -814,7 +814,7 @@ msgstr "Lien"
msgid "Link or file" msgid "Link or file"
msgstr "Lien ou fichier" msgstr "Lien ou fichier"
#: src/contexts/ws/WSProvider.tsx:759 #: src/contexts/ws/WSProvider.tsx:760
msgid "Live updates are temporarily disconnected. Trying to reconnect…" msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…" msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
@@ -919,7 +919,7 @@ msgid "new"
msgstr "nouveau" msgstr "nouveau"
#: src/components/chat/ChatModal.tsx:669 #: src/components/chat/ChatModal.tsx:669
#: src/components/feed/FeedTabBar.tsx:45 #: src/components/feed/FeedTabBar.tsx:46
#: src/components/profile/ProfileSettingsTab.tsx:42 #: src/components/profile/ProfileSettingsTab.tsx:42
msgid "New" msgid "New"
msgstr "Nouveau" msgstr "Nouveau"
@@ -993,7 +993,7 @@ msgstr "Aucune collection ne correspond à « {q} »."
msgid "No playlists yet." msgid "No playlists yet."
msgstr "Pas encore de collections." msgstr "Pas encore de collections."
#: src/contexts/ws/WSProvider.tsx:928 #: src/contexts/ws/WSProvider.tsx:929
msgid "No reply from the server — this message may not have been sent" msgid "No reply from the server — this message may not have been sent"
msgstr "Pas de réponse du serveur — ce message n'a peut-être pas été envoyé" msgstr "Pas de réponse du serveur — ce message n'a peut-être pas été envoyé"
@@ -1351,7 +1351,7 @@ msgstr "Certaines images n'ont pas pu être envoyées."
#: src/components/form/FormError.tsx:31 #: src/components/form/FormError.tsx:31
#: src/components/layout/PageError.tsx:29 #: src/components/layout/PageError.tsx:29
#: src/contexts/ws/WSProvider.tsx:728 #: src/contexts/ws/WSProvider.tsx:729
#: src/pages/PlaylistDetail.tsx:119 #: src/pages/PlaylistDetail.tsx:119
msgid "Something went wrong" msgid "Something went wrong"
msgstr "Une erreur est survenue" msgstr "Une erreur est survenue"
@@ -1380,7 +1380,7 @@ msgstr "Ce fichier dépasse {limit}."
msgid "the chat" msgid "the chat"
msgstr "la tribune" msgstr "la tribune"
#: src/hooks/playlist/usePlaylist.ts:160 #: src/hooks/playlist/usePlaylist.ts:161
msgid "The dump couldn't be removed. Try again." msgid "The dump couldn't be removed. Try again."
msgstr "Le dump n'a pas pu être retiré. Réessayez." msgstr "Le dump n'a pas pu être retiré. Réessayez."
@@ -1388,7 +1388,7 @@ msgstr "Le dump n'a pas pu être retiré. Réessayez."
msgid "The image couldn't be uploaded." msgid "The image couldn't be uploaded."
msgstr "L'image n'a pas pu être envoyée." msgstr "L'image n'a pas pu être envoyée."
#: src/hooks/playlist/usePlaylist.ts:306 #: src/hooks/playlist/usePlaylist.ts:307
msgid "The new order couldn't be saved. Try again." msgid "The new order couldn't be saved. Try again."
msgstr "Le nouvel ordre n'a pas pu être enregistré. Réessayez." msgstr "Le nouvel ordre n'a pas pu être enregistré. Réessayez."
@@ -1448,7 +1448,7 @@ msgstr "Écrivez un message…"
msgid "Undo" msgid "Undo"
msgstr "Annuler" msgstr "Annuler"
#: src/utils/shared/api.ts:21 #: src/utils/shared/api.ts:22
msgid "Unexpected response format" msgid "Unexpected response format"
msgstr "Format de réponse inattendu" msgstr "Format de réponse inattendu"
@@ -1580,7 +1580,7 @@ msgstr "Vous pouvez la publier quand même."
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content." msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
msgstr "Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vos recos ou publie du nouveau contenu." msgstr "Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vos recos ou publie du nouveau contenu."
#: src/contexts/ws/WSProvider.tsx:909 #: src/contexts/ws/WSProvider.tsx:910
msgid "You're offline — not sent" msgid "You're offline — not sent"
msgstr "Hors ligne — message non envoyé" msgstr "Hors ligne — message non envoyé"

View File

@@ -1,5 +1,5 @@
/** /**
* The page of a dump, with its preview, comments, related dumps and the * Renders a dump's page, with its preview, comments, related dumps and the
* playlists holding it. * playlists holding it.
* *
* @module * @module

View File

@@ -1,5 +1,5 @@
/** /**
* The page where a dump's poster, or a moderator, edits or deletes it. * Renders the page where a dump's poster, or a moderator, edits or deletes it.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* The page that shows the results of a search, in a tab for dumps, one for * Renders the results of a search, in a tab for dumps, one for users and one
* users and one for playlists. * for playlists.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* The page listing all of a user's dumps, which the "dumps" section of their * Renders the page listing all of a user's dumps, which the "dumps" section of
* profile links to. * their profile links to.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* The page listing all the dumps a user upvoted, which the "upvoted" section of * Renders the page listing all the dumps a user upvoted, which the "upvoted"
* their profile links to. * section of their profile links to.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Reads and writes the session in localStorage, where every tab shares one * Keeps the user signed in across reloads and tabs, in one localStorage entry
* entry. `AuthProvider` and `useAuth` are its only users. * every tab shares. `AuthProvider` and `useAuth` are its only users.
* *
* @module * @module
*/ */

View File

@@ -1,7 +1,7 @@
/** /**
* Keeps the chat composer's unsent message in sessionStorage, per user, so * Keeps the chat composer's unsent message when the chat modal closes or the
* closing the chat modal or reloading the tab doesn't lose it. `ChatModal` * tab reloads. It lives in sessionStorage, per user; `ChatModal` reads it on
* reads it on open and writes it on every change. * open and writes it on every change.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Reads the Markdown of chat messages, to find mentions and to flatten it into * Finds the mentions in a chat message, and flattens its Markdown into reply
* reply previews. * previews.
* *
* @module * @module
*/ */

View File

@@ -1,7 +1,7 @@
/** /**
* Keeps the half-written dump of the dump creation modal in localStorage, so * Gives back the half-written dump when the dump creation modal is closed by
* that closing the modal by mistake, with Escape or a click outside, loses * mistake, with Escape or a click outside. It is kept in localStorage, all but
* nothing but an attached file, which can't be stored. * an attached file, which can't be stored.
* *
* There is one draft, not one per tab: whoever closed the modal by mistake * There is one draft, not one per tab: whoever closed the modal by mistake
* wants back what they were just writing, wherever they reopen it. * wants back what they were just writing, wherever they reopen it.

Some files were not shown because too many files have changed in this diff Show More