Compare commits

...

10 Commits

Author SHA1 Message Date
khannurien
eec73e9749 v4: a comment and a chat message are edited in the same field as every text edited in place — each had an editor of its own: a comment's was its reply form, which showed a refused edit as a card and left the focus nowhere once closed, and a chat message's a box of its own with its own buttons and rules. InlineEdit gives its field, InlineEditField, to the components that open it with an action of their own, as these two do with their Edit and ✎: they keep those, a click on someone's message being no request to edit it. The field opens on the text with the caret at its end and as tall as the text, saves on Ctrl+Enter (and on Enter in the chat, where Shift+Enter breaks a line, as in its composer), leaves on Escape, sends nothing for a text left as it was, refuses an empty one, says why a save was refused in one line, and gives the focus back to the button that opened it, unless the caller has put it elsewhere, as the chat does to its composer. CommentForm only posts a comment or a reply now; ChatEditBox keeps only what is the chat's, scrolling itself into the list's view.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m20s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 44s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:16:43 +00:00
khannurien
3e03626cc1 v4: text is edited where it stands through one component, InlineEdit, and a dump's note and a playlist's title and description join its title, a profile's description and email — those three were written apart, each with its own state, keys, buttons, error and CSS, and the note and the playlist could only be changed in their full editors. InlineEdit (components/form) shows the text; a click on it, or the ✎ after it, opens a field holding it, with Save and Cancel under it. A one-line field takes the text's own type in every style, from the class it is handed (dump-title, playlist-detail-title), and sits on the faint plate the text showed under the pointer, so that nothing moves but the buttons that come; a Markdown text opens the editor comments use. Enter saves a line, Ctrl+Enter a Markdown text, Escape backs out, a text left as it was closes without a request, a required one can't be emptied, a refused save says why in one line beside the buttons, and the focus goes back to the ✎. A click on a link in the text, or one that ends a selection, follows the link or keeps the selection. The ✎ is now a button with the action's name, where a div pretended to be one, and the text keeps its own role, a heading or Markdown. The plate is drawn under the text by a pseudo-element, tinted from its colour: the surface colour it had was the card's own, and showed nothing on the dump page. On a playlist's page the title edits in place beside Follow and Edit, which go to the row's end, and the description too; an empty note or description says what goes there to whoever may write it. DescriptionEditor and EmailEditor go.
The GeoCities marquee stops only for a link the keyboard focused: a link pressed with the pointer took the focus as well, which put the line back at its start and scrolled it, so the link slipped from under the pointer before the button came up and the click was lost. The next page's marquee also carries on from the very point the last one had reached, with the loop it showed: it worked its place out from when the loop began, which ran on while the crawl was paused under the pointer, and showed the feed as it was by then, of another width.

The chat's message field rests on one line, as tall as the Send button: it rested on two, 54 to 62 pixels against the button's 38, and the button sat low beside it. Both take --chat-composer-height.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:08:53 +00:00
khannurien
b92c674726 v4: the devcontainer has Playwright's browsers — they were installed by hand in the home directory, which the rebuild onto Node 26 left without them, and the screenshots of the dev instances stopped working. The Dockerfile installs Firefox and the Chromium headless shell of npm:playwright@1.63.0, with the system libraries they need, in /opt/ms-playwright (PLAYWRIGHT_BROWSERS_PATH), which the workspace and the instances' services share as part of the image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:08:53 +00:00
khannurien
7d0a0e7534 v4: the devcontainer runs Deno 2.9.7 on Node 26 — its image took Deno 2.9.0 and the Node 20 base. The Dockerfile now copies the deno binary from denoland/deno:bin-2.9.7 onto mcr.microsoft.com/devcontainers/typescript-node:26.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 54s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 13:38:16 +00:00
khannurien
51439f475d v4: the GeoCities marquee scrolls the community feed, what the site's members are doing — it crawled three canned lines, a welcome, "best viewed in Netscape Navigator" and "under construction", which said nothing of the site. The feed is a list any style may show (CommunityFeed.tsx), which GeoCities lays out along its marquee: who is online, the week's dumps, upvotes, comments and chat messages, linking to /community, its most upvoted dump, then the 12 latest things done in public, newest first, each ending on how long ago it was: a public dump posted, a public playlist created, a comment or an upvote on a public dump, someone joining and who invited them. Every name and title links to its page. A dump has one comment line and one upvote line at most, its latest, so that a thread or a popular dump doesn't fill the feed. GET /api/community/feed serves it, signed in only (community-service.ts): the events are read from the tables on each request, the week's figures are those /community already keeps, as of its last refresh. A signed-out visitor has no marquee. CommunityFeedProvider holds the feed around every page, so that it outlives the header each page renders anew, fetches it only while a component shows it, and fetches it again two seconds after the socket last told of a dump, a comment, a vote, a playlist, a rename, a deletion or a reconnection: the socket's messages don't carry a feed's lines, and the server's query stays the one place that decides what the feed holds. Someone joining comes with no message, and shows at the next of those. The crawl goes at 50 pixels a second however much the feed holds, Marquee.tsx measuring one copy for the stylesheet to work the loop's length out from, where a fixed 36 seconds would have sped up with every line. A loop shows what the feed held when it began: text that changed mid-crawl would jump, so what came meanwhile waits for the next loop, which also brings the times up to date. The next page's marquee carries on from where the last one's had got to, rather than from the start. The marquee was hidden from assistive technology as decoration; its first copy is now a labelled list of links, the second, which the seamless loop needs, hidden and out of the tab order. A link that takes the focus stops the crawl and the line scrolls to it, the pointer pauses it as before, and with reduced motion the line shows once, still, follows the feed as it changes and scrolls by hand. The sentences a notification also says, "posted", "upvoted", "commented on", share its translations. The tests' DOM gets an AnimationEvent before React loads (src/test/before-react.ts): finding none, React listened for the animation events under their WebKit names, and a test's "animationiteration" reached no handler.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 51s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:38:44 +00:00
khannurien
62e51f1622 v4: the six styles are reworked, each around one idea of its own, and the tab row stops painting the page — a pass over every theme, then the faults it showed in what they share. Brutalist draws every box in ink with a solid offset shadow and sets the palette's colors as flat blocks (a teal tag, an apricot quote, an inked label); its dark scheme keeps that ink darker than the page, on Twilight Indigo, where eggshell lines glared across every frame. GeoCities goes to the desktop of its day: the header is the taskbar, the site's name its Start button, the controls grey bevels, and a marquee crawls under the header (Marquee.tsx, drawn in that style alone), with Comic Neue where the system has no Comic Sans. Melange leaves its browns and Fraunces for a sorbet blend in Bricolage Grotesque, run along the header, through the site's name, where it drifts, and between a journal's blocks; its two schemes are one print and its negative, trading ink and paper while the pink and the plum written on it stay, and they share one fine grain where the light one had a coarse tile that greyed the page. Kanagawa opens the comments and the related dumps on a status line and reads on Lotus's brightest paper; its page is flat. Times draws its heavy rules in a mid grey at night, where ink is near white, and sets a journal cell with no picture as text. In what the styles share: a row of tabs (TabBar) hid the tabs scrolled under its scope switcher, and faded its ends, by painting the page's color over them, which showed as a patch on any page that is a gradient or a picture. The switcher now sits before the row that scrolls, which clips the tabs at its edge, and the fades are a mask (--tab-fade-start and --tab-fade-end); useRoomBeside measures the row through that one more level. A page's title keeps a word whole unless it alone is wider than the line: Firefox sizes a title set with negative letter-spacing a hair narrower than it lays it out, which dropped its last letter to a second line in any style that tightened it, so the rule is the base's and the two styles' copies go. A card holding one paragraph, such as the instances a new account hasn't signed in to on /settings, is spaced by its padding and not by the paragraph's margins as well. The site's name answers the pointer as a card's title does in each style, where two of them left it still. The emails follow the styles' tokens, Melange's blend among them.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 55s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:30:57 +00:00
khannurien
eb4e64683c v4: the Times nameplate sits level with the header's buttons — the name was 7 pixels under their middle on a wide screen and 3 on a narrow one, with twice the paper above its letters as below. The blackletter sets its letters low in its line, an ascender stopping a fifth of an em under the line's top and a descender ending on its bottom, and the nameplate's padding was the heavier above besides. The name now carries 0.22em under its line, which leaves as much below the letters as above at either size, and the nameplate's padding is the same on both sides, 0.6rem and 0.7rem from 860 pixels up. The wide masthead's row is a pixel taller, the narrow header keeps its 60.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 53s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:30:02 +00:00
khannurien
d54ae554a4 v4: the Times style gets a masthead and a face for each job — one condensed serif did everything, buttons and tabs included, under a plain header bar. The site's name is now a blackletter nameplate (Manufacturing Consent), its emoji beside it on the same baseline at the size of a printer's mark. From 860 pixels up it takes the middle of a row of its own, the buttons to its right, and what a page puts in the header, a feed's tabs or the open search field, runs underneath between a hairline and a double rule; a page that puts nothing there keeps one row and a single ink rule. Instrument Serif stays for headlines, at its one weight and larger sizes, Newsreader sets what is read at length (summaries, notes, comments), and Libre Franklin everything the interface says: controls, tabs, datelines, bylines, labels (--font-ui, listed in themes/nyt.css). Tabs are sentence case with an ink underline on the open one. The comments, the related dumps, a profile's sections and a page's groups open on an ink rule with their label in bold capitals under it. A dump's categories are small capital kickers, where they were grey chips. Buttons and fields take a 3px radius, pictures, cards and sheets staying square. The header sits on the page's color in the dark scheme too. In the journal each cell draws its four rules as a 1px shadow in a 1px gap, where it drew its top and left edges: a last row the dumps don't fill was left open on its right, and the row above it unruled over the empty tracks. A page's title keeps a word whole unless it is wider than the line. deno.lock also takes the key format of the Deno that wrote it, with no version changed beyond the three font packages.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 53s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 09:26:09 +00:00
khannurien
3e15e9a4f2 v4: /follow/playlists opens the followed playlists' feed, and a page's test opens it through the app's routes — the path was a 404 since the two views of the followed feed became pages: the route table held each feed tab as an exact path, /follow, so /follow/playlists fell to /:handle/*, which read "follow" as a category and "playlists" as a tab it hasn't. /music/follow/playlists, which goes through that route, worked. The tabs' routes now take what follows them (/${tab}/*), which Feed already reads and refuses when it names no view.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 57s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 28s
No test saw it because each page's test mounted the page on a route pattern of its own, a copy of App's, with stand-in pages where a redirect lands: Index's parsed the path itself, and passed on one the app doesn't serve. renderPage (src/test/render.tsx) now opens a URL through AppRoutes, which App.tsx exports for it, inside the real providers, and every page's test uses it: the page is the one the app routes that URL to, behind its guard, and a redirect is checked by the path it lands on. An entry may be a location, to arrive with the state a link hands over. Every page being loaded with its route, the first query after opening one waits. src/test/App.test.tsx tests the table itself: the paths that are a feed, those that are not found, those that send a guest to sign in and back, and those that send a signed-in user home.

The profile's test held /@ann/dumps/x to be a path the profile answers as not found, which only its own pattern made true: in the app it is a dump's page. It now asks for /@ann/dumps/x/y. The tests of the two guards still mount them around a stand-in child, being tests of the guard alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:59:52 +00:00
khannurien
cbb770bff8 v4: a still is scaled from its first frame alone, a second image in its file made the copy flash — an avatar came out as a WebP of two frames looping at 40 ms each, the picture then a blank. A still's file may hold more than one image, a phone's JPEG carrying a gain map or a depth map after the photo, and ffmpeg reads each as a frame: only the JPEG and PNG copies were held to one, the WebP keeping them all as it does a GIF's. scalableImage (lib/upload.ts) now says whether the image is an animation, which a GIF alone is, and scaleImage (lib/ffmpeg.ts) writes one frame of anything else, in every format; the image proxy scales through the same call. The copies already made stay, a copy being current while it has its upload's date: uploads/scaled is to be deleted once on an instance that ran the previous version, everything in it being made again on first request.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 52s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:40:14 +00:00
79 changed files with 4754 additions and 1814 deletions

View File

@@ -1,11 +1,20 @@
FROM denoland/deno:bin-2.9.0 AS deno FROM denoland/deno:bin-2.9.7 AS deno
FROM mcr.microsoft.com/devcontainers/typescript-node:20 FROM mcr.microsoft.com/devcontainers/typescript-node:26
COPY --from=deno /deno /usr/local/bin/deno COPY --from=deno /deno /usr/local/bin/deno
# The API draws thumbnails and waveforms with ffmpeg, in the workspace and in # The API draws thumbnails and waveforms with ffmpeg, in the workspace and in
# the instances' services alike (compose.yaml), which don't get the features. # the instances' services alike (compose.yaml), which don't get the features.
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \ && apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Playwright's Firefox and Chromium headless shell, with the system libraries
# they need, for screenshots of the dev instances (`npm:playwright@1.63.0`): a
# script importing another version looks for other builds. They live outside
# the home directory, which a rebuild would otherwise leave without them.
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright
RUN DENO_DIR=/tmp/deno deno run --no-lock -A npm:playwright@1.63.0 install \
--with-deps firefox chromium-headless-shell \
&& chmod -R a+rX /opt/ms-playwright \
&& rm -rf /tmp/deno /var/lib/apt/lists/*
# The services share Deno's cache through a volume (compose.yaml), which Docker # The services share Deno's cache through a volume (compose.yaml), which Docker
# creates owned as this directory is, so it must exist and be node's. # creates owned as this directory is, so it must exist and be node's.
RUN mkdir -p /home/node/.cache/deno && chown -R node:node /home/node/.cache RUN mkdir -p /home/node/.cache/deno && chown -R node:node /home/node/.cache

6
API.md
View File

@@ -216,6 +216,12 @@ there: those endpoints answer it 400.
- `GET /api/community?period=weekly|monthly` — signed in. The site's top dumps, - `GET /api/community?period=weekly|monthly` — signed in. The site's top dumps,
new members, new public playlists and totals over the last week (by default) new members, new public playlists and totals over the last week (by default)
or month, as of the server's last refresh. or month, as of the server's last refresh.
- `GET /api/community/feed` — signed in. The community feed: the 12 latest
things done in public, newest first (a public dump posted, a public playlist
created, a comment or an upvote on a public dump, someone joining), and the
last week's totals and most upvoted dump, as of the server's last refresh. A
dump has one comment event and one upvote event at most, its latest. Every
reader gets the same feed.
### chat ### chat

View File

@@ -200,28 +200,44 @@ const ORIENTATION_FILTERS: Record<number, string> = {
/** What ffmpeg is told to write each {@link ScaledFormat} with. */ /** What ffmpeg is told to write each {@link ScaledFormat} with. */
const ENCODERS: Record<ScaledFormat, string[]> = { const ENCODERS: Record<ScaledFormat, string[]> = {
webp: ["-c:v", "libwebp", "-q:v", "80", "-loop", "0", "-f", "webp"], webp: ["-c:v", "libwebp", "-q:v", "80", "-loop", "0", "-f", "webp"],
// A still: the first frame of an animation. jpg: ["-c:v", "mjpeg", "-q:v", "4", "-f", "image2"],
jpg: ["-frames:v", "1", "-c:v", "mjpeg", "-q:v", "4", "-f", "image2"], png: ["-c:v", "png", "-f", "image2"],
png: ["-frames:v", "1", "-c:v", "png", "-f", "image2"],
}; };
/**
* What {@link scaleImage} is told of the image it scales, which
* `scalableImage` (lib/upload.ts) reads from its first bytes.
*/
export interface ImageTraits {
/** The EXIF orientation to turn the image by, 1 when it is upright. */
orientation: number;
/**
* Whether its frames are an animation, as a GIF's are. A still may hold
* more than one image too, a phone's JPEG carrying a gain map or a depth
* map after the photo, and ffmpeg reads each as a frame.
*/
animated: boolean;
}
/** /**
* Writes a copy of an image at most `width` wide, never wider than the image * Writes a copy of an image at most `width` wide, never wider than the image
* itself, as a WebP unless `format` names another. WebP keeps a PNG's * itself, as a WebP unless `format` names another. WebP keeps a PNG's
* transparency and a GIF's animation, at a fraction of either's bytes. * transparency and a GIF's animation, at a fraction of either's bytes.
* `orientation` is the EXIF orientation of a JPEG, as `scalableImage`
* (lib/upload.ts) reads it.
*/ */
export function scaleImage( export function scaleImage(
srcPath: string, srcPath: string,
outPath: string, outPath: string,
width: ImageWidth, width: ImageWidth,
orientation = 1, { orientation, animated }: ImageTraits,
format: ScaledFormat = "webp", format: ScaledFormat = "webp",
): Promise<void> { ): Promise<void> {
const upright = ORIENTATION_FILTERS[orientation]; const upright = ORIENTATION_FILTERS[orientation];
// A JPEG's colour planes halve its height, which they need even. // A JPEG's colour planes halve its height, which they need even.
const height = format === "jpg" ? -2 : -1; const height = format === "jpg" ? -2 : -1;
// Only an animation keeps its frames, and only as a WebP: of anything else
// the first is the image, and a copy of them all would flash through what
// follows it.
const frames = animated && format === "webp" ? [] : ["-frames:v", "1"];
return runFfmpeg([ return runFfmpeg([
"-noautorotate", "-noautorotate",
"-format_whitelist", "-format_whitelist",
@@ -231,6 +247,7 @@ export function scaleImage(
"-vf", "-vf",
`${upright ? `${upright},` : ""}scale='min(${width},iw)':${height}`, `${upright ? `${upright},` : ""}scale='min(${width},iw)':${height}`,
"-an", "-an",
...frames,
...ENCODERS[format], ...ENCODERS[format],
], outPath); ], outPath);
} }
@@ -243,7 +260,7 @@ export function scaleImage(
export async function scaleImageBytes( export async function scaleImageBytes(
bytes: Uint8Array, bytes: Uint8Array,
width: ImageWidth, width: ImageWidth,
orientation = 1, traits: ImageTraits,
): Promise<Uint8Array | null> { ): Promise<Uint8Array | null> {
if (!await ffmpegAvailable()) return null; if (!await ffmpegAvailable()) return null;
const srcPath = `${SCALED_DIR}/tmp/${crypto.randomUUID()}`; const srcPath = `${SCALED_DIR}/tmp/${crypto.randomUUID()}`;
@@ -251,7 +268,7 @@ export async function scaleImageBytes(
try { try {
await Deno.mkdir(dirname(srcPath), { recursive: true }); await Deno.mkdir(dirname(srcPath), { recursive: true });
await Deno.writeFile(srcPath, bytes); await Deno.writeFile(srcPath, bytes);
await scaleImage(srcPath, outPath, width, orientation); await scaleImage(srcPath, outPath, width, traits);
return await Deno.readFile(outPath); return await Deno.readFile(outPath);
} catch { } catch {
return null; return null;

View File

@@ -14,6 +14,7 @@ import { MAX_IMAGE_SIZE_BYTES } from "../config.ts";
import { type ImageWidth, parseImageWidth } from "../../shared/image.ts"; import { type ImageWidth, parseImageWidth } from "../../shared/image.ts";
import { import {
ensureGenerated, ensureGenerated,
type ImageTraits,
type ScaledFormat, type ScaledFormat,
scaledImagePath, scaledImagePath,
scaleImage, scaleImage,
@@ -113,9 +114,7 @@ function jpegOrientation(data: Uint8Array): number {
const IMAGE_HEAD_BYTES = 64 * 1024 + 32; const IMAGE_HEAD_BYTES = 64 * 1024 + 32;
/** Returned by {@link scalableImage}, for `scaleImage`. */ /** Returned by {@link scalableImage}, for `scaleImage`. */
export interface ScalableImage { export interface ScalableImage extends ImageTraits {
/** The EXIF orientation to turn the image by, 1 when it is upright. */
orientation: number;
/** /**
* The format of its copy for a reader without WebP: a JPEG, or a PNG for * The format of its copy for a reader without WebP: a JPEG, or a PNG for
* an image that may be transparent, which a JPEG would fill with black. * an image that may be transparent, which a JPEG would fill with black.
@@ -133,16 +132,16 @@ export function scalableImage(head: Uint8Array): ScalableImage | null {
// A file dump may be a BMP ("BM"), which no upload route here accepts. // A file dump may be a BMP ("BM"), which no upload route here accepts.
if (!mime) { if (!mime) {
return head[0] === 0x42 && head[1] === 0x4D return head[0] === 0x42 && head[1] === 0x4D
? { orientation: 1, compat: "jpg" } ? { orientation: 1, animated: false, compat: "jpg" }
: null; : null;
} }
// "RIFF....WEBPVP8X", whose flags then say whether frames follow. // "RIFF....WEBPVP8X", whose flags then say whether frames follow.
const animated = mime === "image/webp" && head[15] === 0x58 && const animatedWebp = mime === "image/webp" && head[15] === 0x58 &&
(head[20] & 0x02) !== 0; (head[20] & 0x02) !== 0;
if (animated) return null; if (animatedWebp) return null;
return mime === "image/jpeg" return mime === "image/jpeg"
? { orientation: jpegOrientation(head), compat: "jpg" } ? { orientation: jpegOrientation(head), animated: false, compat: "jpg" }
: { orientation: 1, compat: "png" }; : { orientation: 1, animated: mime === "image/gif", compat: "png" };
} }
/** Reads the first {@link IMAGE_HEAD_BYTES} of the file at `path`. */ /** Reads the first {@link IMAGE_HEAD_BYTES} of the file at `path`. */
@@ -195,7 +194,7 @@ async function scaledCopy(
const made = await ensureGenerated(scaledPath, async () => { const made = await ensureGenerated(scaledPath, async () => {
how ??= await describe(path); how ??= await describe(path);
if (!how) throw new Error("Not an image that can be scaled"); if (!how) throw new Error("Not an image that can be scaled");
await scaleImage(path, scaledPath, width, how.orientation, format); await scaleImage(path, scaledPath, width, how, format);
}, path); }, path);
return made ? { path: scaledPath, mime: SCALED_MIMES[format] } : null; return made ? { path: scaledPath, mime: SCALED_MIMES[format] } : null;
} }

View File

@@ -191,7 +191,7 @@ const RECIPES: Record<StyleName, Recipe> = {
}, },
}, },
melange: { melange: {
webFontAxes: "opsz,wght@9..144,400;9..144,600;9..144,700", webFontAxes: "wght@400;600",
scale: 1, scale: 1,
avatarRadius: 16, avatarRadius: 16,
title: { size: 32, weight: 600 }, title: { size: 32, weight: 600 },

View File

@@ -111,8 +111,7 @@ export async function fetchProxiedImage(
const original = await fetchOriginal(url); const original = await fetchOriginal(url);
if (!original) return null; if (!original) return null;
const how = scalableImage(original.bytes); const how = scalableImage(original.bytes);
const bytes = how && const bytes = how && await scaleImageBytes(original.bytes, width, how);
await scaleImageBytes(original.bytes, width, how.orientation);
if (!bytes) return original; if (!bytes) return original;
writeImageCache(key, bytes, "image/webp"); writeImageCache(key, bytes, "image/webp");
return { bytes, contentType: "image/webp" }; return { bytes, contentType: "image/webp" };

View File

@@ -1,20 +1,25 @@
/** /**
* Serves the figures of the client's `/community` page, which * Serves the figures of the client's `/community` page, which
* `community-service.ts` recomputes on a timer. * `community-service.ts` recomputes on a timer, and the community feed.
* *
* - `GET /api/community?period=weekly|monthly` — signed in. Answers the * - `GET /api/community?period=weekly|monthly` — signed in. Answers the
* site's figures over the last week (by default) or month. * site's figures over the last week (by default) or month.
* - `GET /api/community/feed` — signed in. Answers the latest things done in
* public and the week's figures.
* *
* @module * @module
*/ */
import { Router } from "@oak/oak"; import { Router } from "@oak/oak";
import type { APIResponse } from "../../shared/api.ts"; import type { APIResponse } from "../../shared/api.ts";
import type { CommunityStats } from "../../shared/community.ts"; import type { CommunityFeed, CommunityStats } from "../../shared/community.ts";
import { isDigestCadence } from "../../shared/settings.ts"; import { isDigestCadence } from "../../shared/settings.ts";
import { APIException } from "../lib/api-exception.ts"; import { APIException } from "../lib/api-exception.ts";
import { type AuthContext, authMiddleware } from "../middleware/auth.ts"; import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
import { getCommunityStats } from "../services/community-service.ts"; import {
getCommunityFeed,
getCommunityStats,
} from "../services/community-service.ts";
/** /**
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
@@ -34,4 +39,12 @@ router.get("/", authMiddleware, (ctx: AuthContext) => {
ctx.response.body = responseBody; ctx.response.body = responseBody;
}); });
router.get("/feed", authMiddleware, (ctx: AuthContext) => {
const responseBody: APIResponse<CommunityFeed> = {
success: true,
data: getCommunityFeed(),
};
ctx.response.body = responseBody;
});
export default router; export default router;

View File

@@ -5,18 +5,28 @@
* the month ending at the last refresh, which {@link communityJob} redoes * the month ending at the last refresh, which {@link communityJob} redoes
* every {@link COMMUNITY_REFRESH_MINUTES}, so a visit runs no query. * every {@link COMMUNITY_REFRESH_MINUTES}, so a visit runs no query.
* *
* It also gathers the community feed ({@link getCommunityFeed}), the latest
* things done in public, which is read from the tables on each request: it
* has to show a dump the moment it is posted.
*
* @module * @module
*/ */
import { db } from "../db/connection.ts"; import { db } from "../db/connection.ts";
import { COMMUNITY_REFRESH_MINUTES } from "../config.ts"; import { COMMUNITY_REFRESH_MINUTES } from "../config.ts";
import type { CommunityStats } from "../../shared/community.ts"; import type {
CommunityEvent,
CommunityFeed,
CommunityItem,
CommunityStats,
} from "../../shared/community.ts";
import { import {
DIGEST_CADENCES, DIGEST_CADENCES,
type DigestCadence, type DigestCadence,
PERIOD_MS, PERIOD_MS,
} from "../../shared/settings.ts"; } from "../../shared/settings.ts";
import type { Job } from "../lib/scheduler.ts"; import type { Job } from "../lib/scheduler.ts";
import { parseRows } from "../model/rows.ts";
import { listTopDumps } from "./dump-service.ts"; import { listTopDumps } from "./dump-service.ts";
import { listNewPublicPlaylists } from "./playlist-service.ts"; import { listNewPublicPlaylists } from "./playlist-service.ts";
import { listNewUsers } from "./user-service.ts"; import { listNewUsers } from "./user-service.ts";
@@ -91,3 +101,148 @@ export const communityJob: Job = {
firstAfterMs: 0, firstAfterMs: 0,
run: () => refreshCommunityStats(), run: () => refreshCommunityStats(),
}; };
/** How many events the feed holds, and how many of each kind it reads. */
const FEED_MAX = 12;
/**
* A row of {@link listCommunityEvents}'s query, whose five parts share these
* columns. `title`, `slug` and `owner_username` name the dump or the playlist
* of every event but a `user_joined`.
*/
interface EventRow {
type: CommunityEvent["type"];
/** The dump, the playlist or the new user. */
subject_id: string;
at: string;
username: string;
title: string | null;
slug: string | null;
owner_username: string | null;
comment_id: string | null;
invited_by: string | null;
}
/** Checks `row`'s columns that every event has. */
function isEventRow(row: unknown): row is EventRow {
const r = row as Partial<EventRow> | null;
return !!r && typeof r.type === "string" &&
typeof r.subject_id === "string" && typeof r.at === "string" &&
typeof r.username === "string" &&
(r.type === "user_joined" ||
(typeof r.title === "string" && typeof r.slug === "string" &&
typeof r.owner_username === "string"));
}
function eventOf(row: EventRow): CommunityEvent {
const base = {
id: `${row.type}:${row.subject_id}`,
at: new Date(row.at),
username: row.username,
};
// `isEventRow` has checked the three on every row that reads `item`.
const item: CommunityItem = {
title: row.title!,
slug: row.slug!,
ownerUsername: row.owner_username!,
};
switch (row.type) {
case "dump_posted":
case "dump_upvoted":
return { ...base, type: row.type, dump: item };
case "dump_commented":
return {
...base,
type: row.type,
dump: item,
commentId: row.comment_id!,
};
case "playlist_created":
return { ...base, type: row.type, playlist: item };
case "user_joined":
return {
...base,
type: row.type,
invitedByUsername: row.invited_by ?? undefined,
};
}
}
/**
* Returns the latest things done in public, newest first, {@link FEED_MAX} at
* most: the public dumps posted and playlists created, the comments and
* upvotes on public dumps, and who joined. A dump has one comment event and
* one upvote event at most, its latest, so that a thread or a popular dump
* doesn't fill the feed.
*
* Each part reads its {@link FEED_MAX} latest, which is all the whole may
* keep of it. The comments and the upvotes are grouped by dump first, and
* with `MAX()` SQLite fills a group's other columns from the row that holds
* the maximum: the latest comment's author and id, the latest voter.
*
* @throws {APIException} 500 when a row doesn't parse as an event.
*/
export function listCommunityEvents(): CommunityEvent[] {
const rows = db.prepare(
`SELECT * FROM (
SELECT 'dump_posted' AS type, d.id AS subject_id, d.created_at AS at,
u.username AS username, d.title AS title, d.slug AS slug,
u.username AS owner_username, NULL AS comment_id,
NULL AS invited_by
FROM dumps d JOIN users u ON u.id = d.user_id
WHERE d.is_private = 0
ORDER BY d.created_at DESC LIMIT ${FEED_MAX})
UNION ALL SELECT * FROM (
SELECT 'playlist_created', p.id, p.created_at, u.username, p.title,
p.slug, u.username, NULL, NULL
FROM playlists p JOIN users u ON u.id = p.user_id
WHERE p.is_public = 1
ORDER BY p.created_at DESC LIMIT ${FEED_MAX})
UNION ALL SELECT * FROM (
SELECT 'dump_commented', d.id, MAX(c.created_at) AS at, cu.username,
d.title, d.slug, ou.username, c.id, NULL
FROM comments c
JOIN dumps d ON d.id = c.dump_id
JOIN users cu ON cu.id = c.user_id
JOIN users ou ON ou.id = d.user_id
WHERE c.deleted = 0 AND d.is_private = 0
GROUP BY d.id ORDER BY at DESC LIMIT ${FEED_MAX})
UNION ALL SELECT * FROM (
SELECT 'dump_upvoted', d.id, MAX(v.created_at) AS at, vu.username,
d.title, d.slug, ou.username, NULL, NULL
FROM votes v
JOIN dumps d ON d.id = v.dump_id
JOIN users vu ON vu.id = v.user_id
JOIN users ou ON ou.id = d.user_id
WHERE d.is_private = 0
GROUP BY d.id ORDER BY at DESC LIMIT ${FEED_MAX})
UNION ALL SELECT * FROM (
SELECT 'user_joined', u.id, u.created_at, u.username, NULL, NULL, NULL,
NULL, i.username
FROM users u LEFT JOIN users i ON i.id = u.invited_by
ORDER BY u.created_at DESC LIMIT ${FEED_MAX})
ORDER BY at DESC LIMIT ${FEED_MAX};`,
).all();
return parseRows(rows, isEventRow, eventOf);
}
/**
* Returns the community feed for `GET /api/community/feed`: the latest
* events, read now, and the last week's figures as of the last refresh.
*/
export function getCommunityFeed(): CommunityFeed {
const { totals, chatMessages, top } = getCommunityStats("weekly");
const [best] = top;
return {
events: listCommunityEvents(),
week: {
totals,
chatMessages,
top: best && {
title: best.title,
slug: best.slug,
ownerUsername: best.ownerUsername,
},
},
};
}

View File

@@ -94,17 +94,27 @@ Deno.test("scalableImage: an animated WebP and what isn't an image are served as
assertEquals(scalableImage(webp(0x02)), null); assertEquals(scalableImage(webp(0x02)), null);
// A reader without WebP gets a PNG of what may be transparent, a JPEG of // A reader without WebP gets a PNG of what may be transparent, a JPEG of
// the rest. // the rest.
assertEquals(scalableImage(webp(0x10)), { orientation: 1, compat: "png" }); const still = { orientation: 1, animated: false };
assertEquals(scalableImage(webp(0x10)), { ...still, compat: "png" });
assertEquals( assertEquals(
scalableImage(new TextEncoder().encode("RIFF\0\0\0\0WEBPVP8 ")), scalableImage(new TextEncoder().encode("RIFF\0\0\0\0WEBPVP8 ")),
{ orientation: 1, compat: "png" }, { ...still, compat: "png" },
); );
assertEquals(scalableImage(PNG), { orientation: 1, compat: "png" }); assertEquals(scalableImage(PNG), { ...still, compat: "png" });
assertEquals(scalableImage(jpegWithOrientation(1, true))?.compat, "jpg"); assertEquals(scalableImage(jpegWithOrientation(1, true)), {
assertEquals(scalableImage(new TextEncoder().encode("BM-a-bitmap")), { ...still,
orientation: 1,
compat: "jpg", compat: "jpg",
}); });
assertEquals(scalableImage(new TextEncoder().encode("BM-a-bitmap")), {
...still,
compat: "jpg",
});
// Only a GIF's frames are an animation to keep.
assertEquals(scalableImage(new TextEncoder().encode("GIF89a")), {
orientation: 1,
animated: true,
compat: "png",
});
assertEquals(scalableImage(new TextEncoder().encode("<svg/>")), null); assertEquals(scalableImage(new TextEncoder().encode("<svg/>")), null);
assertEquals(scalableImage(new Uint8Array()), null); assertEquals(scalableImage(new Uint8Array()), null);
}); });

View File

@@ -32,13 +32,14 @@ Deno.test("readTokens: a forced scheme's block overrides the theme's", () => {
Deno.test("readTokens: var() follows tokens, color-mix() is computed", () => { Deno.test("readTokens: var() follows tokens, color-mix() is computed", () => {
const melange = readTokens("melange", "light"); const melange = readTokens("melange", "light");
// The spice starts with var(--color-accent) and drops its stops' positions. // The spice holds var()s, the accent among them, and drops its stops'
// positions.
assertEquals(melange.stops("--mel-spice"), [ assertEquals(melange.stops("--mel-spice"), [
"#a06d00", "#ea6a45",
"#bc5c00", "#e2568f",
"#c77b8b", "#8262d8",
"#be79bb", "#3a8fd4",
"#6e9b72", "#27a583",
]); ]);
// 18% of the muted text over the card, as a badge shows on the site. // 18% of the muted text over the card, as a badge shows on the site.
const smooth = readTokens("smooth", "light"); const smooth = readTokens("smooth", "light");

View File

@@ -38,6 +38,24 @@ test("avatars: upload, serve, and 404 when there is none", async () => {
); );
}); });
test("avatars: a still holding a second image is scaled to its first alone", async () => {
if (!await hasFfmpeg()) return;
const user = await createUser();
// As a phone's photo carries a gain map after it, which ffmpeg reads as a
// second frame.
const photo = await makeImage(600, 400, "red");
const extra = await makeImage(150, 100, "white");
const both = new Uint8Array(photo.length + extra.length);
both.set(photo);
both.set(extra, photo.length);
await put("/api/me/avatar", { auth: user.auth, form: formWithFile(both) });
const small = await get(`/api/users/${user.id}/avatar?w=128`);
assertEquals(small.headers.get("content-type"), "image/webp");
assertEquals(webpWidth(small.bytes), 128);
assertFalse(new TextDecoder().decode(small.bytes).includes("ANMF"));
});
test("avatars: `?w=` answers a scaled copy, made again from a new avatar", async () => { test("avatars: `?w=` answers a scaled copy, made again from a new avatar", async () => {
if (!await hasFfmpeg()) return; if (!await hasFfmpeg()) return;
const user = await createUser(); const user = await createUser();

View File

@@ -1,6 +1,9 @@
import { createUser, db, get, insertDump, test } from "../helpers.ts"; import { createUser, db, get, insertDump, test } from "../helpers.ts";
import { assertEquals } from "@std/assert"; import { assertEquals } from "@std/assert";
import type { CommunityStats } from "../../../shared/community.ts"; import type {
CommunityFeed,
CommunityStats,
} from "../../../shared/community.ts";
import { refreshCommunityStats } from "../../services/community-service.ts"; import { refreshCommunityStats } from "../../services/community-service.ts";
function vote(dumpId: string, n: number) { function vote(dumpId: string, n: number) {
@@ -33,3 +36,25 @@ test("GET /api/community: signed in only, for a week or a month", async () => {
}); });
assertEquals(month.data.totals.dumps, 1); assertEquals(month.data.totals.dumps, 1);
}); });
test("GET /api/community/feed: signed in only, the latest events and the week's figures", async () => {
const ann = await createUser();
insertDump({
userId: ann.id,
title: "Hit",
createdAt: new Date(Date.now() - 1000).toISOString(),
});
refreshCommunityStats();
assertEquals((await get("/api/community/feed")).status, 401);
const feed = await get<CommunityFeed>("/api/community/feed", {
auth: ann.auth,
});
assertEquals(feed.status, 200);
assertEquals(feed.data.events.map((e) => e.type), [
"user_joined",
"dump_posted",
]);
assertEquals(feed.data.week.totals.dumps, 1);
assertEquals(feed.data.week.top, undefined);
});

View File

@@ -9,7 +9,9 @@ import {
import { assertEquals } from "@std/assert"; import { assertEquals } from "@std/assert";
import { import {
communityStatsOf, communityStatsOf,
getCommunityFeed,
getCommunityStats, getCommunityStats,
listCommunityEvents,
refreshCommunityStats, refreshCommunityStats,
} from "../../services/community-service.ts"; } from "../../services/community-service.ts";
@@ -102,3 +104,109 @@ test("getCommunityStats: serves the last refresh, of the week or the month", asy
"Later", "Later",
]); ]);
}); });
function castVote(dumpId: string, userId: string, at: string) {
db.prepare(
`INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?);`,
).run(dumpId, userId, at);
}
test("listCommunityEvents: what was done in public, newest first, a dump's latest comment and upvote alone", async () => {
const ann = await createUser({ createdAt: ago(9 * DAY) });
const bob = await createUser({ invitedBy: ann.id, createdAt: ago(8 * DAY) });
const hit = insertDump({
userId: ann.id,
title: "Hit",
createdAt: ago(7 * DAY),
});
const secret = insertDump({
userId: ann.id,
title: "Secret",
isPrivate: true,
});
insertComment({ dumpId: hit.id, userId: ann.id, createdAt: ago(6 * DAY) });
const last = insertComment({
dumpId: hit.id,
userId: bob.id,
createdAt: ago(5 * DAY),
});
const gone = insertComment({ dumpId: hit.id, userId: ann.id });
db.prepare(`UPDATE comments SET deleted = 1 WHERE id = ?;`).run(gone);
insertComment({ dumpId: secret.id, userId: ann.id });
castVote(hit.id, bob.id, ago(4 * DAY));
castVote(hit.id, ann.id, ago(3 * DAY));
castVote(secret.id, ann.id, ago(0));
const mix = insertPlaylist({ userId: bob.id, title: "Mix" });
insertPlaylist({ userId: bob.id, title: "Hidden", isPublic: false });
const events = listCommunityEvents();
assertEquals(
events.map((e) => [e.type, e.username]),
[
["playlist_created", bob.username],
["dump_upvoted", ann.username],
["dump_commented", bob.username],
["dump_posted", ann.username],
["user_joined", bob.username],
["user_joined", ann.username],
],
);
const [playlist, upvote, comment, , joined] = events;
assertEquals(playlist.type === "playlist_created" && playlist.playlist, {
title: "Mix",
slug: mix.slug,
ownerUsername: bob.username,
});
assertEquals(upvote.type === "dump_upvoted" && upvote.dump, {
title: "Hit",
slug: hit.slug,
ownerUsername: ann.username,
});
assertEquals(upvote.id, `dump_upvoted:${hit.id}`);
assertEquals(comment.type === "dump_commented" && comment.commentId, last);
assertEquals(
joined.type === "user_joined" && joined.invitedByUsername,
ann.username,
);
});
test("listCommunityEvents: keeps the 12 latest", async () => {
const ann = await createUser({ createdAt: ago(DAY) });
for (let i = 0; i < 14; i++) {
insertDump({
userId: ann.id,
title: `Dump ${i}`,
createdAt: ago(i * 1000),
});
}
const events = listCommunityEvents();
assertEquals(events.length, 12);
assertEquals(
events.map((e) => e.type === "dump_posted" && e.dump.title),
Array.from({ length: 12 }, (_, i) => `Dump ${i}`),
);
});
test("getCommunityFeed: the events as they are now, the week's figures as of the last refresh", async () => {
const ann = await createUser({ createdAt: ago(20 * DAY) });
const hit = insertDump({
userId: ann.id,
title: "Hit",
createdAt: ago(1000),
});
vote(hit.id, 3);
refreshCommunityStats();
insertDump({ userId: ann.id, title: "Later" });
const feed = getCommunityFeed();
assertEquals(
feed.events.map((e) => e.type === "dump_posted" && e.dump.title),
["Later", "Hit", false],
);
assertEquals(feed.week.totals.dumps, 1);
assertEquals(feed.week.top, {
title: "Hit",
slug: hit.slug,
ownerUsername: ann.username,
});
});

94
deno.lock generated
View File

@@ -29,34 +29,38 @@
"jsr:@std/path@^1.1.6": "1.1.6", "jsr:@std/path@^1.1.6": "1.1.6",
"jsr:@std/testing@^1.0.21": "1.0.21", "jsr:@std/testing@^1.0.21": "1.0.21",
"jsr:@tajpouria/cors@^1.2.1": "1.2.1", "jsr:@tajpouria/cors@^1.2.1": "1.2.1",
"npm:@eslint/js@^10.0.1": "10.0.1_eslint@10.11.0__jiti@2.7.0_jiti@2.7.0", "npm:@eslint/js@^10.0.1": "10.0.1_eslint@10.11.0_jiti@2.7.0",
"npm:@fontsource-variable/fraunces@5.3.0": "5.3.0", "npm:@fontsource-variable/bricolage-grotesque@5.3.0": "5.3.0",
"npm:@fontsource-variable/jetbrains-mono@5.3.0": "5.3.0", "npm:@fontsource-variable/jetbrains-mono@5.3.0": "5.3.0",
"npm:@fontsource-variable/libre-franklin@5.3.0": "5.3.0",
"npm:@fontsource-variable/newsreader@5.3.0": "5.3.0",
"npm:@fontsource-variable/saira@5.3.0": "5.3.0", "npm:@fontsource-variable/saira@5.3.0": "5.3.0",
"npm:@fontsource-variable/space-grotesk@5.3.0": "5.3.0", "npm:@fontsource-variable/space-grotesk@5.3.0": "5.3.0",
"npm:@fontsource/comic-neue@5.3.0": "5.3.0",
"npm:@fontsource/instrument-serif@5.3.0": "5.3.0", "npm:@fontsource/instrument-serif@5.3.0": "5.3.0",
"npm:@fontsource/manufacturing-consent@5.3.0": "5.3.0",
"npm:@lingui/cli@*": "6.8.0", "npm:@lingui/cli@*": "6.8.0",
"npm:@lingui/cli@6.8.0": "6.8.0", "npm:@lingui/cli@6.8.0": "6.8.0",
"npm:@lingui/conf@6.8.0": "6.8.0", "npm:@lingui/conf@6.8.0": "6.8.0",
"npm:@lingui/core@6.8.0": "6.8.0_@babel+core@7.29.7", "npm:@lingui/core@6.8.0": "6.8.0_@babel+core@7.29.7",
"npm:@lingui/format-po@6.8.0": "6.8.0", "npm:@lingui/format-po@6.8.0": "6.8.0",
"npm:@lingui/react@6.8.0": "6.8.0_react@19.3.0_@babel+core@7.29.7", "npm:@lingui/react@6.8.0": "6.8.0_react@19.3.0_@babel+core@7.29.7",
"npm:@lingui/swc-plugin@6.7.0": "6.7.0_@lingui+core@6.8.0__@babel+core@7.29.7_@babel+core@7.29.7", "npm:@lingui/swc-plugin@6.7.0": "6.7.0_@lingui+core@6.8.0_@babel+core@7.29.7",
"npm:@lingui/vite-plugin@6.8.0": "6.8.0_@babel+core@7.29.7_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0_@types+node@26.6.3_jiti@2.7.0", "npm:@lingui/vite-plugin@6.8.0": "6.8.0_@babel+core@7.29.7_vite@8.3.1_@types+node@26.6.3_jiti@2.7.0",
"npm:@resvg/resvg-wasm@2.6.2": "2.6.2", "npm:@resvg/resvg-wasm@2.6.2": "2.6.2",
"npm:@resvg/resvg-wasm@^2.6.2": "2.6.2", "npm:@resvg/resvg-wasm@^2.6.2": "2.6.2",
"npm:@testing-library/dom@^10.4.2": "10.4.2", "npm:@testing-library/dom@^10.4.2": "10.4.2",
"npm:@testing-library/jest-dom@^7.0.1": "7.0.1_@testing-library+dom@10.4.2_vitest@5.0.2__@types+node@26.6.3__@vitest+coverage-v8@5.0.2__jsdom@30.1.1__vite@8.3.1___@types+node@26.6.3___jiti@2.7.0_@vitest+coverage-v8@5.0.2__vitest@5.0.2__@types+node@26.6.3__jsdom@30.1.1__vite@8.3.1___@types+node@26.6.3___jiti@2.7.0", "npm:@testing-library/jest-dom@^7.0.1": "7.0.1_@testing-library+dom@10.4.2_vitest@5.0.2_@vitest+coverage-v8@5.0.2",
"npm:@testing-library/react@^16.3.3": "16.3.3_@testing-library+dom@10.4.2_@types+react@19.3.0_@types+react-dom@19.3.0__@types+react@19.3.0_react@19.3.0_react-dom@19.3.0__react@19.3.0", "npm:@testing-library/react@^16.3.3": "16.3.3_@testing-library+dom@10.4.2_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0",
"npm:@testing-library/user-event@^14.6.7": "14.6.7_@testing-library+dom@10.4.2", "npm:@testing-library/user-event@^14.6.7": "14.6.7_@testing-library+dom@10.4.2",
"npm:@types/node@^26.6.3": "26.6.3", "npm:@types/node@^26.6.3": "26.6.3",
"npm:@types/react-dom@^19.3.0": "19.3.0_@types+react@19.3.0", "npm:@types/react-dom@^19.3.0": "19.3.0_@types+react@19.3.0",
"npm:@types/react@^19.3.0": "19.3.0", "npm:@types/react@^19.3.0": "19.3.0",
"npm:@vitejs/plugin-react-swc@^4.3.3": "4.3.3_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0_@types+node@26.6.3_jiti@2.7.0", "npm:@vitejs/plugin-react-swc@^4.3.3": "4.3.3_vite@8.3.1_@types+node@26.6.3_jiti@2.7.0",
"npm:@vitest/coverage-v8@^5.0.2": "5.0.2_vitest@5.0.2_@types+node@26.6.3_jsdom@30.1.1_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0", "npm:@vitest/coverage-v8@^5.0.2": "5.0.2_vitest@5.0.2_@types+node@26.6.3_jsdom@30.1.1_vite@8.3.1",
"npm:emojibase-data@17.0.0": "17.0.0_emojibase@17.0.0", "npm:emojibase-data@17.0.0": "17.0.0_emojibase@17.0.0",
"npm:eslint-plugin-react-hooks@^7.1.1": "7.1.1_eslint@10.11.0__jiti@2.7.0_jiti@2.7.0", "npm:eslint-plugin-react-hooks@^7.1.1": "7.1.1_eslint@10.11.0_jiti@2.7.0",
"npm:eslint-plugin-react-refresh@~0.5.7": "0.5.7_eslint@10.11.0__jiti@2.7.0_jiti@2.7.0", "npm:eslint-plugin-react-refresh@~0.5.7": "0.5.7_eslint@10.11.0_jiti@2.7.0",
"npm:eslint@^10.11.0": "10.11.0_jiti@2.7.0", "npm:eslint@^10.11.0": "10.11.0_jiti@2.7.0",
"npm:frimousse@0.4": "0.4.0_react@19.3.0_typescript@6.0.3", "npm:frimousse@0.4": "0.4.0_react@19.3.0_typescript@6.0.3",
"npm:globals@^17.12.0": "17.12.0", "npm:globals@^17.12.0": "17.12.0",
@@ -68,15 +72,15 @@
"npm:react-dom@^19.3.0": "19.3.0_react@19.3.0", "npm:react-dom@^19.3.0": "19.3.0_react@19.3.0",
"npm:react-hook-form@^7.89.0": "7.89.0_@types+react@19.3.0_react@19.3.0", "npm:react-hook-form@^7.89.0": "7.89.0_@types+react@19.3.0_react@19.3.0",
"npm:react-markdown@^10.1.0": "10.1.0_@types+react@19.3.0_react@19.3.0", "npm:react-markdown@^10.1.0": "10.1.0_@types+react@19.3.0_react@19.3.0",
"npm:react-router@^8.4.0": "8.4.0_react@19.3.0_react-dom@19.3.0__react@19.3.0", "npm:react-router@^8.4.0": "8.4.0_react@19.3.0_react-dom@19.3.0",
"npm:react@^19.3.0": "19.3.0", "npm:react@^19.3.0": "19.3.0",
"npm:remark-gfm@^4.0.1": "4.0.1", "npm:remark-gfm@^4.0.1": "4.0.1",
"npm:typescript-eslint@^8.70.1": "8.70.1_eslint@10.11.0__jiti@2.7.0_typescript@6.0.3_jiti@2.7.0", "npm:typescript-eslint@^8.70.1": "8.70.1_eslint@10.11.0_typescript@6.0.3_jiti@2.7.0",
"npm:typescript@~6.0.3": "6.0.3", "npm:typescript@~6.0.3": "6.0.3",
"npm:vite@*": "8.3.1_@types+node@26.6.3_jiti@2.7.0", "npm:vite@*": "8.3.1_@types+node@26.6.3_jiti@2.7.0",
"npm:vite@^8.3.1": "8.3.1_@types+node@26.6.3_jiti@2.7.0", "npm:vite@^8.3.1": "8.3.1_@types+node@26.6.3_jiti@2.7.0",
"npm:vitest@*": "5.0.2_@types+node@26.6.3_@vitest+coverage-v8@5.0.2_jsdom@30.1.1_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0", "npm:vitest@*": "5.0.2_@types+node@26.6.3_@vitest+coverage-v8@5.0.2_jsdom@30.1.1_vite@8.3.1",
"npm:vitest@^5.0.2": "5.0.2_@types+node@26.6.3_@vitest+coverage-v8@5.0.2_jsdom@30.1.1_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0" "npm:vitest@^5.0.2": "5.0.2_@types+node@26.6.3_@vitest+coverage-v8@5.0.2_jsdom@30.1.1_vite@8.3.1"
}, },
"jsr": { "jsr": {
"@db/sqlite@0.13.0": { "@db/sqlite@0.13.0": {
@@ -382,14 +386,14 @@
"@csstools/color-helpers@6.1.2": { "@csstools/color-helpers@6.1.2": {
"integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==" "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow=="
}, },
"@csstools/css-calc@3.4.1_@csstools+css-parser-algorithms@4.0.1__@csstools+css-tokenizer@4.0.2_@csstools+css-tokenizer@4.0.2": { "@csstools/css-calc@3.4.1_@csstools+css-parser-algorithms@4.0.1_@csstools+css-tokenizer@4.0.2": {
"integrity": "sha512-EtC7SoN1j6J4E4DCwg5QgbO5TGxgxIA1RXqe+W+qUM+BUcezx9wT+/tiQ/WO2yCX4i5X+Cuf9ciJ22aP4UEwWw==", "integrity": "sha512-EtC7SoN1j6J4E4DCwg5QgbO5TGxgxIA1RXqe+W+qUM+BUcezx9wT+/tiQ/WO2yCX4i5X+Cuf9ciJ22aP4UEwWw==",
"dependencies": [ "dependencies": [
"@csstools/css-parser-algorithms", "@csstools/css-parser-algorithms",
"@csstools/css-tokenizer" "@csstools/css-tokenizer"
] ]
}, },
"@csstools/css-color-parser@4.2.4_@csstools+css-parser-algorithms@4.0.1__@csstools+css-tokenizer@4.0.2_@csstools+css-tokenizer@4.0.2": { "@csstools/css-color-parser@4.2.4_@csstools+css-parser-algorithms@4.0.1_@csstools+css-tokenizer@4.0.2": {
"integrity": "sha512-DyefytAZ735mX4Dq/WcDAXFtXhaEFvme0ZS9tVEBAc2whxUthXr0R0L2rmEPm59SrMBkGrFzQviBXMs/UtnABQ==", "integrity": "sha512-DyefytAZ735mX4Dq/WcDAXFtXhaEFvme0ZS9tVEBAc2whxUthXr0R0L2rmEPm59SrMBkGrFzQviBXMs/UtnABQ==",
"dependencies": [ "dependencies": [
"@csstools/color-helpers", "@csstools/color-helpers",
@@ -416,7 +420,7 @@
"@csstools/css-tokenizer@4.0.2": { "@csstools/css-tokenizer@4.0.2": {
"integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==" "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw=="
}, },
"@eslint-community/eslint-utils@4.9.1_eslint@10.11.0__jiti@2.7.0_jiti@2.7.0": { "@eslint-community/eslint-utils@4.9.1_eslint@10.11.0_jiti@2.7.0": {
"integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
"dependencies": [ "dependencies": [
"eslint", "eslint",
@@ -446,7 +450,7 @@
"@types/json-schema" "@types/json-schema"
] ]
}, },
"@eslint/js@10.0.1_eslint@10.11.0__jiti@2.7.0_jiti@2.7.0": { "@eslint/js@10.0.1_eslint@10.11.0_jiti@2.7.0": {
"integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==",
"dependencies": [ "dependencies": [
"eslint" "eslint"
@@ -468,21 +472,33 @@
"@exodus/bytes@1.16.0": { "@exodus/bytes@1.16.0": {
"integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==" "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA=="
}, },
"@fontsource-variable/fraunces@5.3.0": { "@fontsource-variable/bricolage-grotesque@5.3.0": {
"integrity": "sha512-9BYGySn4AHEJdgp9Z28tQ3X+laJMEOITXkQarZXeloWQZDq5oOvXJ3kDA8c7MGIfpogIaZfjrQBqmda8POOCKA==" "integrity": "sha512-TLi9Q4hJjS2UvoTMRSS2nHu6c4R56lAw60NR9QYtVRCHn0XtsFpiEhNffZ8Glsoxu6wEEwLKBP8lb94J52PNBA=="
}, },
"@fontsource-variable/jetbrains-mono@5.3.0": { "@fontsource-variable/jetbrains-mono@5.3.0": {
"integrity": "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==" "integrity": "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw=="
}, },
"@fontsource-variable/libre-franklin@5.3.0": {
"integrity": "sha512-PEQemCKVRtkLZJNpfvR4wG7x87c8iG05XJuEaX59ogMcAQpdtCZz/NXZUrXLJyvyx3/HD70W4LUppWLBdEdU2g=="
},
"@fontsource-variable/newsreader@5.3.0": {
"integrity": "sha512-rrzYi43qMpbzwuFtf9OkWH8sxAPVPcQQQEwXpPtwaKYeJ8yVg5aLs5kawmo1f2Q1t1M38TLmEKCkGVDsYwgdFw=="
},
"@fontsource-variable/saira@5.3.0": { "@fontsource-variable/saira@5.3.0": {
"integrity": "sha512-C1rm7n1OuEx4Se/SAB/5EeUuCviGFFnYTr5vxuQQm2YIOfgbQWx/P6qqhdXlVCBRm/1UzNWCtDK5jNbVmO6W2w==" "integrity": "sha512-C1rm7n1OuEx4Se/SAB/5EeUuCviGFFnYTr5vxuQQm2YIOfgbQWx/P6qqhdXlVCBRm/1UzNWCtDK5jNbVmO6W2w=="
}, },
"@fontsource-variable/space-grotesk@5.3.0": { "@fontsource-variable/space-grotesk@5.3.0": {
"integrity": "sha512-2IxmvfB08i9vnGB3Ym/AXvhRE+8XOjWMXIyDum03c+tPwH0FUoMNQfGpU8NXPxjbws0Vvss3AH0Zqt4oJBBAdw==" "integrity": "sha512-2IxmvfB08i9vnGB3Ym/AXvhRE+8XOjWMXIyDum03c+tPwH0FUoMNQfGpU8NXPxjbws0Vvss3AH0Zqt4oJBBAdw=="
}, },
"@fontsource/comic-neue@5.3.0": {
"integrity": "sha512-uZsyUTExw8tCySNhL65dN6rEUhASMPGintSS5Kw/ZkrQTK1vcAgramzOLT/mZKtZ+EXr03MHj0s5osVVJITCow=="
},
"@fontsource/instrument-serif@5.3.0": { "@fontsource/instrument-serif@5.3.0": {
"integrity": "sha512-mDiaIg0u67sYV59fie92Wz4sM8UiVlbL7fLxnFPCKkX15DMASEnQTREbaP5S5/3DCcsoAOcQ0sV9E4AeY4QqYQ==" "integrity": "sha512-mDiaIg0u67sYV59fie92Wz4sM8UiVlbL7fLxnFPCKkX15DMASEnQTREbaP5S5/3DCcsoAOcQ0sV9E4AeY4QqYQ=="
}, },
"@fontsource/manufacturing-consent@5.3.0": {
"integrity": "sha512-p/omJ13hFgV6+THlT370nyHjlMkrZWkKPiMSZHROcyFoylVvazo19Rp9KJhwudWqcbkClgzjEryvC330y4Pr6Q=="
},
"@humanfs/core@0.19.2": { "@humanfs/core@0.19.2": {
"integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
"dependencies": [ "dependencies": [
@@ -647,14 +663,14 @@
"use-sync-external-store" "use-sync-external-store"
] ]
}, },
"@lingui/swc-plugin@6.7.0_@lingui+core@6.8.0__@babel+core@7.29.7_@babel+core@7.29.7": { "@lingui/swc-plugin@6.7.0_@lingui+core@6.8.0_@babel+core@7.29.7": {
"integrity": "sha512-TRLHfeQT/q9JXy0cBGOtbfcEWJFF1szjPCVM4AT1HdZyfGj+dQ0r8hHq+08c4iXZLzlsYilzw+bZJ3TahG5g5w==", "integrity": "sha512-TRLHfeQT/q9JXy0cBGOtbfcEWJFF1szjPCVM4AT1HdZyfGj+dQ0r8hHq+08c4iXZLzlsYilzw+bZJ3TahG5g5w==",
"dependencies": [ "dependencies": [
"@lingui/conf", "@lingui/conf",
"@lingui/core" "@lingui/core"
] ]
}, },
"@lingui/vite-plugin@6.8.0_@babel+core@7.29.7_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0_@types+node@26.6.3_jiti@2.7.0": { "@lingui/vite-plugin@6.8.0_@babel+core@7.29.7_vite@8.3.1_@types+node@26.6.3_jiti@2.7.0": {
"integrity": "sha512-pyOGwY8llaXzBBDrbZpAV5a0z7ekz0zJxU+EGXxyqL/gLneqHgLhPCv8uNB+MyIGwrw0PC9CMtCpHVlS3oWguA==", "integrity": "sha512-pyOGwY8llaXzBBDrbZpAV5a0z7ekz0zJxU+EGXxyqL/gLneqHgLhPCv8uNB+MyIGwrw0PC9CMtCpHVlS3oWguA==",
"dependencies": [ "dependencies": [
"@babel/core", "@babel/core",
@@ -869,7 +885,7 @@
"pretty-format@27.5.1" "pretty-format@27.5.1"
] ]
}, },
"@testing-library/jest-dom@7.0.1_@testing-library+dom@10.4.2_vitest@5.0.2__@types+node@26.6.3__@vitest+coverage-v8@5.0.2__jsdom@30.1.1__vite@8.3.1___@types+node@26.6.3___jiti@2.7.0_@vitest+coverage-v8@5.0.2__vitest@5.0.2__@types+node@26.6.3__jsdom@30.1.1__vite@8.3.1___@types+node@26.6.3___jiti@2.7.0": { "@testing-library/jest-dom@7.0.1_@testing-library+dom@10.4.2_vitest@5.0.2_@vitest+coverage-v8@5.0.2": {
"integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==",
"dependencies": [ "dependencies": [
"@adobe/css-tools", "@adobe/css-tools",
@@ -885,7 +901,7 @@
"vitest" "vitest"
] ]
}, },
"@testing-library/react@16.3.3_@testing-library+dom@10.4.2_@types+react@19.3.0_@types+react-dom@19.3.0__@types+react@19.3.0_react@19.3.0_react-dom@19.3.0__react@19.3.0": { "@testing-library/react@16.3.3_@testing-library+dom@10.4.2_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": {
"integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==",
"dependencies": [ "dependencies": [
"@babel/runtime", "@babel/runtime",
@@ -1003,7 +1019,7 @@
"@types/yargs-parser" "@types/yargs-parser"
] ]
}, },
"@typescript-eslint/eslint-plugin@8.70.1_@typescript-eslint+parser@8.70.1__eslint@10.11.0___jiti@2.7.0__typescript@6.0.3_eslint@10.11.0__jiti@2.7.0_typescript@6.0.3_jiti@2.7.0": { "@typescript-eslint/eslint-plugin@8.70.1_@typescript-eslint+parser@8.70.1_eslint@10.11.0_typescript@6.0.3_jiti@2.7.0": {
"integrity": "sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==", "integrity": "sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==",
"dependencies": [ "dependencies": [
"@eslint-community/regexpp", "@eslint-community/regexpp",
@@ -1019,7 +1035,7 @@
"typescript" "typescript"
] ]
}, },
"@typescript-eslint/parser@8.70.1_eslint@10.11.0__jiti@2.7.0_typescript@6.0.3": { "@typescript-eslint/parser@8.70.1_eslint@10.11.0_typescript@6.0.3": {
"integrity": "sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==", "integrity": "sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==",
"dependencies": [ "dependencies": [
"@typescript-eslint/scope-manager", "@typescript-eslint/scope-manager",
@@ -1053,7 +1069,7 @@
"typescript" "typescript"
] ]
}, },
"@typescript-eslint/type-utils@8.70.1_eslint@10.11.0__jiti@2.7.0_typescript@6.0.3_jiti@2.7.0": { "@typescript-eslint/type-utils@8.70.1_eslint@10.11.0_typescript@6.0.3_jiti@2.7.0": {
"integrity": "sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==", "integrity": "sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==",
"dependencies": [ "dependencies": [
"@typescript-eslint/types", "@typescript-eslint/types",
@@ -1083,7 +1099,7 @@
"typescript" "typescript"
] ]
}, },
"@typescript-eslint/utils@8.70.1_eslint@10.11.0__jiti@2.7.0_typescript@6.0.3_jiti@2.7.0": { "@typescript-eslint/utils@8.70.1_eslint@10.11.0_typescript@6.0.3_jiti@2.7.0": {
"integrity": "sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==", "integrity": "sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==",
"dependencies": [ "dependencies": [
"@eslint-community/eslint-utils", "@eslint-community/eslint-utils",
@@ -1105,7 +1121,7 @@
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
"deprecated": true "deprecated": true
}, },
"@vitejs/plugin-react-swc@4.3.3_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0_@types+node@26.6.3_jiti@2.7.0": { "@vitejs/plugin-react-swc@4.3.3_vite@8.3.1_@types+node@26.6.3_jiti@2.7.0": {
"integrity": "sha512-bti8ZAcvz4Lh6/e4Uk2k3aa1TiUXbbMsahuqOHvd3MveFTkKDZOA6wQVkpj7J/+tepX/wGfe+lsGh/t24HTXMQ==", "integrity": "sha512-bti8ZAcvz4Lh6/e4Uk2k3aa1TiUXbbMsahuqOHvd3MveFTkKDZOA6wQVkpj7J/+tepX/wGfe+lsGh/t24HTXMQ==",
"dependencies": [ "dependencies": [
"@rolldown/pluginutils", "@rolldown/pluginutils",
@@ -1113,7 +1129,7 @@
"vite" "vite"
] ]
}, },
"@vitest/coverage-v8@5.0.2_vitest@5.0.2_@types+node@26.6.3_jsdom@30.1.1_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0": { "@vitest/coverage-v8@5.0.2_vitest@5.0.2_@types+node@26.6.3_jsdom@30.1.1_vite@8.3.1": {
"integrity": "sha512-3ffHBEi8DOOBLwIGBhOBZbRfYFYWjMUuxZicONdhuFEFEG5AVsMOSrVeuROskqnqpbOmEJwxM2eTVZ9N3a1t+A==", "integrity": "sha512-3ffHBEi8DOOBLwIGBhOBZbRfYFYWjMUuxZicONdhuFEFEG5AVsMOSrVeuROskqnqpbOmEJwxM2eTVZ9N3a1t+A==",
"dependencies": [ "dependencies": [
"@bcoe/v8-coverage", "@bcoe/v8-coverage",
@@ -1136,7 +1152,7 @@
"@vitest/istanbul-lib-coverage" "@vitest/istanbul-lib-coverage"
] ]
}, },
"@vitest/mocker@5.0.2_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0_@types+node@26.6.3_jiti@2.7.0": { "@vitest/mocker@5.0.2_vite@8.3.1_@types+node@26.6.3_jiti@2.7.0": {
"integrity": "sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==", "integrity": "sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==",
"dependencies": [ "dependencies": [
"@jridgewell/trace-mapping", "@jridgewell/trace-mapping",
@@ -1428,7 +1444,7 @@
"escape-string-regexp@5.0.0": { "escape-string-regexp@5.0.0": {
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==" "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="
}, },
"eslint-plugin-react-hooks@7.1.1_eslint@10.11.0__jiti@2.7.0_jiti@2.7.0": { "eslint-plugin-react-hooks@7.1.1_eslint@10.11.0_jiti@2.7.0": {
"integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==",
"dependencies": [ "dependencies": [
"@babel/core", "@babel/core",
@@ -1439,7 +1455,7 @@
"zod-validation-error" "zod-validation-error"
] ]
}, },
"eslint-plugin-react-refresh@0.5.7_eslint@10.11.0__jiti@2.7.0_jiti@2.7.0": { "eslint-plugin-react-refresh@0.5.7_eslint@10.11.0_jiti@2.7.0": {
"integrity": "sha512-XhJSzLljuYD4UjNuFGJu2v7aD3sqNVK12w5/DCUfrNfHWegZo2+TsavNx8MuxMwEquuFAzNbNfXKU0WoZ+YUIg==", "integrity": "sha512-XhJSzLljuYD4UjNuFGJu2v7aD3sqNVK12w5/DCUfrNfHWegZo2+TsavNx8MuxMwEquuFAzNbNfXKU0WoZ+YUIg==",
"dependencies": [ "dependencies": [
"eslint" "eslint"
@@ -2569,7 +2585,7 @@
"vfile" "vfile"
] ]
}, },
"react-router@8.4.0_react@19.3.0_react-dom@19.3.0__react@19.3.0": { "react-router@8.4.0_react@19.3.0_react-dom@19.3.0": {
"integrity": "sha512-JtydAkBvU9UTEe5qNC+POhu+ZBNM7rlKY2IegwXc7Idj7xfRG16x5RZrw3mju+XlqBxfvb2ky9P3jUp9WyWC1g==", "integrity": "sha512-JtydAkBvU9UTEe5qNC+POhu+ZBNM7rlKY2IegwXc7Idj7xfRG16x5RZrw3mju+XlqBxfvb2ky9P3jUp9WyWC1g==",
"dependencies": [ "dependencies": [
"@remix-run/route-pattern", "@remix-run/route-pattern",
@@ -2834,7 +2850,7 @@
"prelude-ls" "prelude-ls"
] ]
}, },
"typescript-eslint@8.70.1_eslint@10.11.0__jiti@2.7.0_typescript@6.0.3_jiti@2.7.0": { "typescript-eslint@8.70.1_eslint@10.11.0_typescript@6.0.3_jiti@2.7.0": {
"integrity": "sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA==", "integrity": "sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA==",
"dependencies": [ "dependencies": [
"@typescript-eslint/eslint-plugin", "@typescript-eslint/eslint-plugin",
@@ -2955,7 +2971,7 @@
], ],
"bin": true "bin": true
}, },
"vitest@5.0.2_@types+node@26.6.3_@vitest+coverage-v8@5.0.2_jsdom@30.1.1_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0": { "vitest@5.0.2_@types+node@26.6.3_@vitest+coverage-v8@5.0.2_jsdom@30.1.1_vite@8.3.1": {
"integrity": "sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==", "integrity": "sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==",
"dependencies": [ "dependencies": [
"@types/chai", "@types/chai",
@@ -3069,11 +3085,15 @@
"packageJson": { "packageJson": {
"dependencies": [ "dependencies": [
"npm:@eslint/js@^10.0.1", "npm:@eslint/js@^10.0.1",
"npm:@fontsource-variable/fraunces@5.3.0", "npm:@fontsource-variable/bricolage-grotesque@5.3.0",
"npm:@fontsource-variable/jetbrains-mono@5.3.0", "npm:@fontsource-variable/jetbrains-mono@5.3.0",
"npm:@fontsource-variable/libre-franklin@5.3.0",
"npm:@fontsource-variable/newsreader@5.3.0",
"npm:@fontsource-variable/saira@5.3.0", "npm:@fontsource-variable/saira@5.3.0",
"npm:@fontsource-variable/space-grotesk@5.3.0", "npm:@fontsource-variable/space-grotesk@5.3.0",
"npm:@fontsource/comic-neue@5.3.0",
"npm:@fontsource/instrument-serif@5.3.0", "npm:@fontsource/instrument-serif@5.3.0",
"npm:@fontsource/manufacturing-consent@5.3.0",
"npm:@lingui/cli@6.8.0", "npm:@lingui/cli@6.8.0",
"npm:@lingui/conf@6.8.0", "npm:@lingui/conf@6.8.0",
"npm:@lingui/core@6.8.0", "npm:@lingui/core@6.8.0",

File diff suppressed because one or more lines are too long

View File

@@ -13,18 +13,18 @@ msgstr ""
"Language-Team: \n" "Language-Team: \n"
"Plural-Forms: \n" "Plural-Forms: \n"
#: src/components/comment/CommentNode.tsx:140 #: src/components/comment/CommentNode.tsx:137
msgid "[deleted]" msgid "[deleted]"
msgstr "[deleted]" msgstr "[deleted]"
#. placeholder {0}: dump.commentCount #. placeholder {0}: dump.commentCount
#: src/components/dump/DumpCard.tsx:135 #: src/components/dump/DumpCard.tsx:135
#: src/components/dump/JournalCard.tsx:136 #: src/components/dump/JournalCard.tsx:137
msgid "{0, plural, one {# comment} other {# comments}}" msgid "{0, plural, one {# comment} other {# comments}}"
msgstr "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, one {# comment} other {# comments}}"
#. placeholder {0}: playlist.dumpCount #. placeholder {0}: playlist.dumpCount
#: src/components/playlist/PlaylistCard.tsx:131 #: src/components/playlist/PlaylistCard.tsx:141
msgid "{0, plural, one {# dump} other {# dumps}}" msgid "{0, plural, one {# dump} other {# dumps}}"
msgstr "{0, plural, one {# dump} other {# dumps}}" msgstr "{0, plural, one {# dump} other {# dumps}}"
@@ -206,14 +206,14 @@ msgstr "# Welcome to {site}!\n"
"Your account has been created successfully. Welcome aboard!" "Your account has been created successfully. Welcome aboard!"
#: src/components/dump/create/DumpCreateModal.tsx:736 #: src/components/dump/create/DumpCreateModal.tsx:736
#: src/pages/PlaylistDetail.tsx:98 #: src/pages/PlaylistDetail.tsx:99
#: src/pages/UserPublicProfile.tsx:129 #: src/pages/UserPublicProfile.tsx:129
msgid "← Back" msgid "← Back"
msgstr "← Back" msgstr "← Back"
#: src/pages/Dump.tsx:375 #: src/pages/Dump.tsx:374
#: src/pages/Dump.tsx:400 #: src/pages/Dump.tsx:399
#: src/pages/Dump.tsx:627 #: src/pages/Dump.tsx:560
#: src/pages/DumpEdit.tsx:234 #: src/pages/DumpEdit.tsx:234
msgid "← Back to all dumps" msgid "← Back to all dumps"
msgstr "← Back to all dumps" msgstr "← Back to all dumps"
@@ -226,22 +226,42 @@ msgstr "+ Invite someone"
msgid "+ New playlist" msgid "+ New playlist"
msgstr "+ New playlist" msgstr "+ New playlist"
#: src/pages/Dump.tsx:474 #: src/pages/Dump.tsx:451
msgid "+ Playlist" msgid "+ Playlist"
msgstr "+ Playlist" msgstr "+ Playlist"
#. placeholder {0}: d.commenterUsername #. placeholder {0}: d.commenterUsername
#. placeholder {0}: event.username
#. placeholder {1}: d.dumpTitle #. placeholder {1}: d.dumpTitle
#. placeholder {1}: event.dump.title
#: src/components/social/CommunityFeed.tsx:114
#: src/pages/Notifications.tsx:239 #: src/pages/Notifications.tsx:239
msgid "<0>{0}</0> commented on <1>{1}</1>" msgid "<0>{0}</0> commented on <1>{1}</1>"
msgstr "<0>{0}</0> commented on <1>{1}</1>" msgstr "<0>{0}</0> commented on <1>{1}</1>"
#. placeholder {0}: event.username
#. placeholder {1}: event.playlist.title
#: src/components/social/CommunityFeed.tsx:127
msgid "<0>{0}</0> created the playlist <1>{1}</1>"
msgstr "<0>{0}</0> created the playlist <1>{1}</1>"
#. placeholder {0}: d.followerUsername #. placeholder {0}: d.followerUsername
#. placeholder {1}: d.playlistTitle #. placeholder {1}: d.playlistTitle
#: src/pages/Notifications.tsx:199 #: src/pages/Notifications.tsx:199
msgid "<0>{0}</0> followed your playlist <1>{1}</1>" msgid "<0>{0}</0> followed your playlist <1>{1}</1>"
msgstr "<0>{0}</0> followed your playlist <1>{1}</1>" msgstr "<0>{0}</0> followed your playlist <1>{1}</1>"
#. placeholder {0}: event.username
#: src/components/social/CommunityFeed.tsx:145
msgid "<0>{0}</0> joined"
msgstr "<0>{0}</0> joined"
#. placeholder {0}: event.username
#. placeholder {1}: event.invitedByUsername
#: src/components/social/CommunityFeed.tsx:138
msgid "<0>{0}</0> joined, invited by <1>{1}</1>"
msgstr "<0>{0}</0> joined, invited by <1>{1}</1>"
#. placeholder {0}: d.likerUsername #. placeholder {0}: d.likerUsername
#. placeholder {1}: d.dumpTitle #. placeholder {1}: d.dumpTitle
#: src/pages/Notifications.tsx:249 #: src/pages/Notifications.tsx:249
@@ -259,7 +279,10 @@ msgid "<0>{0}</0> mentioned you in <1>{where}</1>"
msgstr "<0>{0}</0> mentioned you in <1>{where}</1>" msgstr "<0>{0}</0> mentioned you in <1>{where}</1>"
#. placeholder {0}: d.dumperUsername #. placeholder {0}: d.dumperUsername
#. placeholder {0}: event.username
#. placeholder {1}: d.dumpTitle #. placeholder {1}: d.dumpTitle
#. placeholder {1}: event.dump.title
#: src/components/social/CommunityFeed.tsx:94
#: src/pages/Notifications.tsx:209 #: src/pages/Notifications.tsx:209
msgid "<0>{0}</0> posted <1>{1}</1>" msgid "<0>{0}</0> posted <1>{1}</1>"
msgstr "<0>{0}</0> posted <1>{1}</1>" msgstr "<0>{0}</0> posted <1>{1}</1>"
@@ -270,7 +293,10 @@ msgid "<0>{0}</0> started following you"
msgstr "<0>{0}</0> started following you" msgstr "<0>{0}</0> started following you"
#. placeholder {0}: d.voterUsername #. placeholder {0}: d.voterUsername
#. placeholder {0}: event.username
#. placeholder {1}: d.dumpTitle #. placeholder {1}: d.dumpTitle
#. placeholder {1}: event.dump.title
#: src/components/social/CommunityFeed.tsx:104
#: src/pages/Notifications.tsx:229 #: src/pages/Notifications.tsx:229
msgid "<0>{0}</0> upvoted <1>{1}</1>" msgid "<0>{0}</0> upvoted <1>{1}</1>"
msgstr "<0>{0}</0> upvoted <1>{1}</1>" msgstr "<0>{0}</0> upvoted <1>{1}</1>"
@@ -285,6 +311,19 @@ msgstr "<0>{0}</0> was added to <1>{1}</1>"
msgid "<0>{name}</0> ({host}) asks to sign you in with your account here, <1>@{username}</1>. It will receive your username, your email address and your avatar." msgid "<0>{name}</0> ({host}) asks to sign you in with your account here, <1>@{username}</1>. It will receive your username, your email address and your avatar."
msgstr "<0>{name}</0> ({host}) asks to sign you in with your account here, <1>@{username}</1>. It will receive your username, your email address and your avatar." msgstr "<0>{name}</0> ({host}) asks to sign you in with your account here, <1>@{username}</1>. It will receive your username, your email address and your avatar."
#: src/components/social/CommunityFeed.tsx:159
msgid "<0>Online now:</0> {names}"
msgstr "<0>Online now:</0> {names}"
#: src/components/social/CommunityFeed.tsx:166
msgid "<0>This week:</0> <1>{figures}</1>"
msgstr "<0>This week:</0> <1>{figures}</1>"
#. placeholder {0}: top.title
#: src/components/social/CommunityFeed.tsx:174
msgid "<0>Top this week:</0> <1>{0}</1>"
msgstr "<0>Top this week:</0> <1>{0}</1>"
#: src/components/chat/ChatModal.tsx:778 #: src/components/chat/ChatModal.tsx:778
msgid "1 new message" msgid "1 new message"
msgstr "1 new message" msgstr "1 new message"
@@ -305,7 +344,7 @@ msgstr "a post"
msgid "Account" msgid "Account"
msgstr "Account" msgstr "Account"
#: src/components/profile/ProfileHeader.tsx:144 #: src/components/profile/ProfileHeader.tsx:151
msgid "Account on <0>{host}</0>" msgid "Account on <0>{host}</0>"
msgstr "Account on <0>{host}</0>" msgstr "Account on <0>{host}</0>"
@@ -313,12 +352,17 @@ msgstr "Account on <0>{host}</0>"
msgid "Add a comment…" msgid "Add a comment…"
msgstr "Add a comment…" msgstr "Add a comment…"
#: src/components/playlist/PlaylistHeader.tsx:117
#: src/components/playlist/PlaylistHeader.tsx:128
msgid "Add a description…"
msgstr "Add a description…"
#: src/components/category/CategoryManager.tsx:115 #: src/components/category/CategoryManager.tsx:115
#: src/components/category/CategoryManager.tsx:116 #: src/components/category/CategoryManager.tsx:116
msgid "Add category" msgid "Add category"
msgstr "Add category" msgstr "Add category"
#: src/components/profile/ProfileHeader.tsx:173 #: src/components/profile/ProfileHeader.tsx:168
msgid "Add email…" msgid "Add email…"
msgstr "Add email…" msgstr "Add email…"
@@ -427,16 +471,13 @@ msgstr "Can't sign in there"
#: src/components/category/CategoryManager.tsx:241 #: src/components/category/CategoryManager.tsx:241
#: src/components/category/CategoryManager.tsx:242 #: src/components/category/CategoryManager.tsx:242
#: src/components/chat/ChatEditBox.tsx:92 #: src/components/comment/CommentForm.tsx:125
#: src/components/comment/CommentForm.tsx:127
#: src/components/dump/create/DumpCreateModal.tsx:746 #: src/components/dump/create/DumpCreateModal.tsx:746
#: src/components/form/FormActions.tsx:42 #: src/components/form/FormActions.tsx:42
#: src/components/form/InlineEdit.tsx:318
#: src/components/playlist/PlaylistEditForm.tsx:222 #: src/components/playlist/PlaylistEditForm.tsx:222
#: src/components/profile/DescriptionEditor.tsx:97
#: src/components/profile/EmailEditor.tsx:83
#: src/components/shared/ConfirmModal.tsx:63 #: src/components/shared/ConfirmModal.tsx:63
#: src/pages/Authorize.tsx:158 #: src/pages/Authorize.tsx:158
#: src/pages/Dump.tsx:516
#: src/pages/DumpEdit.tsx:561 #: src/pages/DumpEdit.tsx:561
msgid "Cancel" msgid "Cancel"
msgstr "Cancel" msgstr "Cancel"
@@ -450,7 +491,7 @@ msgstr "Cancel removal"
msgid "Cancel reply" msgid "Cancel reply"
msgstr "Cancel reply" msgstr "Cancel reply"
#: src/components/layout/AppHeader.tsx:181 #: src/components/layout/AppHeader.tsx:182
msgid "Cancel search" msgid "Cancel search"
msgstr "Cancel search" msgstr "Cancel search"
@@ -462,7 +503,7 @@ msgstr "Categories"
msgid "Category" msgid "Category"
msgstr "Category" msgstr "Category"
#: src/components/profile/ProfileHeader.tsx:102 #: src/components/profile/ProfileHeader.tsx:109
msgid "Change avatar" msgid "Change avatar"
msgstr "Change avatar" msgstr "Change avatar"
@@ -521,7 +562,7 @@ msgstr "Close anyway"
msgid "Close emoji picker" msgid "Close emoji picker"
msgstr "Close emoji picker" msgstr "Close emoji picker"
#: src/components/player/GlobalPlayer.tsx:262 #: src/components/player/GlobalPlayer.tsx:264
msgid "Close player" msgid "Close player"
msgstr "Close player" msgstr "Close player"
@@ -529,7 +570,7 @@ msgstr "Close player"
msgid "Close without posting? The attached file won't be kept — everything you typed will." msgid "Close without posting? The attached file won't be kept — everything you typed will."
msgstr "Close without posting? The attached file won't be kept — everything you typed will." msgstr "Close without posting? The attached file won't be kept — everything you typed will."
#: src/components/player/GlobalPlayer.tsx:254 #: src/components/player/GlobalPlayer.tsx:256
msgid "Collapse player" msgid "Collapse player"
msgstr "Collapse player" msgstr "Collapse player"
@@ -628,7 +669,7 @@ msgstr "Create"
msgid "Create & Add" msgid "Create & Add"
msgstr "Create & Add" msgstr "Create & Add"
#: src/components/playlist/PlaylistCard.tsx:142 #: src/components/playlist/PlaylistCard.tsx:152
msgid "created {ago}" msgid "created {ago}"
msgstr "created {ago}" msgstr "created {ago}"
@@ -652,9 +693,9 @@ msgstr "Default tab"
#: src/components/category/CategoryManager.tsx:253 #: src/components/category/CategoryManager.tsx:253
#: src/components/chat/ChatMessageItem.tsx:118 #: src/components/chat/ChatMessageItem.tsx:118
#: src/components/chat/ChatMessageItem.tsx:119 #: src/components/chat/ChatMessageItem.tsx:119
#: src/components/chat/ChatMessageItem.tsx:220 #: src/components/chat/ChatMessageItem.tsx:217
#: src/components/comment/CommentNode.tsx:259
#: src/components/comment/CommentNode.tsx:265 #: src/components/comment/CommentNode.tsx:265
#: src/components/comment/CommentNode.tsx:271
#: src/components/form/DeleteButton.tsx:36 #: src/components/form/DeleteButton.tsx:36
#: src/components/shared/ConfirmModal.tsx:34 #: src/components/shared/ConfirmModal.tsx:34
msgid "Delete" msgid "Delete"
@@ -679,14 +720,14 @@ msgstr "Delete dump"
msgid "Delete failed ({0})" msgid "Delete failed ({0})"
msgstr "Delete failed ({0})" msgstr "Delete failed ({0})"
#: src/components/playlist/PlaylistCard.tsx:162 #: src/components/playlist/PlaylistCard.tsx:172
#: src/components/playlist/PlaylistEditForm.tsx:213 #: src/components/playlist/PlaylistEditForm.tsx:213
#: src/components/profile/ProfilePlaylistsTab.tsx:224 #: src/components/profile/ProfilePlaylistsTab.tsx:224
#: src/pages/PlaylistDetail.tsx:172 #: src/pages/PlaylistDetail.tsx:174
msgid "Delete playlist" msgid "Delete playlist"
msgstr "Delete playlist" msgstr "Delete playlist"
#: src/components/comment/CommentNode.tsx:270 #: src/components/comment/CommentNode.tsx:264
msgid "Delete this comment?" msgid "Delete this comment?"
msgstr "Delete this comment?" msgstr "Delete this comment?"
@@ -694,12 +735,12 @@ msgstr "Delete this comment?"
msgid "Delete this dump? This cannot be undone." msgid "Delete this dump? This cannot be undone."
msgstr "Delete this dump? This cannot be undone." msgstr "Delete this dump? This cannot be undone."
#: src/components/chat/ChatMessageItem.tsx:219 #: src/components/chat/ChatMessageItem.tsx:216
msgid "Delete this message?" msgid "Delete this message?"
msgstr "Delete this message?" msgstr "Delete this message?"
#: src/components/profile/ProfilePlaylistsTab.tsx:223 #: src/components/profile/ProfilePlaylistsTab.tsx:223
#: src/pages/PlaylistDetail.tsx:171 #: src/pages/PlaylistDetail.tsx:173
msgid "Delete this playlist? This cannot be undone." msgid "Delete this playlist? This cannot be undone."
msgstr "Delete this playlist? This cannot be undone." msgstr "Delete this playlist? This cannot be undone."
@@ -737,7 +778,7 @@ msgid "Done"
msgstr "Done" msgstr "Done"
#. placeholder {0}: dump.fileName #. placeholder {0}: dump.fileName
#: src/components/dump/FilePreview.tsx:226 #: src/components/dump/FilePreview.tsx:233
msgid "Download {0}" msgid "Download {0}"
msgstr "Download {0}" msgstr "Download {0}"
@@ -753,7 +794,7 @@ msgstr "Drop a file here, or paste one"
msgid "Drop a replacement here" msgid "Drop a replacement here"
msgstr "Drop a replacement here" msgstr "Drop a replacement here"
#: src/components/layout/AppHeader.tsx:200 #: src/components/layout/AppHeader.tsx:201
msgid "Dump" msgid "Dump"
msgstr "Dump" msgstr "Dump"
@@ -787,9 +828,9 @@ msgstr "Earlier"
#: src/components/chat/ChatMessageItem.tsx:107 #: src/components/chat/ChatMessageItem.tsx:107
#: src/components/chat/ChatMessageItem.tsx:108 #: src/components/chat/ChatMessageItem.tsx:108
#: src/components/chat/PendingItem.tsx:93 #: src/components/chat/PendingItem.tsx:93
#: src/components/comment/CommentNode.tsx:256 #: src/components/comment/CommentNode.tsx:250
#: src/components/playlist/PlaylistHeader.tsx:60 #: src/components/playlist/PlaylistHeader.tsx:104
#: src/pages/Dump.tsx:623 #: src/pages/Dump.tsx:556
msgid "Edit" msgid "Edit"
msgstr "Edit" msgstr "Edit"
@@ -798,16 +839,22 @@ msgstr "Edit"
msgid "Edit {0}" msgid "Edit {0}"
msgstr "Edit {0}" msgstr "Edit {0}"
#: src/components/profile/ProfileDescription.tsx:58 #: src/components/playlist/PlaylistHeader.tsx:113
#: src/components/profile/ProfileDescription.tsx:45
msgid "Edit description" msgid "Edit description"
msgstr "Edit description" msgstr "Edit description"
#: src/components/profile/ProfileHeader.tsx:171 #: src/components/profile/ProfileHeader.tsx:161
msgid "Edit email" msgid "Edit email"
msgstr "Edit email" msgstr "Edit email"
#: src/pages/Dump.tsx:510
msgid "Edit note"
msgstr "Edit note"
#: src/components/dump/create/TitleField.tsx:69 #: src/components/dump/create/TitleField.tsx:69
#: src/pages/Dump.tsx:533 #: src/components/playlist/PlaylistHeader.tsx:84
#: src/pages/Dump.tsx:459
msgid "Edit title" msgid "Edit title"
msgstr "Edit title" msgstr "Edit title"
@@ -819,9 +866,9 @@ msgstr "edited"
#. placeholder {0}: relativeTime(dump.updatedAt) #. placeholder {0}: relativeTime(dump.updatedAt)
#. placeholder {0}: relativeTime(message.updatedAt) #. placeholder {0}: relativeTime(message.updatedAt)
#: src/components/chat/ChatMessageItem.tsx:132 #: src/components/chat/ChatMessageItem.tsx:132
#: src/components/comment/CommentNode.tsx:205 #: src/components/comment/CommentNode.tsx:202
#: src/components/playlist/PlaylistHeader.tsx:95 #: src/components/playlist/PlaylistHeader.tsx:158
#: src/pages/Dump.tsx:576 #: src/pages/Dump.tsx:492
msgid "edited {0}" msgid "edited {0}"
msgstr "edited {0}" msgstr "edited {0}"
@@ -829,9 +876,9 @@ msgstr "edited {0}"
#. placeholder {0}: dump.updatedAt.toLocaleString() #. placeholder {0}: dump.updatedAt.toLocaleString()
#. placeholder {0}: message.updatedAt.toLocaleString() #. placeholder {0}: message.updatedAt.toLocaleString()
#: src/components/chat/ChatMessageItem.tsx:128 #: src/components/chat/ChatMessageItem.tsx:128
#: src/components/comment/CommentNode.tsx:203 #: src/components/comment/CommentNode.tsx:200
#: src/components/playlist/PlaylistHeader.tsx:93 #: src/components/playlist/PlaylistHeader.tsx:156
#: src/pages/Dump.tsx:574 #: src/pages/Dump.tsx:490
msgid "Edited {0}" msgid "Edited {0}"
msgstr "Edited {0}" msgstr "Edited {0}"
@@ -856,7 +903,7 @@ msgstr "Email address"
msgid "Enter a query to search." msgid "Enter a query to search."
msgstr "Enter a query to search." msgstr "Enter a query to search."
#: src/components/player/GlobalPlayer.tsx:254 #: src/components/player/GlobalPlayer.tsx:256
msgid "Expand player" msgid "Expand player"
msgstr "Expand player" msgstr "Expand player"
@@ -890,15 +937,13 @@ msgstr "Failed to post"
msgid "Failed to post comment" msgid "Failed to post comment"
msgstr "Failed to post comment" msgstr "Failed to post comment"
#: src/components/comment/CommentNode.tsx:291 #: src/components/comment/CommentNode.tsx:285
msgid "Failed to post reply" msgid "Failed to post reply"
msgstr "Failed to post reply" msgstr "Failed to post reply"
#: src/components/federation/GrantList.tsx:98 #: src/components/federation/GrantList.tsx:98
#: src/components/federation/PeerManager.tsx:158 #: src/components/federation/PeerManager.tsx:158
#: src/components/playlist/PlaylistEditForm.tsx:207 #: src/components/playlist/PlaylistEditForm.tsx:207
#: src/components/profile/DescriptionEditor.tsx:99
#: src/components/profile/EmailEditor.tsx:86
msgid "Failed to save" msgid "Failed to save"
msgstr "Failed to save" msgstr "Failed to save"
@@ -906,11 +951,7 @@ msgstr "Failed to save"
msgid "Failed to save category" msgid "Failed to save category"
msgstr "Failed to save category" msgstr "Failed to save category"
#: src/components/comment/CommentNode.tsx:218 #: src/components/profile/ProfileHeader.tsx:174
msgid "Failed to save edit"
msgstr "Failed to save edit"
#: src/components/profile/ProfileHeader.tsx:182
msgid "Failed to update avatar" msgid "Failed to update avatar"
msgstr "Failed to update avatar" msgstr "Failed to update avatar"
@@ -1025,7 +1066,7 @@ msgstr "If you didn't ask for this, ignore this email. Your password stays the s
msgid "image" msgid "image"
msgstr "image" msgstr "image"
#: src/pages/Dump.tsx:643 #: src/pages/Dump.tsx:576
msgid "In collections" msgid "In collections"
msgstr "In collections" msgstr "In collections"
@@ -1056,7 +1097,7 @@ msgstr "Invalid link"
msgid "Invite link" msgid "Invite link"
msgstr "Invite link" msgstr "Invite link"
#: src/components/profile/ProfileHeader.tsx:126 #: src/components/profile/ProfileHeader.tsx:133
msgid "invited by" msgid "invited by"
msgstr "invited by" msgstr "invited by"
@@ -1114,7 +1155,7 @@ msgstr "Link or file"
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…"
#: src/components/layout/AppHeader.tsx:233 #: src/components/layout/AppHeader.tsx:236
msgid "Live updates unavailable." msgid "Live updates unavailable."
msgstr "Live updates unavailable." msgstr "Live updates unavailable."
@@ -1126,7 +1167,7 @@ msgstr "Load more"
msgid "Load older messages" msgid "Load older messages"
msgstr "Load older messages" msgstr "Load older messages"
#: src/pages/Dump.tsx:359 #: src/pages/Dump.tsx:358
#: src/pages/DumpEdit.tsx:210 #: src/pages/DumpEdit.tsx:210
msgid "Loading dump…" msgid "Loading dump…"
msgstr "Loading dump…" msgstr "Loading dump…"
@@ -1139,7 +1180,7 @@ msgstr "Loading messages…"
msgid "Loading more…" msgid "Loading more…"
msgstr "Loading more…" msgstr "Loading more…"
#: src/pages/PlaylistDetail.tsx:82 #: src/pages/PlaylistDetail.tsx:83
msgid "Loading playlist…" msgid "Loading playlist…"
msgstr "Loading playlist…" msgstr "Loading playlist…"
@@ -1159,7 +1200,7 @@ msgstr "Loading profile…"
msgid "Loading…" msgid "Loading…"
msgstr "Loading…" msgstr "Loading…"
#: src/components/layout/AppHeader.tsx:207 #: src/components/layout/AppHeader.tsx:208
#: src/pages/FederationCallback.tsx:32 #: src/pages/FederationCallback.tsx:32
#: src/pages/UserLogin.tsx:41 #: src/pages/UserLogin.tsx:41
#: src/pages/UserLogin.tsx:66 #: src/pages/UserLogin.tsx:66
@@ -1273,7 +1314,7 @@ msgstr "New public playlists"
msgid "Next →" msgid "Next →"
msgstr "Next →" msgstr "Next →"
#: src/components/player/GlobalPlayer.tsx:244 #: src/components/player/GlobalPlayer.tsx:246
msgid "Next track" msgid "Next track"
msgstr "Next track" msgstr "Next track"
@@ -1388,11 +1429,11 @@ msgid "email.welcome.button"
msgstr "Open {site}" msgstr "Open {site}"
#. placeholder {0}: richContent.siteName #. placeholder {0}: richContent.siteName
#: src/components/dump/RichContentCard.tsx:122 #: src/components/dump/RichContentCard.tsx:124
msgid "Open on {0}" msgid "Open on {0}"
msgstr "Open on {0}" msgstr "Open on {0}"
#: src/components/dump/RichContentCard.tsx:123 #: src/components/dump/RichContentCard.tsx:125
msgid "Open source" msgid "Open source"
msgstr "Open source" msgstr "Open source"
@@ -1453,9 +1494,9 @@ msgstr "Passwords do not match"
msgid "Paste a link…" msgid "Paste a link…"
msgstr "Paste a link…" msgstr "Paste a link…"
#: src/components/dump/FilePreview.tsx:70 #: src/components/dump/FilePreview.tsx:72
#: src/components/dump/FilePreview.tsx:184 #: src/components/dump/FilePreview.tsx:191
#: src/components/dump/RichContentCard.tsx:103 #: src/components/dump/RichContentCard.tsx:105
#: src/components/player/MediaPlayer.tsx:276 #: src/components/player/MediaPlayer.tsx:276
msgid "Pause" msgid "Pause"
msgstr "Pause" msgstr "Pause"
@@ -1464,10 +1505,10 @@ msgstr "Pause"
msgid "Picked up where you left off." msgid "Picked up where you left off."
msgstr "Picked up where you left off." msgstr "Picked up where you left off."
#: src/components/dump/FilePreview.tsx:70 #: src/components/dump/FilePreview.tsx:72
#: src/components/dump/FilePreview.tsx:184 #: src/components/dump/FilePreview.tsx:191
#: src/components/dump/RichContentCard.tsx:103 #: src/components/dump/RichContentCard.tsx:105
#: src/components/dump/RichContentCard.tsx:150 #: src/components/dump/RichContentCard.tsx:153
#: src/components/player/MediaPlayer.tsx:276 #: src/components/player/MediaPlayer.tsx:276
msgid "Play" msgid "Play"
msgstr "Play" msgstr "Play"
@@ -1477,7 +1518,7 @@ msgid "Playlist categories"
msgstr "Playlist categories" msgstr "Playlist categories"
#: src/components/dump/create/StepIndicator.tsx:21 #: src/components/dump/create/StepIndicator.tsx:21
#: src/components/layout/AppHeader.tsx:173 #: src/components/layout/AppHeader.tsx:174
#: src/components/layout/UserMenu.tsx:93 #: src/components/layout/UserMenu.tsx:93
#: src/components/profile/ProfileFollowTab.tsx:147 #: src/components/profile/ProfileFollowTab.tsx:147
#: src/components/profile/ProfilePlaylistsTab.tsx:162 #: src/components/profile/ProfilePlaylistsTab.tsx:162
@@ -1492,26 +1533,26 @@ msgstr "Playlists"
msgid "Post comment" msgid "Post comment"
msgstr "Post comment" msgstr "Post comment"
#: src/components/comment/CommentNode.tsx:289 #: src/components/comment/CommentNode.tsx:283
msgid "Post reply" msgid "Post reply"
msgstr "Post reply" msgstr "Post reply"
#: src/components/comment/CommentNode.tsx:290 #: src/components/comment/CommentNode.tsx:284
#: src/components/comment/CommentThread.tsx:100 #: src/components/comment/CommentThread.tsx:100
#: src/components/dump/create/DumpCreateModal.tsx:752 #: src/components/dump/create/DumpCreateModal.tsx:752
msgid "Posting…" msgid "Posting…"
msgstr "Posting…" msgstr "Posting…"
#: src/components/player/GlobalPlayer.tsx:228 #: src/components/player/GlobalPlayer.tsx:230
msgid "Previous track" msgid "Previous track"
msgstr "Previous track" msgstr "Previous track"
#: src/components/dump/DumpCard.tsx:144 #: src/components/dump/DumpCard.tsx:144
#: src/components/dump/JournalCard.tsx:145 #: src/components/dump/JournalCard.tsx:146
#: src/components/playlist/PlaylistCard.tsx:117 #: src/components/playlist/PlaylistCard.tsx:127
#: src/components/playlist/PlaylistHeader.tsx:77 #: src/components/playlist/PlaylistHeader.tsx:140
#: src/components/playlist/PlaylistMembershipPanel.tsx:81 #: src/components/playlist/PlaylistMembershipPanel.tsx:81
#: src/pages/Dump.tsx:582 #: src/pages/Dump.tsx:498
msgid "private" msgid "private"
msgstr "private" msgstr "private"
@@ -1519,8 +1560,8 @@ msgstr "private"
msgid "Private" msgid "Private"
msgstr "Private" msgstr "Private"
#: src/components/playlist/PlaylistCard.tsx:116 #: src/components/playlist/PlaylistCard.tsx:126
#: src/components/playlist/PlaylistHeader.tsx:77 #: src/components/playlist/PlaylistHeader.tsx:140
msgid "public" msgid "public"
msgstr "public" msgstr "public"
@@ -1532,7 +1573,7 @@ msgstr "Public"
msgid "Refresh metadata" msgid "Refresh metadata"
msgstr "Refresh metadata" msgstr "Refresh metadata"
#: src/components/player/GlobalPlayer.tsx:324 #: src/components/player/GlobalPlayer.tsx:326
msgid "Refreshing stream…" msgid "Refreshing stream…"
msgstr "Refreshing stream…" msgstr "Refreshing stream…"
@@ -1554,7 +1595,7 @@ msgstr "Registering…"
msgid "Registration failed" msgid "Registration failed"
msgstr "Registration failed" msgstr "Registration failed"
#: src/pages/Dump.tsx:634 #: src/pages/Dump.tsx:567
msgid "Related" msgid "Related"
msgstr "Related" msgstr "Related"
@@ -1594,7 +1635,7 @@ msgstr "Replace file"
#: src/components/chat/ChatMessageItem.tsx:97 #: src/components/chat/ChatMessageItem.tsx:97
#: src/components/chat/ChatMessageItem.tsx:98 #: src/components/chat/ChatMessageItem.tsx:98
#: src/components/comment/CommentNode.tsx:244 #: src/components/comment/CommentNode.tsx:240
msgid "Reply" msgid "Reply"
msgstr "Reply" msgstr "Reply"
@@ -1641,7 +1682,7 @@ msgid "email.reset.title"
msgstr "Reset your password" msgstr "Reset your password"
#: src/components/chat/PendingItem.tsx:86 #: src/components/chat/PendingItem.tsx:86
#: src/pages/Dump.tsx:393 #: src/pages/Dump.tsx:392
#: src/pages/DumpEdit.tsx:227 #: src/pages/DumpEdit.tsx:227
msgid "Retry" msgid "Retry"
msgstr "Retry" msgstr "Retry"
@@ -1651,27 +1692,20 @@ msgid "Role"
msgstr "Role" msgstr "Role"
#: src/components/category/CategoryManager.tsx:232 #: src/components/category/CategoryManager.tsx:232
#: src/components/chat/ChatEditBox.tsx:85 #: src/components/form/InlineEdit.tsx:310
#: src/components/comment/CommentNode.tsx:216
#: src/components/playlist/PlaylistEditForm.tsx:229 #: src/components/playlist/PlaylistEditForm.tsx:229
#: src/components/profile/DescriptionEditor.tsx:89
#: src/components/profile/EmailEditor.tsx:75
#: src/pages/Dump.tsx:508
#: src/pages/DumpEdit.tsx:564 #: src/pages/DumpEdit.tsx:564
msgid "Save" msgid "Save"
msgstr "Save" msgstr "Save"
#: src/components/comment/CommentNode.tsx:217 #: src/components/form/InlineEdit.tsx:307
#: src/components/playlist/PlaylistEditForm.tsx:226 #: src/components/playlist/PlaylistEditForm.tsx:226
#: src/components/profile/ChangePasswordModal.tsx:119 #: src/components/profile/ChangePasswordModal.tsx:119
#: src/components/profile/DescriptionEditor.tsx:86
#: src/components/profile/EmailEditor.tsx:72
#: src/pages/Dump.tsx:507
#: src/pages/ResetPassword.tsx:191 #: src/pages/ResetPassword.tsx:191
msgid "Saving…" msgid "Saving…"
msgstr "Saving…" msgstr "Saving…"
#: src/components/layout/AppHeader.tsx:181 #: src/components/layout/AppHeader.tsx:182
#: src/components/layout/SearchBar.tsx:83 #: src/components/layout/SearchBar.tsx:83
#: src/pages/Search.tsx:106 #: src/pages/Search.tsx:106
msgid "Search" msgid "Search"
@@ -1719,7 +1753,7 @@ msgstr "Send reset link"
msgid "Sending…" msgid "Sending…"
msgstr "Sending…" msgstr "Sending…"
#: src/components/layout/AppHeader.tsx:197 #: src/components/layout/AppHeader.tsx:198
msgid "Server unreachable" msgid "Server unreachable"
msgstr "Server unreachable" msgstr "Server unreachable"
@@ -1776,7 +1810,7 @@ msgstr "Someone asked to reset the password of your {site} account. The button b
#: 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:738 #: src/contexts/ws/WSProvider.tsx:738
#: src/pages/PlaylistDetail.tsx:155 #: src/pages/PlaylistDetail.tsx:157
msgid "Something went wrong" msgid "Something went wrong"
msgstr "Something went wrong" msgstr "Something went wrong"
@@ -1836,11 +1870,11 @@ msgstr "The month on {site}, {range}"
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."
#: src/components/comment/CommentNode.tsx:282 #: src/components/comment/CommentNode.tsx:276
msgid "This comment couldn't be deleted. Try again." msgid "This comment couldn't be deleted. Try again."
msgstr "This comment couldn't be deleted. Try again." msgstr "This comment couldn't be deleted. Try again."
#: src/pages/Dump.tsx:368 #: src/pages/Dump.tsx:367
msgid "This dump was deleted, or made private." msgid "This dump was deleted, or made private."
msgstr "This dump was deleted, or made private." msgstr "This dump was deleted, or made private."
@@ -1911,7 +1945,7 @@ msgid "digest.topWeek"
msgstr "Top this week" msgstr "Top this week"
#. placeholder {0}: i + 1 #. placeholder {0}: i + 1
#: src/components/player/GlobalPlayer.tsx:338 #: src/components/player/GlobalPlayer.tsx:340
msgid "Track {0}" msgid "Track {0}"
msgstr "Track {0}" msgstr "Track {0}"
@@ -1985,7 +2019,7 @@ msgstr "Unsubscribed"
msgid "Updated" msgid "Updated"
msgstr "Updated" msgstr "Updated"
#: src/components/playlist/PlaylistCard.tsx:141 #: src/components/playlist/PlaylistCard.tsx:151
msgid "updated {ago}" msgid "updated {ago}"
msgstr "updated {ago}" msgstr "updated {ago}"
@@ -1994,7 +2028,7 @@ msgstr "updated {ago}"
msgid "email.digest.upload" msgid "email.digest.upload"
msgstr "upload" msgstr "upload"
#: src/components/profile/ProfileHeader.tsx:79 #: src/components/profile/ProfileHeader.tsx:80
msgid "Upload failed" msgid "Upload failed"
msgstr "Upload failed" msgstr "Upload failed"
@@ -2097,12 +2131,14 @@ msgid "email.welcome.subject"
msgstr "Welcome to {site}" msgstr "Welcome to {site}"
#: src/components/dump/create/DumpCreateModal.tsx:705 #: src/components/dump/create/DumpCreateModal.tsx:705
#: src/pages/Dump.tsx:514
#: src/pages/Dump.tsx:521
#: src/pages/DumpEdit.tsx:539 #: src/pages/DumpEdit.tsx:539
msgid "What makes it worth it?" msgid "What makes it worth it?"
msgstr "What makes it worth it?" msgstr "What makes it worth it?"
#: src/components/profile/DescriptionEditor.tsx:78 #: src/components/profile/ProfileDescription.tsx:62
#: src/components/profile/ProfileDescription.tsx:79 #: src/components/profile/ProfileDescription.tsx:72
msgid "Who am I?" msgid "Who am I?"
msgstr "Who am I?" msgstr "Who am I?"
@@ -2119,7 +2155,7 @@ msgstr "Why & where"
msgid "Why?" msgid "Why?"
msgstr "Why?" msgstr "Why?"
#: src/components/comment/CommentNode.tsx:293 #: src/components/comment/CommentNode.tsx:287
msgid "Write a reply…" msgid "Write a reply…"
msgstr "Write a reply…" msgstr "Write a reply…"

File diff suppressed because one or more lines are too long

View File

@@ -13,18 +13,18 @@ msgstr ""
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: \n" "Language-Team: \n"
#: src/components/comment/CommentNode.tsx:140 #: src/components/comment/CommentNode.tsx:137
msgid "[deleted]" msgid "[deleted]"
msgstr "[supprimé]" msgstr "[supprimé]"
#. placeholder {0}: dump.commentCount #. placeholder {0}: dump.commentCount
#: src/components/dump/DumpCard.tsx:135 #: src/components/dump/DumpCard.tsx:135
#: src/components/dump/JournalCard.tsx:136 #: src/components/dump/JournalCard.tsx:137
msgid "{0, plural, one {# comment} other {# comments}}" msgid "{0, plural, one {# comment} other {# comments}}"
msgstr "{0, plural, one {# commentaire} other {# commentaires}}" msgstr "{0, plural, one {# commentaire} other {# commentaires}}"
#. placeholder {0}: playlist.dumpCount #. placeholder {0}: playlist.dumpCount
#: src/components/playlist/PlaylistCard.tsx:131 #: src/components/playlist/PlaylistCard.tsx:141
msgid "{0, plural, one {# dump} other {# dumps}}" msgid "{0, plural, one {# dump} other {# dumps}}"
msgstr "{0, plural, one {# reco} other {# recos}}" msgstr "{0, plural, one {# reco} other {# recos}}"
@@ -206,14 +206,14 @@ msgstr "# Bienvenue sur {site} !\n"
"Votre compte a bien été créé. Bienvenue à bord !" "Votre compte a bien été créé. Bienvenue à bord !"
#: src/components/dump/create/DumpCreateModal.tsx:736 #: src/components/dump/create/DumpCreateModal.tsx:736
#: src/pages/PlaylistDetail.tsx:98 #: src/pages/PlaylistDetail.tsx:99
#: src/pages/UserPublicProfile.tsx:129 #: src/pages/UserPublicProfile.tsx:129
msgid "← Back" msgid "← Back"
msgstr "← Retour" msgstr "← Retour"
#: src/pages/Dump.tsx:375 #: src/pages/Dump.tsx:374
#: src/pages/Dump.tsx:400 #: src/pages/Dump.tsx:399
#: src/pages/Dump.tsx:627 #: src/pages/Dump.tsx:560
#: src/pages/DumpEdit.tsx:234 #: src/pages/DumpEdit.tsx:234
msgid "← Back to all dumps" msgid "← Back to all dumps"
msgstr "← Retour à toutes les recos" msgstr "← Retour à toutes les recos"
@@ -226,22 +226,42 @@ msgstr "+ Inviter quelqu'un"
msgid "+ New playlist" msgid "+ New playlist"
msgstr "+ Nouvelle collection" msgstr "+ Nouvelle collection"
#: src/pages/Dump.tsx:474 #: src/pages/Dump.tsx:451
msgid "+ Playlist" msgid "+ Playlist"
msgstr "+ Collection" msgstr "+ Collection"
#. placeholder {0}: d.commenterUsername #. placeholder {0}: d.commenterUsername
#. placeholder {0}: event.username
#. placeholder {1}: d.dumpTitle #. placeholder {1}: d.dumpTitle
#. placeholder {1}: event.dump.title
#: src/components/social/CommunityFeed.tsx:114
#: src/pages/Notifications.tsx:239 #: src/pages/Notifications.tsx:239
msgid "<0>{0}</0> commented on <1>{1}</1>" msgid "<0>{0}</0> commented on <1>{1}</1>"
msgstr "<0>{0}</0> a commenté sur <1>{1}</1>" msgstr "<0>{0}</0> a commenté sur <1>{1}</1>"
#. placeholder {0}: event.username
#. placeholder {1}: event.playlist.title
#: src/components/social/CommunityFeed.tsx:127
msgid "<0>{0}</0> created the playlist <1>{1}</1>"
msgstr "<0>{0}</0> a créé la playlist <1>{1}</1>"
#. placeholder {0}: d.followerUsername #. placeholder {0}: d.followerUsername
#. placeholder {1}: d.playlistTitle #. placeholder {1}: d.playlistTitle
#: src/pages/Notifications.tsx:199 #: src/pages/Notifications.tsx:199
msgid "<0>{0}</0> followed your playlist <1>{1}</1>" msgid "<0>{0}</0> followed your playlist <1>{1}</1>"
msgstr "<0>{0}</0> a suivi votre collection <1>{1}</1>" msgstr "<0>{0}</0> a suivi votre collection <1>{1}</1>"
#. placeholder {0}: event.username
#: src/components/social/CommunityFeed.tsx:145
msgid "<0>{0}</0> joined"
msgstr "<0>{0}</0> a rejoint le site"
#. placeholder {0}: event.username
#. placeholder {1}: event.invitedByUsername
#: src/components/social/CommunityFeed.tsx:138
msgid "<0>{0}</0> joined, invited by <1>{1}</1>"
msgstr "<0>{0}</0> a rejoint le site, sur invitation de <1>{1}</1>"
#. placeholder {0}: d.likerUsername #. placeholder {0}: d.likerUsername
#. placeholder {1}: d.dumpTitle #. placeholder {1}: d.dumpTitle
#: src/pages/Notifications.tsx:249 #: src/pages/Notifications.tsx:249
@@ -259,7 +279,10 @@ msgid "<0>{0}</0> mentioned you in <1>{where}</1>"
msgstr "<0>{0}</0> vous a mentionné dans <1>{where}</1>" msgstr "<0>{0}</0> vous a mentionné dans <1>{where}</1>"
#. placeholder {0}: d.dumperUsername #. placeholder {0}: d.dumperUsername
#. placeholder {0}: event.username
#. placeholder {1}: d.dumpTitle #. placeholder {1}: d.dumpTitle
#. placeholder {1}: event.dump.title
#: src/components/social/CommunityFeed.tsx:94
#: src/pages/Notifications.tsx:209 #: src/pages/Notifications.tsx:209
msgid "<0>{0}</0> posted <1>{1}</1>" msgid "<0>{0}</0> posted <1>{1}</1>"
msgstr "<0>{0}</0> a publié <1>{1}</1>" msgstr "<0>{0}</0> a publié <1>{1}</1>"
@@ -270,7 +293,10 @@ msgid "<0>{0}</0> started following you"
msgstr "<0>{0}</0> a commencé à vous suivre" msgstr "<0>{0}</0> a commencé à vous suivre"
#. placeholder {0}: d.voterUsername #. placeholder {0}: d.voterUsername
#. placeholder {0}: event.username
#. placeholder {1}: d.dumpTitle #. placeholder {1}: d.dumpTitle
#. placeholder {1}: event.dump.title
#: src/components/social/CommunityFeed.tsx:104
#: src/pages/Notifications.tsx:229 #: src/pages/Notifications.tsx:229
msgid "<0>{0}</0> upvoted <1>{1}</1>" msgid "<0>{0}</0> upvoted <1>{1}</1>"
msgstr "<0>{0}</0> a validé <1>{1}</1>" msgstr "<0>{0}</0> a validé <1>{1}</1>"
@@ -285,6 +311,19 @@ msgstr "<0>{0}</0> a été ajouté à <1>{1}</1>"
msgid "<0>{name}</0> ({host}) asks to sign you in with your account here, <1>@{username}</1>. It will receive your username, your email address and your avatar." msgid "<0>{name}</0> ({host}) asks to sign you in with your account here, <1>@{username}</1>. It will receive your username, your email address and your avatar."
msgstr "<0>{name}</0> ({host}) demande à vous connecter avec votre compte d'ici, <1>@{username}</1>. Il recevra votre nom d'utilisateur, votre adresse e-mail et votre avatar." msgstr "<0>{name}</0> ({host}) demande à vous connecter avec votre compte d'ici, <1>@{username}</1>. Il recevra votre nom d'utilisateur, votre adresse e-mail et votre avatar."
#: src/components/social/CommunityFeed.tsx:159
msgid "<0>Online now:</0> {names}"
msgstr "<0>En ligne :</0> {names}"
#: src/components/social/CommunityFeed.tsx:166
msgid "<0>This week:</0> <1>{figures}</1>"
msgstr "<0>Cette semaine :</0> <1>{figures}</1>"
#. placeholder {0}: top.title
#: src/components/social/CommunityFeed.tsx:174
msgid "<0>Top this week:</0> <1>{0}</1>"
msgstr "<0>En tête cette semaine :</0> <1>{0}</1>"
#: src/components/chat/ChatModal.tsx:778 #: src/components/chat/ChatModal.tsx:778
msgid "1 new message" msgid "1 new message"
msgstr "1 nouveau message" msgstr "1 nouveau message"
@@ -305,7 +344,7 @@ msgstr "une publication"
msgid "Account" msgid "Account"
msgstr "Compte" msgstr "Compte"
#: src/components/profile/ProfileHeader.tsx:144 #: src/components/profile/ProfileHeader.tsx:151
msgid "Account on <0>{host}</0>" msgid "Account on <0>{host}</0>"
msgstr "Compte sur <0>{host}</0>" msgstr "Compte sur <0>{host}</0>"
@@ -313,12 +352,17 @@ msgstr "Compte sur <0>{host}</0>"
msgid "Add a comment…" msgid "Add a comment…"
msgstr "Ajouter un commentaire…" msgstr "Ajouter un commentaire…"
#: src/components/playlist/PlaylistHeader.tsx:117
#: src/components/playlist/PlaylistHeader.tsx:128
msgid "Add a description…"
msgstr "Ajouter une description…"
#: src/components/category/CategoryManager.tsx:115 #: src/components/category/CategoryManager.tsx:115
#: src/components/category/CategoryManager.tsx:116 #: src/components/category/CategoryManager.tsx:116
msgid "Add category" msgid "Add category"
msgstr "Ajouter une catégorie" msgstr "Ajouter une catégorie"
#: src/components/profile/ProfileHeader.tsx:173 #: src/components/profile/ProfileHeader.tsx:168
msgid "Add email…" msgid "Add email…"
msgstr "Ajouter un e-mail…" msgstr "Ajouter un e-mail…"
@@ -427,16 +471,13 @@ msgstr "Connexion impossible"
#: src/components/category/CategoryManager.tsx:241 #: src/components/category/CategoryManager.tsx:241
#: src/components/category/CategoryManager.tsx:242 #: src/components/category/CategoryManager.tsx:242
#: src/components/chat/ChatEditBox.tsx:92 #: src/components/comment/CommentForm.tsx:125
#: src/components/comment/CommentForm.tsx:127
#: src/components/dump/create/DumpCreateModal.tsx:746 #: src/components/dump/create/DumpCreateModal.tsx:746
#: src/components/form/FormActions.tsx:42 #: src/components/form/FormActions.tsx:42
#: src/components/form/InlineEdit.tsx:318
#: src/components/playlist/PlaylistEditForm.tsx:222 #: src/components/playlist/PlaylistEditForm.tsx:222
#: src/components/profile/DescriptionEditor.tsx:97
#: src/components/profile/EmailEditor.tsx:83
#: src/components/shared/ConfirmModal.tsx:63 #: src/components/shared/ConfirmModal.tsx:63
#: src/pages/Authorize.tsx:158 #: src/pages/Authorize.tsx:158
#: src/pages/Dump.tsx:516
#: src/pages/DumpEdit.tsx:561 #: src/pages/DumpEdit.tsx:561
msgid "Cancel" msgid "Cancel"
msgstr "Annuler" msgstr "Annuler"
@@ -450,7 +491,7 @@ msgstr "Annuler la suppression"
msgid "Cancel reply" msgid "Cancel reply"
msgstr "Annuler la réponse" msgstr "Annuler la réponse"
#: src/components/layout/AppHeader.tsx:181 #: src/components/layout/AppHeader.tsx:182
msgid "Cancel search" msgid "Cancel search"
msgstr "Annuler la recherche" msgstr "Annuler la recherche"
@@ -462,7 +503,7 @@ msgstr "Catégories"
msgid "Category" msgid "Category"
msgstr "Catégorie" msgstr "Catégorie"
#: src/components/profile/ProfileHeader.tsx:102 #: src/components/profile/ProfileHeader.tsx:109
msgid "Change avatar" msgid "Change avatar"
msgstr "Changer l'avatar" msgstr "Changer l'avatar"
@@ -521,7 +562,7 @@ msgstr "Fermer quand même"
msgid "Close emoji picker" msgid "Close emoji picker"
msgstr "Fermer le sélecteur d'emoji" msgstr "Fermer le sélecteur d'emoji"
#: src/components/player/GlobalPlayer.tsx:262 #: src/components/player/GlobalPlayer.tsx:264
msgid "Close player" msgid "Close player"
msgstr "Fermer le lecteur" msgstr "Fermer le lecteur"
@@ -529,7 +570,7 @@ msgstr "Fermer le lecteur"
msgid "Close without posting? The attached file won't be kept — everything you typed will." msgid "Close without posting? The attached file won't be kept — everything you typed will."
msgstr "Fermer sans publier ? Le fichier joint sera perdu — ce que vous avez écrit sera conservé." msgstr "Fermer sans publier ? Le fichier joint sera perdu — ce que vous avez écrit sera conservé."
#: src/components/player/GlobalPlayer.tsx:254 #: src/components/player/GlobalPlayer.tsx:256
msgid "Collapse player" msgid "Collapse player"
msgstr "Réduire le lecteur" msgstr "Réduire le lecteur"
@@ -628,7 +669,7 @@ msgstr "Créer"
msgid "Create & Add" msgid "Create & Add"
msgstr "Créer et ajouter" msgstr "Créer et ajouter"
#: src/components/playlist/PlaylistCard.tsx:142 #: src/components/playlist/PlaylistCard.tsx:152
msgid "created {ago}" msgid "created {ago}"
msgstr "créée {ago}" msgstr "créée {ago}"
@@ -652,9 +693,9 @@ msgstr "Onglet par défaut"
#: src/components/category/CategoryManager.tsx:253 #: src/components/category/CategoryManager.tsx:253
#: src/components/chat/ChatMessageItem.tsx:118 #: src/components/chat/ChatMessageItem.tsx:118
#: src/components/chat/ChatMessageItem.tsx:119 #: src/components/chat/ChatMessageItem.tsx:119
#: src/components/chat/ChatMessageItem.tsx:220 #: src/components/chat/ChatMessageItem.tsx:217
#: src/components/comment/CommentNode.tsx:259
#: src/components/comment/CommentNode.tsx:265 #: src/components/comment/CommentNode.tsx:265
#: src/components/comment/CommentNode.tsx:271
#: src/components/form/DeleteButton.tsx:36 #: src/components/form/DeleteButton.tsx:36
#: src/components/shared/ConfirmModal.tsx:34 #: src/components/shared/ConfirmModal.tsx:34
msgid "Delete" msgid "Delete"
@@ -679,14 +720,14 @@ msgstr "Supprimer la reco"
msgid "Delete failed ({0})" msgid "Delete failed ({0})"
msgstr "Échec de la suppression ({0})" msgstr "Échec de la suppression ({0})"
#: src/components/playlist/PlaylistCard.tsx:162 #: src/components/playlist/PlaylistCard.tsx:172
#: src/components/playlist/PlaylistEditForm.tsx:213 #: src/components/playlist/PlaylistEditForm.tsx:213
#: src/components/profile/ProfilePlaylistsTab.tsx:224 #: src/components/profile/ProfilePlaylistsTab.tsx:224
#: src/pages/PlaylistDetail.tsx:172 #: src/pages/PlaylistDetail.tsx:174
msgid "Delete playlist" msgid "Delete playlist"
msgstr "Supprimer la collection" msgstr "Supprimer la collection"
#: src/components/comment/CommentNode.tsx:270 #: src/components/comment/CommentNode.tsx:264
msgid "Delete this comment?" msgid "Delete this comment?"
msgstr "Supprimer ce commentaire ?" msgstr "Supprimer ce commentaire ?"
@@ -694,12 +735,12 @@ msgstr "Supprimer ce commentaire ?"
msgid "Delete this dump? This cannot be undone." msgid "Delete this dump? This cannot be undone."
msgstr "Supprimer cette reco ? Cette action est irréversible." msgstr "Supprimer cette reco ? Cette action est irréversible."
#: src/components/chat/ChatMessageItem.tsx:219 #: src/components/chat/ChatMessageItem.tsx:216
msgid "Delete this message?" msgid "Delete this message?"
msgstr "Supprimer ce message ?" msgstr "Supprimer ce message ?"
#: src/components/profile/ProfilePlaylistsTab.tsx:223 #: src/components/profile/ProfilePlaylistsTab.tsx:223
#: src/pages/PlaylistDetail.tsx:171 #: src/pages/PlaylistDetail.tsx:173
msgid "Delete this playlist? This cannot be undone." msgid "Delete this playlist? This cannot be undone."
msgstr "Supprimer cette collection ? Cette action est irréversible." msgstr "Supprimer cette collection ? Cette action est irréversible."
@@ -737,7 +778,7 @@ msgid "Done"
msgstr "Terminé" msgstr "Terminé"
#. placeholder {0}: dump.fileName #. placeholder {0}: dump.fileName
#: src/components/dump/FilePreview.tsx:226 #: src/components/dump/FilePreview.tsx:233
msgid "Download {0}" msgid "Download {0}"
msgstr "Télécharger {0}" msgstr "Télécharger {0}"
@@ -753,7 +794,7 @@ msgstr "Déposez un fichier ici, ou collez-en un"
msgid "Drop a replacement here" msgid "Drop a replacement here"
msgstr "Déposez un fichier de remplacement ici" msgstr "Déposez un fichier de remplacement ici"
#: src/components/layout/AppHeader.tsx:200 #: src/components/layout/AppHeader.tsx:201
msgid "Dump" msgid "Dump"
msgstr "Reco" msgstr "Reco"
@@ -787,9 +828,9 @@ msgstr "Plus tôt"
#: src/components/chat/ChatMessageItem.tsx:107 #: src/components/chat/ChatMessageItem.tsx:107
#: src/components/chat/ChatMessageItem.tsx:108 #: src/components/chat/ChatMessageItem.tsx:108
#: src/components/chat/PendingItem.tsx:93 #: src/components/chat/PendingItem.tsx:93
#: src/components/comment/CommentNode.tsx:256 #: src/components/comment/CommentNode.tsx:250
#: src/components/playlist/PlaylistHeader.tsx:60 #: src/components/playlist/PlaylistHeader.tsx:104
#: src/pages/Dump.tsx:623 #: src/pages/Dump.tsx:556
msgid "Edit" msgid "Edit"
msgstr "Modifier" msgstr "Modifier"
@@ -798,16 +839,22 @@ msgstr "Modifier"
msgid "Edit {0}" msgid "Edit {0}"
msgstr "Modifier {0}" msgstr "Modifier {0}"
#: src/components/profile/ProfileDescription.tsx:58 #: src/components/playlist/PlaylistHeader.tsx:113
#: src/components/profile/ProfileDescription.tsx:45
msgid "Edit description" msgid "Edit description"
msgstr "Modifier la description" msgstr "Modifier la description"
#: src/components/profile/ProfileHeader.tsx:171 #: src/components/profile/ProfileHeader.tsx:161
msgid "Edit email" msgid "Edit email"
msgstr "Modifier l'e-mail" msgstr "Modifier l'e-mail"
#: src/pages/Dump.tsx:510
msgid "Edit note"
msgstr "Modifier la note"
#: src/components/dump/create/TitleField.tsx:69 #: src/components/dump/create/TitleField.tsx:69
#: src/pages/Dump.tsx:533 #: src/components/playlist/PlaylistHeader.tsx:84
#: src/pages/Dump.tsx:459
msgid "Edit title" msgid "Edit title"
msgstr "Modifier le titre" msgstr "Modifier le titre"
@@ -819,9 +866,9 @@ msgstr "modifié"
#. placeholder {0}: relativeTime(dump.updatedAt) #. placeholder {0}: relativeTime(dump.updatedAt)
#. placeholder {0}: relativeTime(message.updatedAt) #. placeholder {0}: relativeTime(message.updatedAt)
#: src/components/chat/ChatMessageItem.tsx:132 #: src/components/chat/ChatMessageItem.tsx:132
#: src/components/comment/CommentNode.tsx:205 #: src/components/comment/CommentNode.tsx:202
#: src/components/playlist/PlaylistHeader.tsx:95 #: src/components/playlist/PlaylistHeader.tsx:158
#: src/pages/Dump.tsx:576 #: src/pages/Dump.tsx:492
msgid "edited {0}" msgid "edited {0}"
msgstr "modifié {0}" msgstr "modifié {0}"
@@ -829,9 +876,9 @@ msgstr "modifié {0}"
#. placeholder {0}: dump.updatedAt.toLocaleString() #. placeholder {0}: dump.updatedAt.toLocaleString()
#. placeholder {0}: message.updatedAt.toLocaleString() #. placeholder {0}: message.updatedAt.toLocaleString()
#: src/components/chat/ChatMessageItem.tsx:128 #: src/components/chat/ChatMessageItem.tsx:128
#: src/components/comment/CommentNode.tsx:203 #: src/components/comment/CommentNode.tsx:200
#: src/components/playlist/PlaylistHeader.tsx:93 #: src/components/playlist/PlaylistHeader.tsx:156
#: src/pages/Dump.tsx:574 #: src/pages/Dump.tsx:490
msgid "Edited {0}" msgid "Edited {0}"
msgstr "Modifié le {0}" msgstr "Modifié le {0}"
@@ -856,7 +903,7 @@ msgstr "Adresse e-mail"
msgid "Enter a query to search." msgid "Enter a query to search."
msgstr "Saisissez une recherche." msgstr "Saisissez une recherche."
#: src/components/player/GlobalPlayer.tsx:254 #: src/components/player/GlobalPlayer.tsx:256
msgid "Expand player" msgid "Expand player"
msgstr "Agrandir le lecteur" msgstr "Agrandir le lecteur"
@@ -890,15 +937,13 @@ msgstr "Publication échouée"
msgid "Failed to post comment" msgid "Failed to post comment"
msgstr "Impossible de publier le commentaire" msgstr "Impossible de publier le commentaire"
#: src/components/comment/CommentNode.tsx:291 #: src/components/comment/CommentNode.tsx:285
msgid "Failed to post reply" msgid "Failed to post reply"
msgstr "Impossible de publier la réponse" msgstr "Impossible de publier la réponse"
#: src/components/federation/GrantList.tsx:98 #: src/components/federation/GrantList.tsx:98
#: src/components/federation/PeerManager.tsx:158 #: src/components/federation/PeerManager.tsx:158
#: src/components/playlist/PlaylistEditForm.tsx:207 #: src/components/playlist/PlaylistEditForm.tsx:207
#: src/components/profile/DescriptionEditor.tsx:99
#: src/components/profile/EmailEditor.tsx:86
msgid "Failed to save" msgid "Failed to save"
msgstr "Enregistrement échoué" msgstr "Enregistrement échoué"
@@ -906,11 +951,7 @@ msgstr "Enregistrement échoué"
msgid "Failed to save category" msgid "Failed to save category"
msgstr "Échec de l’enregistrement de la catégorie" msgstr "Échec de l’enregistrement de la catégorie"
#: src/components/comment/CommentNode.tsx:218 #: src/components/profile/ProfileHeader.tsx:174
msgid "Failed to save edit"
msgstr "Impossible d'enregistrer la modification"
#: src/components/profile/ProfileHeader.tsx:182
msgid "Failed to update avatar" msgid "Failed to update avatar"
msgstr "Impossible de mettre à jour l'avatar" msgstr "Impossible de mettre à jour l'avatar"
@@ -1025,7 +1066,7 @@ msgstr "Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail. V
msgid "image" msgid "image"
msgstr "image" msgstr "image"
#: src/pages/Dump.tsx:643 #: src/pages/Dump.tsx:576
msgid "In collections" msgid "In collections"
msgstr "Dans les collections" msgstr "Dans les collections"
@@ -1056,7 +1097,7 @@ msgstr "Lien invalide"
msgid "Invite link" msgid "Invite link"
msgstr "Lien d'invitation" msgstr "Lien d'invitation"
#: src/components/profile/ProfileHeader.tsx:126 #: src/components/profile/ProfileHeader.tsx:133
msgid "invited by" msgid "invited by"
msgstr "invité par" msgstr "invité par"
@@ -1114,7 +1155,7 @@ msgstr "Lien ou fichier"
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…"
#: src/components/layout/AppHeader.tsx:233 #: src/components/layout/AppHeader.tsx:236
msgid "Live updates unavailable." msgid "Live updates unavailable."
msgstr "Mises à jour en direct indisponibles." msgstr "Mises à jour en direct indisponibles."
@@ -1126,7 +1167,7 @@ msgstr "Charger plus"
msgid "Load older messages" msgid "Load older messages"
msgstr "Charger les messages plus anciens" msgstr "Charger les messages plus anciens"
#: src/pages/Dump.tsx:359 #: src/pages/Dump.tsx:358
#: src/pages/DumpEdit.tsx:210 #: src/pages/DumpEdit.tsx:210
msgid "Loading dump…" msgid "Loading dump…"
msgstr "Chargement de la reco…" msgstr "Chargement de la reco…"
@@ -1139,7 +1180,7 @@ msgstr "Chargement des messages…"
msgid "Loading more…" msgid "Loading more…"
msgstr "Chargement…" msgstr "Chargement…"
#: src/pages/PlaylistDetail.tsx:82 #: src/pages/PlaylistDetail.tsx:83
msgid "Loading playlist…" msgid "Loading playlist…"
msgstr "Chargement de la collection…" msgstr "Chargement de la collection…"
@@ -1159,7 +1200,7 @@ msgstr "Chargement du profil…"
msgid "Loading…" msgid "Loading…"
msgstr "Chargement…" msgstr "Chargement…"
#: src/components/layout/AppHeader.tsx:207 #: src/components/layout/AppHeader.tsx:208
#: src/pages/FederationCallback.tsx:32 #: src/pages/FederationCallback.tsx:32
#: src/pages/UserLogin.tsx:41 #: src/pages/UserLogin.tsx:41
#: src/pages/UserLogin.tsx:66 #: src/pages/UserLogin.tsx:66
@@ -1273,7 +1314,7 @@ msgstr "Nouvelles collections publiques"
msgid "Next →" msgid "Next →"
msgstr "Suivant →" msgstr "Suivant →"
#: src/components/player/GlobalPlayer.tsx:244 #: src/components/player/GlobalPlayer.tsx:246
msgid "Next track" msgid "Next track"
msgstr "Piste suivante" msgstr "Piste suivante"
@@ -1388,11 +1429,11 @@ msgid "email.welcome.button"
msgstr "Ouvrir {site}" msgstr "Ouvrir {site}"
#. placeholder {0}: richContent.siteName #. placeholder {0}: richContent.siteName
#: src/components/dump/RichContentCard.tsx:122 #: src/components/dump/RichContentCard.tsx:124
msgid "Open on {0}" msgid "Open on {0}"
msgstr "Ouvrir sur {0}" msgstr "Ouvrir sur {0}"
#: src/components/dump/RichContentCard.tsx:123 #: src/components/dump/RichContentCard.tsx:125
msgid "Open source" msgid "Open source"
msgstr "Ouvrir la source" msgstr "Ouvrir la source"
@@ -1453,9 +1494,9 @@ msgstr "Les mots de passe ne correspondent pas"
msgid "Paste a link…" msgid "Paste a link…"
msgstr "Collez un lien…" msgstr "Collez un lien…"
#: src/components/dump/FilePreview.tsx:70 #: src/components/dump/FilePreview.tsx:72
#: src/components/dump/FilePreview.tsx:184 #: src/components/dump/FilePreview.tsx:191
#: src/components/dump/RichContentCard.tsx:103 #: src/components/dump/RichContentCard.tsx:105
#: src/components/player/MediaPlayer.tsx:276 #: src/components/player/MediaPlayer.tsx:276
msgid "Pause" msgid "Pause"
msgstr "Pause" msgstr "Pause"
@@ -1464,10 +1505,10 @@ msgstr "Pause"
msgid "Picked up where you left off." msgid "Picked up where you left off."
msgstr "Reprise de votre brouillon." msgstr "Reprise de votre brouillon."
#: src/components/dump/FilePreview.tsx:70 #: src/components/dump/FilePreview.tsx:72
#: src/components/dump/FilePreview.tsx:184 #: src/components/dump/FilePreview.tsx:191
#: src/components/dump/RichContentCard.tsx:103 #: src/components/dump/RichContentCard.tsx:105
#: src/components/dump/RichContentCard.tsx:150 #: src/components/dump/RichContentCard.tsx:153
#: src/components/player/MediaPlayer.tsx:276 #: src/components/player/MediaPlayer.tsx:276
msgid "Play" msgid "Play"
msgstr "Lecture" msgstr "Lecture"
@@ -1477,7 +1518,7 @@ msgid "Playlist categories"
msgstr "Catégories de collections" msgstr "Catégories de collections"
#: src/components/dump/create/StepIndicator.tsx:21 #: src/components/dump/create/StepIndicator.tsx:21
#: src/components/layout/AppHeader.tsx:173 #: src/components/layout/AppHeader.tsx:174
#: src/components/layout/UserMenu.tsx:93 #: src/components/layout/UserMenu.tsx:93
#: src/components/profile/ProfileFollowTab.tsx:147 #: src/components/profile/ProfileFollowTab.tsx:147
#: src/components/profile/ProfilePlaylistsTab.tsx:162 #: src/components/profile/ProfilePlaylistsTab.tsx:162
@@ -1492,26 +1533,26 @@ msgstr "Collections"
msgid "Post comment" msgid "Post comment"
msgstr "Publier le commentaire" msgstr "Publier le commentaire"
#: src/components/comment/CommentNode.tsx:289 #: src/components/comment/CommentNode.tsx:283
msgid "Post reply" msgid "Post reply"
msgstr "Publier la réponse" msgstr "Publier la réponse"
#: src/components/comment/CommentNode.tsx:290 #: src/components/comment/CommentNode.tsx:284
#: src/components/comment/CommentThread.tsx:100 #: src/components/comment/CommentThread.tsx:100
#: src/components/dump/create/DumpCreateModal.tsx:752 #: src/components/dump/create/DumpCreateModal.tsx:752
msgid "Posting…" msgid "Posting…"
msgstr "Publication…" msgstr "Publication…"
#: src/components/player/GlobalPlayer.tsx:228 #: src/components/player/GlobalPlayer.tsx:230
msgid "Previous track" msgid "Previous track"
msgstr "Piste précédente" msgstr "Piste précédente"
#: src/components/dump/DumpCard.tsx:144 #: src/components/dump/DumpCard.tsx:144
#: src/components/dump/JournalCard.tsx:145 #: src/components/dump/JournalCard.tsx:146
#: src/components/playlist/PlaylistCard.tsx:117 #: src/components/playlist/PlaylistCard.tsx:127
#: src/components/playlist/PlaylistHeader.tsx:77 #: src/components/playlist/PlaylistHeader.tsx:140
#: src/components/playlist/PlaylistMembershipPanel.tsx:81 #: src/components/playlist/PlaylistMembershipPanel.tsx:81
#: src/pages/Dump.tsx:582 #: src/pages/Dump.tsx:498
msgid "private" msgid "private"
msgstr "privé" msgstr "privé"
@@ -1519,8 +1560,8 @@ msgstr "privé"
msgid "Private" msgid "Private"
msgstr "Privé" msgstr "Privé"
#: src/components/playlist/PlaylistCard.tsx:116 #: src/components/playlist/PlaylistCard.tsx:126
#: src/components/playlist/PlaylistHeader.tsx:77 #: src/components/playlist/PlaylistHeader.tsx:140
msgid "public" msgid "public"
msgstr "public" msgstr "public"
@@ -1532,7 +1573,7 @@ msgstr "Public"
msgid "Refresh metadata" msgid "Refresh metadata"
msgstr "Actualiser les métadonnées" msgstr "Actualiser les métadonnées"
#: src/components/player/GlobalPlayer.tsx:324 #: src/components/player/GlobalPlayer.tsx:326
msgid "Refreshing stream…" msgid "Refreshing stream…"
msgstr "Actualisation du flux…" msgstr "Actualisation du flux…"
@@ -1554,7 +1595,7 @@ msgstr "Inscription…"
msgid "Registration failed" msgid "Registration failed"
msgstr "Inscription échouée" msgstr "Inscription échouée"
#: src/pages/Dump.tsx:634 #: src/pages/Dump.tsx:567
msgid "Related" msgid "Related"
msgstr "Connexe" msgstr "Connexe"
@@ -1594,7 +1635,7 @@ msgstr "Remplacer le fichier"
#: src/components/chat/ChatMessageItem.tsx:97 #: src/components/chat/ChatMessageItem.tsx:97
#: src/components/chat/ChatMessageItem.tsx:98 #: src/components/chat/ChatMessageItem.tsx:98
#: src/components/comment/CommentNode.tsx:244 #: src/components/comment/CommentNode.tsx:240
msgid "Reply" msgid "Reply"
msgstr "Répondre" msgstr "Répondre"
@@ -1641,7 +1682,7 @@ msgid "email.reset.title"
msgstr "Réinitialiser votre mot de passe" msgstr "Réinitialiser votre mot de passe"
#: src/components/chat/PendingItem.tsx:86 #: src/components/chat/PendingItem.tsx:86
#: src/pages/Dump.tsx:393 #: src/pages/Dump.tsx:392
#: src/pages/DumpEdit.tsx:227 #: src/pages/DumpEdit.tsx:227
msgid "Retry" msgid "Retry"
msgstr "Réessayer" msgstr "Réessayer"
@@ -1651,27 +1692,20 @@ msgid "Role"
msgstr "Rôle" msgstr "Rôle"
#: src/components/category/CategoryManager.tsx:232 #: src/components/category/CategoryManager.tsx:232
#: src/components/chat/ChatEditBox.tsx:85 #: src/components/form/InlineEdit.tsx:310
#: src/components/comment/CommentNode.tsx:216
#: src/components/playlist/PlaylistEditForm.tsx:229 #: src/components/playlist/PlaylistEditForm.tsx:229
#: src/components/profile/DescriptionEditor.tsx:89
#: src/components/profile/EmailEditor.tsx:75
#: src/pages/Dump.tsx:508
#: src/pages/DumpEdit.tsx:564 #: src/pages/DumpEdit.tsx:564
msgid "Save" msgid "Save"
msgstr "Enregistrer" msgstr "Enregistrer"
#: src/components/comment/CommentNode.tsx:217 #: src/components/form/InlineEdit.tsx:307
#: src/components/playlist/PlaylistEditForm.tsx:226 #: src/components/playlist/PlaylistEditForm.tsx:226
#: src/components/profile/ChangePasswordModal.tsx:119 #: src/components/profile/ChangePasswordModal.tsx:119
#: src/components/profile/DescriptionEditor.tsx:86
#: src/components/profile/EmailEditor.tsx:72
#: src/pages/Dump.tsx:507
#: src/pages/ResetPassword.tsx:191 #: src/pages/ResetPassword.tsx:191
msgid "Saving…" msgid "Saving…"
msgstr "Enregistrement…" msgstr "Enregistrement…"
#: src/components/layout/AppHeader.tsx:181 #: src/components/layout/AppHeader.tsx:182
#: src/components/layout/SearchBar.tsx:83 #: src/components/layout/SearchBar.tsx:83
#: src/pages/Search.tsx:106 #: src/pages/Search.tsx:106
msgid "Search" msgid "Search"
@@ -1719,7 +1753,7 @@ msgstr "Envoyer le lien de réinitialisation"
msgid "Sending…" msgid "Sending…"
msgstr "Envoi…" msgstr "Envoi…"
#: src/components/layout/AppHeader.tsx:197 #: src/components/layout/AppHeader.tsx:198
msgid "Server unreachable" msgid "Server unreachable"
msgstr "Serveur inaccessible" msgstr "Serveur inaccessible"
@@ -1776,7 +1810,7 @@ msgstr "Quelqu'un a demandé à réinitialiser le mot de passe de votre compte {
#: 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:738 #: src/contexts/ws/WSProvider.tsx:738
#: src/pages/PlaylistDetail.tsx:155 #: src/pages/PlaylistDetail.tsx:157
msgid "Something went wrong" msgid "Something went wrong"
msgstr "Une erreur est survenue" msgstr "Une erreur est survenue"
@@ -1836,11 +1870,11 @@ msgstr "Le mois sur {site}, {range}"
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."
#: src/components/comment/CommentNode.tsx:282 #: src/components/comment/CommentNode.tsx:276
msgid "This comment couldn't be deleted. Try again." msgid "This comment couldn't be deleted. Try again."
msgstr "Ce commentaire n'a pas pu être supprimé. Réessayez." msgstr "Ce commentaire n'a pas pu être supprimé. Réessayez."
#: src/pages/Dump.tsx:368 #: src/pages/Dump.tsx:367
msgid "This dump was deleted, or made private." msgid "This dump was deleted, or made private."
msgstr "Cette reco a été supprimée, ou rendue privée." msgstr "Cette reco a été supprimée, ou rendue privée."
@@ -1911,7 +1945,7 @@ msgid "digest.topWeek"
msgstr "Les plus validées de la semaine" msgstr "Les plus validées de la semaine"
#. placeholder {0}: i + 1 #. placeholder {0}: i + 1
#: src/components/player/GlobalPlayer.tsx:338 #: src/components/player/GlobalPlayer.tsx:340
msgid "Track {0}" msgid "Track {0}"
msgstr "Piste {0}" msgstr "Piste {0}"
@@ -1985,7 +2019,7 @@ msgstr "Désabonné"
msgid "Updated" msgid "Updated"
msgstr "Mises à jour" msgstr "Mises à jour"
#: src/components/playlist/PlaylistCard.tsx:141 #: src/components/playlist/PlaylistCard.tsx:151
msgid "updated {ago}" msgid "updated {ago}"
msgstr "mise à jour {ago}" msgstr "mise à jour {ago}"
@@ -1994,7 +2028,7 @@ msgstr "mise à jour {ago}"
msgid "email.digest.upload" msgid "email.digest.upload"
msgstr "fichier" msgstr "fichier"
#: src/components/profile/ProfileHeader.tsx:79 #: src/components/profile/ProfileHeader.tsx:80
msgid "Upload failed" msgid "Upload failed"
msgstr "Envoi échoué" msgstr "Envoi échoué"
@@ -2097,12 +2131,14 @@ msgid "email.welcome.subject"
msgstr "Bienvenue sur {site}" msgstr "Bienvenue sur {site}"
#: src/components/dump/create/DumpCreateModal.tsx:705 #: src/components/dump/create/DumpCreateModal.tsx:705
#: src/pages/Dump.tsx:514
#: src/pages/Dump.tsx:521
#: src/pages/DumpEdit.tsx:539 #: src/pages/DumpEdit.tsx:539
msgid "What makes it worth it?" msgid "What makes it worth it?"
msgstr "Pourquoi on en voudrait ?" msgstr "Pourquoi on en voudrait ?"
#: src/components/profile/DescriptionEditor.tsx:78 #: src/components/profile/ProfileDescription.tsx:62
#: src/components/profile/ProfileDescription.tsx:79 #: src/components/profile/ProfileDescription.tsx:72
msgid "Who am I?" msgid "Who am I?"
msgstr "Qui suis-je ?" msgstr "Qui suis-je ?"
@@ -2119,7 +2155,7 @@ msgstr "Pourquoi & où"
msgid "Why?" msgid "Why?"
msgstr "Pourquoi ?" msgstr "Pourquoi ?"
#: src/components/comment/CommentNode.tsx:293 #: src/components/comment/CommentNode.tsx:287
msgid "Write a reply…" msgid "Write a reply…"
msgstr "Écrire une réponse…" msgstr "Écrire une réponse…"

View File

@@ -10,11 +10,15 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/fraunces": "5.3.0", "@fontsource-variable/bricolage-grotesque": "5.3.0",
"@fontsource-variable/jetbrains-mono": "5.3.0", "@fontsource-variable/jetbrains-mono": "5.3.0",
"@fontsource-variable/saira": "5.3.0", "@fontsource-variable/saira": "5.3.0",
"@fontsource-variable/space-grotesk": "5.3.0", "@fontsource-variable/space-grotesk": "5.3.0",
"@fontsource-variable/libre-franklin": "5.3.0",
"@fontsource-variable/newsreader": "5.3.0",
"@fontsource/comic-neue": "5.3.0",
"@fontsource/instrument-serif": "5.3.0", "@fontsource/instrument-serif": "5.3.0",
"@fontsource/manufacturing-consent": "5.3.0",
"@lingui/cli": "6.8.0", "@lingui/cli": "6.8.0",
"@lingui/conf": "6.8.0", "@lingui/conf": "6.8.0",
"@lingui/core": "6.8.0", "@lingui/core": "6.8.0",

View File

@@ -1,14 +1,16 @@
/** /**
* What the whole site did over a period, one of the digests' cadences: its * What the whole site did, told two ways. Over a period, one of the digests'
* most upvoted dumps, who joined, the new public playlists and the totals. * cadences: its most upvoted dumps, who joined, the new public playlists and
* This module types it for the `/community` page, which shows it as it was at * the totals, which the `/community` page shows as they were at the server's
* the server's last refresh; the digests gather the same figures for each * last refresh and the digests gather for each reader's own period. And as it
* reader's own period. * happens: the community feed, the latest things its members did in public,
* which the GeoCities style's marquee scrolls.
* *
* @module * @module
*/ */
import type { Dump } from "./dump.ts"; import type { Dump } from "./dump.ts";
import type { OwnedItem } from "./paths.ts";
import type { Playlist } from "./playlist.ts"; import type { Playlist } from "./playlist.ts";
import type { PublicUser } from "./user.ts"; import type { PublicUser } from "./user.ts";
@@ -30,3 +32,86 @@ export interface CommunityStats {
/** Public dumps posted, upvotes cast and comments written in it. */ /** Public dumps posted, upvotes cast and comments written in it. */
totals: { dumps: number; upvotes: number; comments: number }; totals: { dumps: number; upvotes: number; comments: number };
} }
/**
* Names a dump or a playlist in a {@link CommunityEvent}: its title, and what
* `dumpPath` or `playlistPath` builds its page's path from.
*/
export interface CommunityItem extends OwnedItem {
title: string;
}
/** What every {@link CommunityEvent} carries. */
interface CommunityEventBase {
/**
* The event's kind and what it is about, `dump_upvoted:<dump id>`: unique in
* a feed, where a dump has one event of each kind at most.
*/
id: string;
at: Date;
/** Who did it, by their current name. */
username: string;
}
/** A public dump was posted. A dump made public later keeps its date. */
export interface DumpPostedEvent extends CommunityEventBase {
type: "dump_posted";
dump: CommunityItem;
}
/** A public playlist was created. */
export interface PlaylistCreatedEvent extends CommunityEventBase {
type: "playlist_created";
playlist: CommunityItem;
}
/**
* A public dump got a comment. However many it got, the feed tells of its
* latest alone.
*/
export interface DumpCommentedEvent extends CommunityEventBase {
type: "dump_commented";
dump: CommunityItem;
/** For the `#comment-<id>` anchor on the dump's page. */
commentId: string;
}
/** A public dump got an upvote, told as {@link DumpCommentedEvent} is. */
export interface DumpUpvotedEvent extends CommunityEventBase {
type: "dump_upvoted";
dump: CommunityItem;
}
/** Someone joined the site: `username` is theirs. */
export interface UserJoinedEvent extends CommunityEventBase {
type: "user_joined";
invitedByUsername?: string;
}
/** One line of the community feed, told apart by `type`. */
export type CommunityEvent =
| DumpPostedEvent
| PlaylistCreatedEvent
| DumpCommentedEvent
| DumpUpvotedEvent
| UserJoinedEvent;
/**
* Returned by `GET /api/community/feed` to `CommunityFeedProvider`, which
* asks again as the socket tells of changes, and rendered by the
* `CommunityFeed` component with who is online, which the client knows from
* its socket. `community-service.ts` gathers it. Everything in it is public:
* every reader gets the same feed.
*/
export interface CommunityFeed {
/** The latest events, newest first, 12 at most. */
events: CommunityEvent[];
/**
* The last week's figures, as `/community` shows them: as of the server's
* last refresh.
*/
week: Pick<CommunityStats, "totals" | "chatMessages"> & {
/** The week's most upvoted dump, when one was upvoted. */
top?: CommunityItem;
};
}

View File

@@ -223,67 +223,151 @@
word-break: break-word; word-break: break-word;
} }
.dump-title-view { /* ── Inline edit ── */
/* Text edited where it stands (InlineEdit.tsx). Hovered, the text sits on a
faint plate with a ✎ after it; opened, the field takes the plate's place
and the text's type, so that nothing jumps but the buttons that come under
it. The plate spills past the text, drawn under it by a pseudo-element: the
text's own box keeps its size, which a row measuring it (a playlist's title
beside its buttons) depends on. */
.inline-edit {
/* Tinted from the text, as it sits on a card or on the page alike. */
--inline-edit-plate: color-mix(in srgb, var(--color-text) 7%, transparent);
--inline-edit-pad-y: 0.1rem;
--inline-edit-pad-x: 0.45rem;
min-width: 0;
max-width: 100%;
}
.inline-edit--block {
--inline-edit-pad-y: 0.45rem;
--inline-edit-pad-x: 0.6rem;
}
.inline-edit-view {
position: relative; position: relative;
isolation: isolate;
max-width: 100%;
cursor: text;
}
.inline-edit-view::before {
content: "";
position: absolute;
inset: calc(-1 * var(--inline-edit-pad-y))
calc(-1 * var(--inline-edit-pad-x));
z-index: -1;
border-radius: var(--radius-md);
transition: background-color var(--dur-fast);
}
.inline-edit--line .inline-edit-view {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.4em;
border-radius: var(--radius-md);
max-width: 100%;
/* Padding gives the hover box breathing room (incl. right of the pencil);
the matching negative margin keeps the title's visual position unchanged. */
padding: 0.1rem 0.45rem;
margin: -0.1rem -0.45rem;
} }
.dump-title-view--editable { /* Room on the right for the ✎, which sits in the corner. */
cursor: pointer; .inline-edit--block .inline-edit-view {
display: block;
padding-right: 1.5rem;
} }
.dump-title-view--editable:hover { .inline-edit-view:hover::before {
background: var(--color-surface); background: var(--inline-edit-plate);
} }
.dump-title-edit-btn { .inline-edit-mark {
font-size: var(--text-sm);
line-height: 1;
flex-shrink: 0; flex-shrink: 0;
padding: 0.15em;
border: none;
border-radius: var(--radius-sm);
background: none;
font-family: inherit;
font-size: var(--text-sm);
font-weight: 400;
line-height: 1;
color: var(--color-text-muted); color: var(--color-text-muted);
cursor: pointer;
opacity: 0; opacity: 0;
transition: opacity var(--dur-fast); transition: opacity var(--dur-fast);
pointer-events: none;
} }
.dump-title-view--editable:hover .dump-title-edit-btn, .inline-edit--block .inline-edit-mark {
.dump-title-view--editable:focus-visible .dump-title-edit-btn { position: absolute;
top: 0;
right: 0;
}
.inline-edit-view:hover .inline-edit-mark,
.inline-edit-mark:focus-visible {
opacity: 1; opacity: 1;
} }
.dump-title-editor { /* No pointer to hover with: the mark stays, faintly, to say it can be
tapped. */
@media (hover: none) {
.inline-edit-mark {
opacity: 0.45;
}
}
/* What an empty text says to the one who may fill it. */
.inline-edit-empty {
margin: 0;
color: var(--color-text-muted);
font-style: italic;
}
.inline-edit-form {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
/* Typed as the text it replaces: the type is inherited at no specificity, so
that the class of that text (`fieldClassName`) and a style's rules for it
both set it over this. */
:where(.inline-edit-input) {
font: inherit;
letter-spacing: inherit;
color: inherit;
}
.inline-edit-input {
box-sizing: border-box;
width: calc(100% + 2 * var(--inline-edit-pad-x));
min-width: 0;
padding: calc(var(--inline-edit-pad-y) - var(--border-width))
calc(var(--inline-edit-pad-x) - var(--border-width));
margin: calc(-1 * var(--inline-edit-pad-y))
calc(-1 * var(--inline-edit-pad-x));
border: var(--border-width) solid transparent;
border-radius: var(--radius-md);
background: var(--inline-edit-plate);
transition: border-color var(--dur-base), box-shadow var(--dur-base);
}
/* As the Markdown editor's box takes the focus. */
.inline-edit-input:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--color-accent) 20%, transparent);
}
.inline-edit-actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.4rem;
} }
/* Shared chrome for inline-edit inputs (the editors that live outside `.form`). .inline-edit-error {
Each rule below adds only its own size / weight / width deltas. */ margin: 0;
.dump-title-input, font-size: var(--text-sm);
.profile-email-input, color: var(--color-danger);
.username-field-input {
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface-sunken);
color: var(--color-text);
font-family: inherit;
outline: none;
transition: border-color var(--dur-slow);
}
.dump-title-input:focus,
.profile-email-input:focus,
.username-field-input:focus {
border-color: var(--color-accent);
} }
/* A name and its "Rename" button, the hint and errors below. */ /* A name and its "Rename" button, the hint and errors below. */
@@ -304,28 +388,18 @@
flex: 1 1 10rem; flex: 1 1 10rem;
min-width: 0; min-width: 0;
padding: 0.4rem 0.6rem; padding: 0.4rem 0.6rem;
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface-sunken);
color: var(--color-text);
font-family: inherit;
font-size: var(--text-base); font-size: var(--text-base);
outline: none;
transition: border-color var(--dur-slow);
} }
.dump-title-input { .username-field-input:focus {
flex: 1; border-color: var(--color-accent);
min-width: 12rem;
padding: 0.4rem 0.6rem;
font-size: var(--text-xl);
font-weight: 700;
}
/* Save and Cancel wrap together, under the input on a phone. */
.dump-title-actions {
display: flex;
gap: 0.5rem;
}
.dump-title-error {
width: 100%;
margin: 0;
font-size: var(--text-sm);
color: var(--color-danger);
} }
/* On the narrowest viewports the fixed-width action column squeezes the title /* On the narrowest viewports the fixed-width action column squeezes the title
@@ -2357,23 +2431,9 @@ body.has-player .chat-fab {
/* No margins of its own: the page's gap already sets it apart. */ /* No margins of its own: the page's gap already sets it apart. */
.profile-description { .profile-description {
width: 100%; width: 100%;
}
.profile-description-view {
position: relative;
padding: 0.5rem 0.6rem;
border-radius: var(--radius-md);
font-size: var(--text-md); font-size: var(--text-md);
} }
.profile-description-view--editable {
cursor: pointer;
}
.profile-description-view--editable:hover {
background: var(--color-surface);
}
.profile-description-text { .profile-description-text {
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: break-word; overflow-wrap: break-word;
@@ -2381,80 +2441,16 @@ body.has-player .chat-fab {
line-height: 1.6; line-height: 1.6;
} }
.profile-description-empty { .profile-email {
color: var(--color-muted); margin: 0.1rem 0 0.4rem;
font-style: italic;
}
.profile-description-edit-btn {
position: absolute;
top: 0.55rem;
right: 0.5rem;
font-size: var(--text-sm);
color: var(--color-muted);
opacity: 0;
transition: opacity var(--dur-fast);
pointer-events: none;
}
.profile-description-view--editable:hover .profile-description-edit-btn,
.profile-description-view--editable:focus-visible .profile-description-edit-btn {
opacity: 1;
}
.profile-description-editor {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.profile-email-display {
display: inline-flex;
max-width: 100%;
align-items: center;
gap: 0.35rem;
font-size: var(--text-xs); font-size: var(--text-xs);
color: var(--color-text-muted); color: var(--color-text-muted);
margin: 0.1rem 0 0.4rem;
cursor: pointer;
border-radius: var(--radius-sm);
padding: 0.1rem 0.2rem;
margin-left: -0.2rem;
} }
.profile-email-display:hover { /* An address is typed a size up from the line it shows on. */
background: var(--color-surface); .profile-email .inline-edit-input {
} max-width: 22rem;
.profile-email-display:hover .profile-description-edit-btn,
.profile-email-display:focus-visible .profile-description-edit-btn {
opacity: 1;
}
.profile-email-editor {
margin: 0.2rem 0 0.4rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.profile-email-input {
width: min(260px, 100%);
padding: 0.35rem 0.6rem;
border-radius: var(--radius-md);
font-size: var(--text-sm); font-size: var(--text-sm);
line-height: 1.5;
}
.profile-email-actions {
display: flex;
gap: 0.4rem;
}
.profile-description-actions {
display: flex;
align-items: center;
gap: 0.5rem;
} }
.profile-invited-by { .profile-invited-by {
@@ -2731,7 +2727,11 @@ body.has-player .chat-fab {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
overflow-wrap: anywhere; /* A word stays whole unless it alone is wider than the line. Breaking
anywhere lets the text be squeezed under the width of a word, and Firefox
sizes a title set with negative letter-spacing a hair narrower than it
lays it out, which dropped the last letter to a line of its own. */
overflow-wrap: break-word;
} }
.page-title-icon { .page-title-icon {
@@ -2769,6 +2769,12 @@ body.has-player .chat-fab {
padding: 1rem; padding: 1rem;
} }
/* A card that holds one line of text, such as a list with nothing in it yet:
the card's padding spaces it, not the paragraph's margins as well. */
.page-card > p:only-child {
margin: 0;
}
/* A list of dump or playlist cards in a group fills the column, its edges in /* A list of dump or playlist cards in a group fills the column, its edges in
line with the label's rule. */ line with the label's rule. */
.page-group .dump-feed { .page-group .dump-feed {
@@ -2855,6 +2861,13 @@ body.has-player .chat-fab {
flex-shrink: 0; flex-shrink: 0;
color: var(--color-text); color: var(--color-text);
text-decoration: none; text-decoration: none;
/* It answers the pointer as a card's title does (.dump-card-title): the
accent, eased in. A theme that colors the name restates the hover. */
transition: color var(--dur-base);
}
.app-header-brand:hover {
color: var(--color-accent);
} }
.app-header-center { .app-header-center {
@@ -2929,7 +2942,7 @@ body.has-player .chat-fab {
/* When the search bar is expanded, immediately clear the rest of the center — /* When the search bar is expanded, immediately clear the rest of the center —
the expanded input needs the full column width */ the expanded input needs the full column width */
.app-header-center:has(.search-bar--expanded) .index-presence, .app-header-center:has(.search-bar--expanded) .index-presence,
.app-header-center:has(.search-bar--expanded) .feed-sort { .app-header-center:has(.search-bar--expanded) .feed-sort-scroller {
display: none; display: none;
} }
@@ -2942,10 +2955,6 @@ body.has-player .chat-fab {
min-width: 0; min-width: 0;
} }
.app-header-brand:hover {
color: var(--color-accent);
}
.app-header-nav { .app-header-nav {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -3792,90 +3801,54 @@ body.has-player .chat-fab {
/* ── Feed sort scroller + buttons ── */ /* ── Feed sort scroller + buttons ── */
/* Wrapper: constrains width, clips scroll overflow, and carries the gradient /* Wrapper: the leading content (the scope switcher), then the row of tabs,
hint. --tab-row-bg is what the row sits on, which the fade and the pinned which scrolls on its own beside it. Nothing here paints the ground the row
leading content paint so the tabs vanish under them. */ sits on: a theme's page may be a gradient or a picture, which a patch of
flat color would show against. */
.feed-sort-scroller { .feed-sort-scroller {
position: relative; display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0; min-width: 0;
overflow: hidden;
--tab-row-bg: var(--color-bg);
} }
.app-header .feed-sort-scroller {
--tab-row-bg: var(--header-bg);
}
/* Right-edge fade, over the scroll container whatever its scroll position.
It shows only while tabs are hidden past that edge (TabBar's
--more), so it never dims the last tab once it is scrolled to or fits. */
.feed-sort-scroller::after {
content: "";
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 2rem;
background: linear-gradient(to right, transparent, var(--tab-row-bg));
pointer-events: none;
opacity: 0;
transition: opacity var(--dur-base);
}
.feed-sort-scroller--more::after {
opacity: 1;
}
/* Its twin on the left edge once the row has scrolled, unless leading content
is pinned there, which draws its own (.feed-sort-leading::after). */
.feed-sort-scroller::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
z-index: 1;
width: 2rem;
background: linear-gradient(to left, transparent, var(--tab-row-bg));
pointer-events: none;
opacity: 0;
transition: opacity var(--dur-base);
}
.feed-sort-scroller--more-left:not(:has(.feed-sort-leading))::before {
opacity: 1;
}
/* The leading content (the scope switcher) stays at the row's start while the
tabs scroll under it. Its padding hides them right after its divider, and
the matching negative margin keeps the gap to the first tab unchanged. */
.feed-sort-leading { .feed-sort-leading {
position: sticky;
left: 0;
z-index: 1;
display: flex; display: flex;
flex-shrink: 0; flex-shrink: 0;
padding-right: 0.4rem;
margin-right: -0.4rem;
background: var(--tab-row-bg);
} }
/* Once tabs have scrolled under it, a short fade softens their cut edge. */ /* The row fades out at an edge while tabs are hidden past it (TabBar's
.feed-sort-leading::after { --more and --more-left), so a fade never dims a tab that is scrolled to or
content: ""; fits. The fade is a mask, the tabs thinning out over whatever is behind
position: absolute; them. The two lengths are registered so that they can ease in. */
top: 0; @property --tab-fade-start {
bottom: 0; syntax: "<length>";
left: 100%; inherits: true;
width: 0.75rem; initial-value: 0px;
background: linear-gradient(to left, transparent, var(--tab-row-bg));
pointer-events: none;
opacity: 0;
transition: opacity var(--dur-base);
} }
.feed-sort-scroller--more-left .feed-sort-leading::after { @property --tab-fade-end {
opacity: 1; syntax: "<length>";
inherits: true;
initial-value: 0px;
}
.feed-sort-scroller {
transition: --tab-fade-start var(--dur-base), --tab-fade-end var(--dur-base);
}
.feed-sort-scroller--more {
--tab-fade-end: 2rem;
}
.feed-sort-scroller--more-left {
--tab-fade-start: 2rem;
}
/* Beside leading content the tabs are cut at its divider, and a shorter fade
softens the cut. */
.feed-sort-scroller--more-left:has(.feed-sort-leading) {
--tab-fade-start: 0.75rem;
} }
/* In the index below-header the scroller should fill the available row width */ /* In the index below-header the scroller should fill the available row width */
@@ -3889,6 +3862,13 @@ body.has-player .chat-fab {
gap: 0.4rem; gap: 0.4rem;
overflow-x: auto; overflow-x: auto;
scrollbar-width: none; scrollbar-width: none;
mask-image: linear-gradient(
to right,
transparent,
#000 var(--tab-fade-start),
#000 calc(100% - var(--tab-fade-end)),
transparent
);
/* Symmetric, so the tabs stay centred against their row (the header's /* Symmetric, so the tabs stay centred against their row (the header's
brand and buttons), with room on both sides for the focus ring that brand and buttons), with room on both sides for the focus ring that
overflow-x would otherwise clip. */ overflow-x would otherwise clip. */
@@ -5129,19 +5109,10 @@ body.has-player .chat-fab {
color: var(--color-danger); color: var(--color-danger);
} }
.chat-edit { /* The input rests on one line, as tall as the Send button beside it: both
display: flex; take --chat-composer-height, which any style's line of text fits in. */
flex-direction: column;
gap: 0.4rem;
}
.chat-edit-actions {
display: flex;
justify-content: flex-end;
gap: 0.4rem;
}
.chat-composer { .chat-composer {
--chat-composer-height: 2.75rem;
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
align-items: flex-end; align-items: flex-end;
@@ -5170,6 +5141,10 @@ body.has-player .chat-fab {
above the input, in the gap over the composer: inside the textarea, the text above the input, in the gap over the composer: inside the textarea, the text
ran under them, and in a row below they would knock the Send button out of ran under them, and in a row below they would knock the Send button out of
alignment. */ alignment. */
.chat-composer .chat-input {
min-height: var(--chat-composer-height);
}
.chat-composer .text-editor-footer { .chat-composer .text-editor-footer {
position: absolute; position: absolute;
left: 0.25rem; left: 0.25rem;
@@ -5184,9 +5159,9 @@ body.has-player .chat-fab {
.chat-send-btn { .chat-send-btn {
flex-shrink: 0; flex-shrink: 0;
/* Match the input's resting height so the two line up exactly (both edges), /* The input's resting height, so that the two line up at both edges, while
while the input is free to grow taller as you type. */ the input is free to grow taller as you type. */
height: 2.4rem; height: var(--chat-composer-height);
box-sizing: border-box; box-sizing: border-box;
} }
@@ -5534,6 +5509,23 @@ body.has-player .chat-fab {
flex-wrap: wrap; flex-wrap: wrap;
} }
/* The open title field takes the room its buttons leave beside it, which
line up with its top rather than with its Save and Cancel. */
.playlist-detail-title-row:has(> .inline-edit--editing) {
align-items: flex-start;
}
.playlist-detail-title-row > .inline-edit--editing {
flex: 1 1 16rem;
/* Clear of the buttons by the gap, past the field's spill. */
margin-right: var(--inline-edit-pad-x);
}
/* The title on the left, Follow and Edit together at the row's end. */
.playlist-detail-title-row > :first-child:not(.inline-edit--editing) {
margin-right: auto;
}
.playlist-detail-title { .playlist-detail-title {
margin: 0; margin: 0;
font-size: var(--text-2xl); font-size: var(--text-2xl);
@@ -7543,7 +7535,7 @@ body.has-player .chat-fab {
} }
/* Vertical rule separating the scope selector from the sort tabs. */ /* Vertical rule separating the scope selector from the sort tabs. */
.feed-sort .category-switcher::after { .feed-sort-leading .category-switcher::after {
content: ""; content: "";
width: 2px; width: 2px;
height: 1.05rem; height: 1.05rem;
@@ -7636,7 +7628,7 @@ body.has-player .chat-fab {
.category-link { .category-link {
padding: 0.1rem 0.5rem; padding: 0.1rem 0.5rem;
border-radius: var(--radius-full); border-radius: var(--radius-full);
background: var(--color-header-user-bg); background: var(--color-badge-bg);
color: var(--color-text-secondary); color: var(--color-text-secondary);
text-decoration: none; text-decoration: none;
font-size: var(--text-xs); font-size: var(--text-xs);
@@ -7849,3 +7841,12 @@ body.has-player .chat-fab {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
} }
/* ── Community feed ── */
/* A bare list: the style that shows the feed lays it out, as GeoCities does
along its marquee. */
.community-feed {
list-style: none;
margin: 0;
padding: 0;
}

View File

@@ -62,6 +62,7 @@ import { AuthProvider } from "./contexts/auth/AuthProvider.tsx";
import { PlayerProvider } from "./contexts/player/PlayerProvider.tsx"; import { PlayerProvider } from "./contexts/player/PlayerProvider.tsx";
import { WSProvider } from "./contexts/ws/WSProvider.tsx"; import { WSProvider } from "./contexts/ws/WSProvider.tsx";
import { FollowProvider } from "./contexts/social/FollowProvider.tsx"; import { FollowProvider } from "./contexts/social/FollowProvider.tsx";
import { CommunityFeedProvider } from "./contexts/social/CommunityFeedProvider.tsx";
import { CategoriesProvider } from "./contexts/category/CategoriesProvider.tsx"; import { CategoriesProvider } from "./contexts/category/CategoriesProvider.tsx";
import { ThemeProvider } from "./contexts/theme/ThemeProvider.tsx"; import { ThemeProvider } from "./contexts/theme/ThemeProvider.tsx";
import { ChatProvider } from "./contexts/chat/ChatProvider.tsx"; import { ChatProvider } from "./contexts/chat/ChatProvider.tsx";
@@ -203,15 +204,22 @@ function HandleRoutes() {
: <Feed categorySlug={handle} />; : <Feed categorySlug={handle} />;
} }
/** Renders the route table, and the floating buttons over every page. */ /**
function AppRoutes() { * Renders the route table, and the floating buttons over every page. Exported
* for the tests, which reach a page through it as a visit does.
*/
export function AppRoutes(): React.JSX.Element {
return ( return (
<> <>
<Suspense> <Suspense>
<Routes> <Routes>
<Route path="/" element={<Feed />} /> <Route path="/" element={<Feed />} />
{
/* The splat takes the `follow` tab's view, which `Feed` reads from
the path; without it `/follow/playlists` is category `follow`. */
}
{FEED_TABS.map((tab) => ( {FEED_TABS.map((tab) => (
<Route key={tab} path={`/${tab}`} element={<Feed />} /> <Route key={tab} path={`/${tab}/*`} element={<Feed />} />
))} ))}
<Route path="/search" element={<Search />} /> <Route path="/search" element={<Search />} />
<Route path="/search/:searchTab" element={<Search />} /> <Route path="/search/:searchTab" element={<Search />} />
@@ -298,6 +306,7 @@ export function App(): React.JSX.Element {
<PlayerProvider> <PlayerProvider>
<WSProvider> <WSProvider>
<FollowProvider> <FollowProvider>
<CommunityFeedProvider>
<CategoriesProvider> <CategoriesProvider>
<BrowserRouter> <BrowserRouter>
<ChatProvider> <ChatProvider>
@@ -306,6 +315,7 @@ export function App(): React.JSX.Element {
</ChatProvider> </ChatProvider>
</BrowserRouter> </BrowserRouter>
</CategoriesProvider> </CategoriesProvider>
</CommunityFeedProvider>
</FollowProvider> </FollowProvider>
</WSProvider> </WSProvider>
</PlayerProvider> </PlayerProvider>

View File

@@ -5,39 +5,33 @@
* @module * @module
*/ */
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef } from "react";
import { Trans } from "@lingui/react/macro"; import { InlineEditField } from "../form/InlineEdit.tsx";
import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx"; import { COUNT_FROM } from "../../utils/chat/message.ts";
import { COUNT_FROM, sendable } from "../../utils/chat/message.ts";
import { VALIDATION } from "../../../shared/validation.ts"; import { VALIDATION } from "../../../shared/validation.ts";
/** Passed to {@link ChatEditBox} by `ChatMessageItem` while it is editing. */ /** Passed to {@link ChatEditBox} by `ChatMessageItem` while it is editing. */
export interface ChatEditBoxProps { export interface ChatEditBoxProps {
initial: string; initial: string;
/** /** Receives the trimmed text, only when it changed and can be sent. */
* Receives the trimmed text, only when it can be sent. It may be unchanged:
* the caller decides whether to send an edit.
*/
onSave: (body: string) => void; onSave: (body: string) => void;
/** Also called on Escape. */ /** Called once the text is saved, or left, Escape included. */
onCancel: () => void; onClose: () => void;
} }
/** /**
* Renders an editor holding `initial`, with Save and Cancel. Enter saves and * Renders the field every text edited in place gets, `InlineEditField`, with
* Shift+Enter starts a new line, as in the composer. * Enter saving and Shift+Enter starting a new line, as in the composer. It
* scrolls itself into the list's view.
*/ */
export function ChatEditBox( export function ChatEditBox(
{ initial, onSave, onCancel }: ChatEditBoxProps, { initial, onSave, onClose }: ChatEditBoxProps,
): React.JSX.Element { ): React.JSX.Element {
const [draft, setDraft] = useState(initial);
const editorRef = useRef<TextEditorHandle>(null);
const boxRef = useRef<HTMLDivElement>(null); const boxRef = useRef<HTMLDivElement>(null);
// The message is still easing to its new height, so a scroll on focus would // The message is still easing to its new height, so a scroll now would land
// land short. Scroll once the transition is over, Save button included. // short. Scroll once the transition is over, Save button included.
useEffect(() => { useEffect(() => {
editorRef.current?.focus({ atEnd: true, preventScroll: true });
const box = boxRef.current; const box = boxRef.current;
if (!box) return; if (!box) return;
const settle = parseFloat( const settle = parseFloat(
@@ -50,48 +44,19 @@ export function ChatEditBox(
return () => clearTimeout(id); return () => clearTimeout(id);
}, []); }, []);
const save = () => {
if (!sendable(draft)) return;
onSave(draft.trim());
};
return ( return (
<div className="chat-edit animate-in" ref={boxRef}> <div className="chat-edit" ref={boxRef}>
<TextEditor <InlineEditField
ref={editorRef} value={initial}
value={draft} onSave={onSave}
onChange={setDraft} onClose={onClose}
rows={2} kind="markdown"
autoResize required
maxLength={VALIDATION.CHAT_MESSAGE_MAX} maxLength={VALIDATION.CHAT_MESSAGE_MAX}
countFrom={COUNT_FROM} countFrom={COUNT_FROM}
className="comment-reply-textarea chat-input" fieldClassName="chat-input"
onSubmit={save} enterSaves
onCancel={onCancel}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
save();
}
}}
/> />
<div className="chat-edit-actions">
<button
type="button"
className="btn-primary btn-sm"
onClick={save}
disabled={!sendable(draft)}
>
<Trans>Save</Trans>
</button>
<button
type="button"
className="btn-secondary btn-sm"
onClick={onCancel}
>
<Trans>Cancel</Trans>
</button>
</div>
</div> </div>
); );
} }

View File

@@ -194,11 +194,8 @@ export function ChatMessageItem(
? ( ? (
<ChatEditBox <ChatEditBox
initial={message.body} initial={message.body}
onSave={(body) => { onSave={(body) => onEdit(message.id, body)}
if (body !== message.body) onEdit(message.id, body); onClose={onEndEdit}
onEndEdit();
}}
onCancel={onEndEdit}
/> />
) )
: ( : (

View File

@@ -850,7 +850,7 @@ export function ChatModal(
value={draft} value={draft}
onChange={handleDraftChange} onChange={handleDraftChange}
placeholder={t`Type a message…`} placeholder={t`Type a message…`}
rows={2} rows={1}
autoResize autoResize
maxLength={VALIDATION.CHAT_MESSAGE_MAX} maxLength={VALIDATION.CHAT_MESSAGE_MAX}
countFrom={COUNT_FROM} countFrom={COUNT_FROM}

View File

@@ -1,6 +1,6 @@
/** /**
* Renders the form of a dump's comment thread that posts a comment, a reply, * Renders the form of a dump's comment thread that posts a comment or a
* or an edit. * reply. An edit opens `InlineEditField` in the comment's place.
* *
* @module * @module
*/ */
@@ -15,7 +15,7 @@ import { useApiForm } from "../../hooks/form/useApiForm.ts";
/** /**
* Passed to {@link CommentForm} by `CommentThread`, for a new comment, and by * Passed to {@link CommentForm} by `CommentThread`, for a new comment, and by
* `CommentNode`, for a reply or an edit. * `CommentNode`, for a reply.
*/ */
export interface CommentFormProps { export interface CommentFormProps {
/** /**
@@ -28,7 +28,6 @@ export interface CommentFormProps {
pendingLabel: React.ReactNode; pendingLabel: React.ReactNode;
errorTitle: string; errorTitle: string;
placeholder?: string; placeholder?: string;
defaultValue?: string;
/** Without it, the form has no Cancel button. */ /** Without it, the form has no Cancel button. */
onCancel?: () => void; onCancel?: () => void;
/** /**
@@ -39,7 +38,7 @@ export interface CommentFormProps {
editorRef?: React.Ref<TextEditorHandle>; editorRef?: React.Ref<TextEditorHandle>;
/** /**
* `top` puts `leading` beside the editor, for the new-comment form. `inline` * `top` puts `leading` beside the editor, for the new-comment form. `inline`
* is for replies and edits, inside a comment. * is for replies, inside a comment.
*/ */
layout?: "inline" | "top"; layout?: "inline" | "top";
leading?: React.ReactNode; leading?: React.ReactNode;
@@ -57,7 +56,6 @@ export function CommentForm({
pendingLabel, pendingLabel,
errorTitle, errorTitle,
placeholder, placeholder,
defaultValue = "",
onCancel, onCancel,
cancelWhenEmpty, cancelWhenEmpty,
editorRef, editorRef,
@@ -66,7 +64,7 @@ export function CommentForm({
className, className,
}: CommentFormProps): React.JSX.Element { }: CommentFormProps): React.JSX.Element {
const form = useApiForm<{ body: string }>({ const form = useApiForm<{ body: string }>({
defaultValues: { body: defaultValue }, defaultValues: { body: "" },
}); });
const body = form.watch("body"); const body = form.watch("body");
const disabled = !body.trim() || body.length > VALIDATION.COMMENT_BODY_MAX; const disabled = !body.trim() || body.length > VALIDATION.COMMENT_BODY_MAX;
@@ -91,8 +89,8 @@ export function CommentForm({
value={field.value} value={field.value}
onChange={field.onChange} onChange={field.onChange}
onSubmit={() => void submit()} onSubmit={() => void submit()}
// Escape closes a reply or an edit. The new-comment form has // Escape closes a reply. The new-comment form has nothing to
// nothing to close. // close.
onCancel={onCancel && !cancelWhenEmpty onCancel={onCancel && !cancelWhenEmpty
? () => { ? () => {
form.reset({ body: "" }); form.reset({ body: "" });

View File

@@ -30,6 +30,8 @@ import { useWS } from "../../hooks/ws/useWS.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts";
import { expectOk } from "../../utils/shared/api.ts"; import { expectOk } from "../../utils/shared/api.ts";
import { CommentForm } from "./CommentForm.tsx"; import { CommentForm } from "./CommentForm.tsx";
import { InlineEditField } from "../form/InlineEdit.tsx";
import { VALIDATION } from "../../../shared/validation.ts";
import { userPath } from "../../../shared/paths.ts"; import { userPath } from "../../../shared/paths.ts";
/** /**
@@ -75,7 +77,7 @@ export function CommentNode({
); );
const replyEditorRef = useRef<TextEditorHandle>(null); const replyEditorRef = useRef<TextEditorHandle>(null);
const editEditorRef = useRef<TextEditorHandle>(null); const editButtonRef = useRef<HTMLButtonElement>(null);
const { likeCounts, myLikes, castCommentLike, removeCommentLike } = useWS(); const { likeCounts, myLikes, castCommentLike, removeCommentLike } = useWS();
const { authFetch } = useAuth(); const { authFetch } = useAuth();
@@ -105,18 +107,13 @@ export function CommentNode({
} }
} }
// Not called with the text unchanged, which would only mark it "edited".
async function handleEditSave(body: string) { async function handleEditSave(body: string) {
// Saving it unchanged would only mark it "edited".
if (body === comment.body) {
setEditOpen(false);
return;
}
const res = await authFetch(`${API_URL}/api/comments/${comment.id}`, { const res = await authFetch(`${API_URL}/api/comments/${comment.id}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ body } satisfies UpdateCommentRequest), body: JSON.stringify({ body } satisfies UpdateCommentRequest),
}); });
onCommentUpdated(deserializeComment(await expectOk<Wire<Comment>>(res))); onCommentUpdated(deserializeComment(await expectOk<Wire<Comment>>(res)));
setEditOpen(false);
} }
const canModerate = can(currentUser, "comment:moderate"); const canModerate = can(currentUser, "comment:moderate");
@@ -209,15 +206,14 @@ export function CommentNode({
</div> </div>
{editOpen {editOpen
? ( ? (
<CommentForm <InlineEditField
editorRef={editEditorRef} value={comment.body}
defaultValue={comment.body} onSave={handleEditSave}
onSubmit={handleEditSave} onClose={() => setEditOpen(false)}
submitLabel={<Trans>Save</Trans>} returnFocusTo={editButtonRef}
pendingLabel={<Trans>Saving…</Trans>} kind="markdown"
errorTitle={t`Failed to save edit`} required
className="animate-in" maxLength={VALIDATION.COMMENT_BODY_MAX}
onCancel={() => setEditOpen(false)}
/> />
) )
: <Markdown className="comment-body">{comment.body}</Markdown>} : <Markdown className="comment-body">{comment.body}</Markdown>}
@@ -246,12 +242,10 @@ export function CommentNode({
)} )}
{canEdit && !editOpen && ( {canEdit && !editOpen && (
<button <button
ref={editButtonRef}
type="button" type="button"
className="comment-action-btn" className="comment-action-btn"
onClick={() => { onClick={() => setEditOpen(true)}
setEditOpen(true);
setTimeout(() => editEditorRef.current?.focus(), 0);
}}
> >
<Trans>Edit</Trans> <Trans>Edit</Trans>
</button> </button>

View File

@@ -0,0 +1,340 @@
/**
* Renders the editing of a text in place, with no page or form of its own.
* {@link InlineEdit} shows the text and opens the field over it: a dump's
* title and note, a playlist's title and description, a profile's description
* and email. {@link InlineEditField} is that field alone, for a text that
* opens it with an action of its own: a comment's Edit, a chat message's ✎.
*
* @module
*/
import { type ReactNode, useEffect, useRef, useState } from "react";
import { Trans } from "@lingui/react/macro";
import { Controller, FormProvider, useFormState } from "react-hook-form";
import { useApiForm } from "../../hooks/form/useApiForm.ts";
import { useHeightTransition } from "../../hooks/shared/useHeightTransition.ts";
import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx";
import { SubmitButton } from "./SubmitButton.tsx";
/**
* Passed to {@link InlineEdit} by the component showing the text, which
* renders it as `children` and saves it in `onSave`.
*/
export interface InlineEditProps {
/** The text as saved, which the field opens on. */
value: string;
/** Without it, `children` show alone. */
editable: boolean;
/** Names the action, such as "Edit title": the text's tooltip. */
label: string;
/**
* Saves the trimmed text, and shows it: the field closes once it resolves.
* A thrown error, such as `expectOk`'s, stays open under the field. A text
* left as it was closes the field without calling it.
*/
onSave: (value: string) => Promise<void>;
/**
* A one-line field (`text`, `email`), where Enter saves, or the Markdown
* editor (`markdown`), where Ctrl/Cmd+Enter does. Escape cancels in both.
*/
kind?: "text" | "email" | "markdown";
/** Refuses to save an empty text. */
required?: boolean;
maxLength?: number;
placeholder?: string;
/** Added to the wrapper, which the text and the field both sit in. */
className?: string;
/** Added to the field: see {@link InlineEditFieldProps}. */
fieldClassName?: string;
children: ReactNode;
}
/**
* Renders `children`, which a click on them, or the ✎ button after them,
* swaps for a field holding `value`, with Save and Cancel under it. The ✎
* shows on hover and on keyboard focus. A click on a link in the text, or one
* that ends a selection, follows the link or keeps the selection instead.
* Closing the field gives the focus back to the ✎ when the field had it.
*/
export function InlineEdit(
{
value,
editable,
label,
onSave,
kind = "text",
required = false,
maxLength,
placeholder,
className,
fieldClassName,
children,
}: InlineEditProps,
): React.JSX.Element {
const [editing, setEditing] = useState(false);
const rootRef = useHeightTransition<HTMLDivElement>(editing);
const markRef = useRef<HTMLButtonElement>(null);
if (!editable) return <>{children}</>;
return (
<div
ref={rootRef}
className={`inline-edit inline-edit--${
kind === "markdown" ? "block" : "line"
}${editing ? " inline-edit--editing" : ""}${
className ? ` ${className}` : ""
}`}
>
{editing
? (
<InlineEditField
value={value}
onSave={onSave}
onClose={() => setEditing(false)}
returnFocusTo={markRef}
kind={kind}
required={required}
maxLength={maxLength}
placeholder={placeholder}
fieldClassName={fieldClassName}
label={label}
/>
)
: (
<div
className="inline-edit-view"
title={label}
// The text opens the field to the pointer; the keyboard has the ✎.
// Neither is a button around the text, which keeps its own role:
// a heading, Markdown with its links.
onClick={(e) => {
if ((e.target as Element).closest("a")) return;
if (getSelection()?.type === "Range") return;
setEditing(true);
}}
>
{children}
<button
ref={markRef}
type="button"
className="inline-edit-mark"
aria-label={label}
// Opens the field whatever is selected on the page.
onClick={(e) => {
e.stopPropagation();
setEditing(true);
}}
>
✎
</button>
</div>
)}
</div>
);
}
/**
* Passed to {@link InlineEditField} by {@link InlineEdit}, and by the
* components that open it with an action of their own, `CommentNode` and
* `ChatMessageItem`, in place of the text while it is edited.
*/
export interface InlineEditFieldProps extends
Pick<
InlineEditProps,
"value" | "kind" | "required" | "maxLength" | "placeholder"
> {
/**
* Saves the trimmed text. The field closes once it is done, and stays open
* under the error it throws. A text left as it was isn't passed to it.
*/
onSave: (value: string) => Promise<void> | void;
/** Called once the text is saved, or left: the field is to unmount. */
onClose: () => void;
/** Names a one-line field, such as "Edit title". */
label?: string;
/**
* Added to the field: for a one-line one, the class of the text it edits,
* such as `dump-title`, so that it takes on its type in every style.
*/
fieldClassName?: string;
/**
* In the Markdown editor, saves on Enter, a line breaking on Shift+Enter, as
* the chat's composer does. Ctrl/Cmd+Enter saves in either case.
*/
enterSaves?: boolean;
/** Where the Markdown editor's counter starts, near `maxLength`. */
countFrom?: number;
/**
* Takes the focus once the field is gone, if it had it and no one has
* taken it since: the button that opened it, back in its place.
*/
returnFocusTo?: React.RefObject<HTMLElement | null>;
}
/**
* Renders the field holding `value`, focused with the caret at its end, and
* Save and Cancel under it. Enter saves a line, Ctrl/Cmd+Enter a Markdown
* text, Escape leaves. Saving is refused while the text is empty and
* `required`, or longer than `maxLength`. It is mounted afresh on each
* opening, so that it starts from `value`.
*/
export function InlineEditField(
{
value,
onSave,
onClose,
kind = "text",
required,
maxLength,
placeholder,
fieldClassName,
label,
enterSaves,
countFrom,
returnFocusTo,
}: InlineEditFieldProps,
): React.JSX.Element {
const form = useApiForm<{ text: string }>({
defaultValues: { text: value },
});
const text = form.watch("text");
const submitting = form.formState.isSubmitting;
const editorRef = useRef<TextEditorHandle>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// Runs once the field is gone and the button that opened it is back. The
// focus fell to the page with the field that had it, unless the caller has
// put it somewhere already.
useEffect(() => () => {
const active = document.activeElement;
if (!active || active === document.body) {
returnFocusTo?.current?.focus({ preventScroll: true });
}
}, [returnFocusTo]);
// The caret goes after the text, where an edit most often begins.
useEffect(() => {
editorRef.current?.focus({ atEnd: true, preventScroll: true });
const input = inputRef.current;
if (!input) return;
input.focus({ preventScroll: true });
// An email field has no selection to set.
if (input.type === "text") {
input.setSelectionRange(input.value.length, input.value.length);
}
}, []);
const tooLong = maxLength !== undefined && text.length > maxLength;
const blank = required && !text.trim();
const onSubmit = form.submit(async ({ text }) => {
const trimmed = text.trim();
if (trimmed !== value.trim()) await onSave(trimmed);
onClose();
});
const save = () => {
if (!tooLong && !blank) void onSubmit();
};
const cancel = () => {
if (!submitting) onClose();
};
const { ref: registerRef, ...registration } = form.register("text");
return (
<FormProvider {...form}>
<form className="inline-edit-form animate-in" onSubmit={onSubmit}>
{kind === "markdown"
? (
<Controller
name="text"
control={form.control}
render={({ field }) => (
<TextEditor
ref={editorRef}
className={`comment-reply-textarea${
fieldClassName ? ` ${fieldClassName}` : ""
}`}
value={field.value}
onChange={field.onChange}
onSubmit={save}
onCancel={cancel}
onKeyDown={enterSaves
? (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
save();
}
}
: undefined}
placeholder={placeholder}
// As tall as the text it opens on, growing as it is typed.
rows={1}
autoResize
maxLength={maxLength}
countFrom={countFrom}
disabled={submitting}
/>
)}
/>
)
: (
<input
{...registration}
ref={(el) => {
registerRef(el);
inputRef.current = el;
}}
type={kind}
className={`inline-edit-input${
fieldClassName ? ` ${fieldClassName}` : ""
}`}
aria-label={label}
placeholder={placeholder}
maxLength={maxLength}
disabled={submitting}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
cancel();
}
}}
/>
)}
<div className="inline-edit-actions">
<SubmitButton
className="btn-primary btn-sm"
pendingLabel={<Trans>Saving…</Trans>}
disabled={tooLong || blank}
>
<Trans>Save</Trans>
</SubmitButton>
<button
type="button"
className="btn-secondary btn-sm"
onClick={cancel}
disabled={submitting}
>
<Trans>Cancel</Trans>
</button>
<InlineEditError />
</div>
</form>
</FormProvider>
);
}
/**
* Renders why the save failed, as one line beside the buttons: an
* `ErrorCard` would outweigh the field it is about.
*/
function InlineEditError(): React.JSX.Element | null {
const { errors } = useFormState();
const message = errors.root?.message;
if (!message) return null;
return (
<p className="inline-edit-error" role="alert">
{message}
</p>
);
}

View File

@@ -15,6 +15,7 @@ import { NotificationBell } from "./NotificationBell.tsx";
import { ChatButton } from "./ChatButton.tsx"; import { ChatButton } from "./ChatButton.tsx";
import { UserMenu } from "./UserMenu.tsx"; import { UserMenu } from "./UserMenu.tsx";
import { SearchBar } from "./SearchBar.tsx"; import { SearchBar } from "./SearchBar.tsx";
import { Marquee } from "./Marquee.tsx";
import { IconClose, IconSearch } from "./NavIcons.tsx"; import { IconClose, IconSearch } from "./NavIcons.tsx";
import { SITE_EMOJI, SITE_NAME } from "../../hooks/shared/useDocumentTitle.ts"; import { SITE_EMOJI, SITE_NAME } from "../../hooks/shared/useDocumentTitle.ts";
import { playlistsPath, searchPath } from "../../../shared/paths.ts"; import { playlistsPath, searchPath } from "../../../shared/paths.ts";
@@ -227,6 +228,8 @@ export function AppHeader(
</div> </div>
</div> </div>
<Marquee />
{wsStatus === "disconnected" && wsErrorMessage && ( {wsStatus === "disconnected" && wsErrorMessage && (
<div className="app-header-status" role="alert"> <div className="app-header-status" role="alert">
<strong> <strong>

View File

@@ -0,0 +1,158 @@
/**
* Renders the GeoCities style's marquee: the community feed crawling across
* the page under the header, as a `<marquee>` did on a homepage of that era.
* `AppHeader` renders it on every page. It draws nothing in any other style,
* nor for a visitor, the feed being for signed-in users.
*
* @module
*/
import {
useEffect,
useLayoutEffect,
useRef,
useState,
useSyncExternalStore,
} from "react";
import type { CommunityFeed as CommunityFeedData } from "../../../shared/community.ts";
import type { OnlineUser } from "../../../shared/ws.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useCommunityFeed } from "../../hooks/social/useCommunityFeed.ts";
import { useTheme } from "../../hooks/theme/useTheme.ts";
import { useWSPresence } from "../../hooks/ws/useWS.ts";
import { CommunityFeed } from "../social/CommunityFeed.tsx";
const REDUCED_MOTION = "(prefers-reduced-motion: reduce)";
function onMotionChange(callback: () => void): () => void {
const query = matchMedia(REDUCED_MOTION);
query.addEventListener("change", callback);
return () => query.removeEventListener("change", callback);
}
// Where the crawl had got to, and what its loop showed, when the marquee was
// last on screen. They are kept out of the component, which every page mounts
// anew with its header: the next page's marquee carries on from that point of
// that loop, rather than from the start. A crawl that was running has gone on
// since; one paused under the pointer, which has just clicked a link in it,
// hasn't.
let lastPosition:
| { elapsed: number; at: number; running: boolean }
| undefined;
let lastLoop: Loop | null = null;
/** What one loop of the crawl shows, from its start to its end. */
interface Loop {
feed: CommunityFeedData;
online: OnlineUser[];
/** Counts the loops, and keys the track: a new loop is a new animation. */
turn: number;
}
/**
* Renders the feed twice, side by side: geocities.css slides the pair left by
* the width of one, so the second copy arrives where the first began and the
* loop has no seam. The second copy is hidden from assistive technology and
* from the Tab key.
*
* A loop shows what the feed held when it began. Text that changed mid-crawl
* would jump, the track's width being what the slide is measured by, so what
* came meanwhile waits for the next loop, which also brings the lines' times
* up to date. The crawl's speed is the style's: this sets `--marquee-length`,
* one copy's width in pixels, from which the stylesheet works out how long a
* loop lasts.
*
* For a reader who asked for reduced motion nothing moves or repeats: the
* line shows once, and follows the feed as it changes.
*/
function FeedMarquee(): React.JSX.Element {
const feed = useCommunityFeed();
const { onlineUsers: online } = useWSPresence();
const still = useSyncExternalStore(
onMotionChange,
() => matchMedia(REDUCED_MOTION).matches,
);
// A marquee with no feed yet is a first one, or follows a sign-out.
const [loop, setLoop] = useState<Loop | null>(() => feed && lastLoop);
if (!loop && feed) setLoop({ feed, online, turn: 0 });
useEffect(() => {
lastLoop = loop;
}, [loop]);
const shown = still ? feed && { feed, online } : loop;
const trackRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const track = trackRef.current;
const length = track?.firstElementChild?.getBoundingClientRect().width;
if (!track || !length) return;
track.style.setProperty("--marquee-length", String(Math.round(length)));
const last = lastPosition;
lastPosition = undefined;
const elapsed = !last
? 0
: last.elapsed + (last.running ? performance.now() - last.at : 0);
// A negative delay starts the animation that far in.
track.style.animationDelay = `${-elapsed / 1000}s`;
return () => {
// The track's own animation: the focus can have stopped it.
const animation = track.getAnimations()[0];
const timing = animation?.effect?.getComputedTiming();
if (!timing?.progress || typeof timing.duration !== "number") return;
lastPosition = {
elapsed: timing.progress * timing.duration,
at: performance.now(),
running: animation.playState === "running",
};
};
}, [loop?.turn, still]);
return (
<div
className="site-marquee"
// A link focused from the keyboard stops the crawl, and the browser
// scrolls the line to show it: the line goes back to its start once the
// focus has left.
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget)) {
e.currentTarget.scrollLeft = 0;
}
}}
>
{shown && (
<div
key={loop?.turn}
ref={trackRef}
className="site-marquee-track"
onAnimationIteration={(e) => {
// Not a child's animation ending a round of its own.
if (e.target !== e.currentTarget || !feed || !loop) return;
setLoop({ feed, online, turn: loop.turn + 1 });
}}
>
<CommunityFeed feed={shown.feed} online={shown.online} />
{!still && (
<div aria-hidden="true">
<CommunityFeed
feed={shown.feed}
online={shown.online}
tabIndex={-1}
/>
</div>
)}
</div>
)}
</div>
);
}
/**
* Renders the marquee for a signed-in user in the GeoCities style, an empty
* band until the feed is loaded, so that the page under it doesn't move when
* it comes.
*/
export function Marquee(): React.JSX.Element | null {
const { style } = useTheme();
const { user } = useAuth();
if (style !== "geocities" || !user) return null;
return <FeedMarquee />;
}

View File

@@ -8,12 +8,22 @@
import { Link } from "react-router"; import { Link } from "react-router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import type { PlaylistWithDumps } from "../../../shared/playlist.ts"; import { API_URL } from "../../config/api.ts";
import { VALIDATION } from "../../../shared/validation.ts";
import type { Wire } from "../../../shared/api.ts";
import type {
Playlist,
PlaylistWithDumps,
UpdatePlaylistRequest,
} from "../../../shared/playlist.ts";
import { expectOk } from "../../utils/shared/api.ts";
import { deserializePlaylist } from "../../utils/shared/deserialize.ts";
import { imageProps, playlistImageUrl } from "../../utils/shared/urls.ts"; import { imageProps, playlistImageUrl } from "../../utils/shared/urls.ts";
import { IMAGE_SIZES } from "../../config/imageSizes.ts"; import { IMAGE_SIZES } from "../../config/imageSizes.ts";
import { relativeTime } from "../../utils/shared/relativeTime.ts"; import { relativeTime } from "../../utils/shared/relativeTime.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts";
import { Markdown } from "../editor/Markdown.tsx"; import { Markdown } from "../editor/Markdown.tsx";
import { InlineEdit } from "../form/InlineEdit.tsx";
import { FollowPlaylistButton } from "../social/FollowButton.tsx"; import { FollowPlaylistButton } from "../social/FollowButton.tsx";
import { Tooltip } from "../shared/Tooltip.tsx"; import { Tooltip } from "../shared/Tooltip.tsx";
import { CategoryChips } from "../category/CategoryChips.tsx"; import { CategoryChips } from "../category/CategoryChips.tsx";
@@ -22,18 +32,38 @@ import { userPath } from "../../../shared/paths.ts";
/** Passed to {@link PlaylistHeader} by `PlaylistDetail`. */ /** Passed to {@link PlaylistHeader} by `PlaylistDetail`. */
export interface PlaylistHeaderProps { export interface PlaylistHeaderProps {
playlist: PlaylistWithDumps; playlist: PlaylistWithDumps;
/** Given to those who may edit it, for whom it adds an "Edit" button. */ /**
* Given to those who may edit it, with `onSaved`: it adds an "Edit" button,
* which opens `PlaylistEditForm`.
*/
onEdit?: () => void; onEdit?: () => void;
/**
* Called with the playlist as the server has it once its title or
* description is edited in place, which it lets its editors do.
*/
onSaved?: (playlist: Playlist) => void;
} }
/** /**
* Renders the playlist's details, with a follow button for anyone but its * Renders the playlist's details, with a follow button for anyone but its
* owner. `PlaylistEditForm` takes its place while it is edited. * owner. Its editors change the title and the description in place, saved to
* `PUT /api/playlists/:playlistId`, and the rest in `PlaylistEditForm`, which
* takes the header's place while it is open.
*/ */
export function PlaylistHeader( export function PlaylistHeader(
{ playlist, onEdit }: PlaylistHeaderProps, { playlist, onEdit, onSaved }: PlaylistHeaderProps,
): React.JSX.Element { ): React.JSX.Element {
const { user, mediaToken } = useAuth(); const { user, mediaToken, authFetch } = useAuth();
const save = async (changes: UpdatePlaylistRequest) => {
const updated = await expectOk<Wire<Playlist>>(
await authFetch(`${API_URL}/api/playlists/${playlist.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(changes),
}),
);
onSaved?.(deserializePlaylist(updated));
};
return ( return (
<> <>
{playlist.imageMime && ( {playlist.imageMime && (
@@ -48,7 +78,17 @@ export function PlaylistHeader(
)} )}
<div className="playlist-detail-content"> <div className="playlist-detail-content">
<div className="playlist-detail-title-row"> <div className="playlist-detail-title-row">
<InlineEdit
value={playlist.title}
editable={!!onSaved}
label={t`Edit title`}
onSave={(title) => save({ title })}
required
maxLength={VALIDATION.PLAYLIST_TITLE_MAX}
fieldClassName="playlist-detail-title"
>
<h1 className="playlist-detail-title">{playlist.title}</h1> <h1 className="playlist-detail-title">{playlist.title}</h1>
</InlineEdit>
{user?.id !== playlist.userId && ( {user?.id !== playlist.userId && (
<FollowPlaylistButton <FollowPlaylistButton
targetPlaylistId={playlist.id} targetPlaylistId={playlist.id}
@@ -66,10 +106,29 @@ export function PlaylistHeader(
)} )}
</div> </div>
{playlist.description && ( {(playlist.description || onSaved) && (
<InlineEdit
value={playlist.description ?? ""}
editable={!!onSaved}
label={t`Edit description`}
onSave={(description) => save({ description: description || null })}
kind="markdown"
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
placeholder={t`Add a description…`}
>
{playlist.description
? (
<Markdown className="playlist-detail-description"> <Markdown className="playlist-detail-description">
{playlist.description} {playlist.description}
</Markdown> </Markdown>
)
: (
<p className="inline-edit-empty">
<Trans>Add a description…</Trans>
</p>
)}
</InlineEdit>
)} )}
<div className="playlist-detail-meta"> <div className="playlist-detail-meta">

View File

@@ -1,104 +0,0 @@
/**
* Renders the inline editor of the signed-in user's description, under their
* profile's header.
*
* @module
*/
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { Controller, FormProvider } from "react-hook-form";
import { API_URL } from "../../config/api.ts";
import { VALIDATION } from "../../../shared/validation.ts";
import type { Wire } from "../../../shared/api.ts";
import type { PublicUser, UpdateUserRequest } from "../../../shared/user.ts";
import { expectOk } from "../../utils/shared/api.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useApiForm } from "../../hooks/form/useApiForm.ts";
import { TextEditor } from "../editor/TextEditor.tsx";
import { FormError } from "../form/FormError.tsx";
import { SubmitButton } from "../form/SubmitButton.tsx";
/**
* Passed to {@link DescriptionEditor} by `ProfileDescription`, which shows it
* in place of the description while editing.
*/
export interface DescriptionEditorProps {
initialDescription: string;
onCancel: () => void;
onSaved: (description: string | undefined) => void;
}
/**
* Renders the description's Markdown editor, saved to `PATCH /api/me`.
* Other browsers see the change through the socket's `user_updated` event.
*/
export function DescriptionEditor(
{ initialDescription, onCancel, onSaved }: DescriptionEditorProps,
): React.JSX.Element {
const { authFetch } = useAuth();
const form = useApiForm<{ description: string }>({
defaultValues: { description: initialDescription },
});
const description = form.watch("description");
const submitting = form.formState.isSubmitting;
const onSubmit = form.submit(async ({ description }) => {
const trimmed = description.trim();
// Sent even when empty, since the server keeps the old one when it is left
// out.
await expectOk<Wire<PublicUser>>(
await authFetch(`${API_URL}/api/me`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
{ description: trimmed } satisfies UpdateUserRequest,
),
}),
);
onSaved(trimmed || undefined);
});
return (
<FormProvider {...form}>
<form
className="profile-description-editor animate-in"
onSubmit={onSubmit}
>
<Controller
name="description"
control={form.control}
render={({ field }) => (
<TextEditor
className="comment-reply-textarea"
value={field.value}
onChange={field.onChange}
onSubmit={() => void onSubmit()}
onCancel={submitting ? undefined : onCancel}
placeholder={t`Who am I?`}
autoResize
maxLength={VALIDATION.USER_DESCRIPTION_MAX}
/>
)}
/>
<div className="profile-description-actions">
<SubmitButton
pendingLabel={<Trans>Saving…</Trans>}
disabled={description.length > VALIDATION.USER_DESCRIPTION_MAX}
>
<Trans>Save</Trans>
</SubmitButton>
<button
type="button"
className="btn-secondary"
onClick={onCancel}
disabled={submitting}
>
<Trans>Cancel</Trans>
</button>
<FormError title={t`Failed to save`} />
</div>
</form>
</FormProvider>
);
}

View File

@@ -1,90 +0,0 @@
/**
* Renders the inline editor of the signed-in user's email, in their profile's
* header.
*
* @module
*/
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { FormProvider } from "react-hook-form";
import { API_URL } from "../../config/api.ts";
import type { Wire } from "../../../shared/api.ts";
import type { PublicUser, UpdateUserRequest } from "../../../shared/user.ts";
import { expectOk } from "../../utils/shared/api.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useApiForm } from "../../hooks/form/useApiForm.ts";
import { FormError } from "../form/FormError.tsx";
import { SubmitButton } from "../form/SubmitButton.tsx";
/**
* Passed to {@link EmailEditor} by `ProfileHeader`, which shows it in place of
* the email while editing.
*/
export interface EmailEditorProps {
initialEmail: string;
onCancel: () => void;
onSaved: (email: string) => void;
}
/**
* Renders the email field, saved to `PATCH /api/me`. Escape cancels.
*/
export function EmailEditor(
{ initialEmail, onCancel, onSaved }: EmailEditorProps,
): React.JSX.Element {
const { authFetch } = useAuth();
const form = useApiForm<{ email: string }>({
defaultValues: { email: initialEmail },
});
const email = form.watch("email");
const submitting = form.formState.isSubmitting;
const onSubmit = form.submit(async ({ email }) => {
await expectOk<Wire<PublicUser>>(
await authFetch(`${API_URL}/api/me`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
{ email: email.trim() } satisfies UpdateUserRequest,
),
}),
);
onSaved(email.trim());
});
return (
<FormProvider {...form}>
<form className="profile-email-editor animate-in" onSubmit={onSubmit}>
<input
type="email"
className="profile-email-input"
{...form.register("email")}
onKeyDown={(e) => {
if (e.key === "Escape") onCancel();
}}
disabled={submitting}
autoFocus
/>
<div className="profile-email-actions">
<SubmitButton
className="btn-primary btn-sm"
pendingLabel={<Trans>Saving…</Trans>}
disabled={!email.trim()}
>
<Trans>Save</Trans>
</SubmitButton>
<button
type="button"
className="btn-secondary btn-sm"
onClick={onCancel}
disabled={submitting}
>
<Trans>Cancel</Trans>
</button>
</div>
<FormError title={t`Failed to save`} />
</form>
</FormProvider>
);
}

View File

@@ -5,12 +5,16 @@
* @module * @module
*/ */
import { useState } from "react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { useHeightTransition } from "../../hooks/shared/useHeightTransition.ts"; import { API_URL } from "../../config/api.ts";
import { VALIDATION } from "../../../shared/validation.ts";
import type { Wire } from "../../../shared/api.ts";
import type { PublicUser, UpdateUserRequest } from "../../../shared/user.ts";
import { expectOk } from "../../utils/shared/api.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { InlineEdit } from "../form/InlineEdit.tsx";
import { Markdown } from "../editor/Markdown.tsx"; import { Markdown } from "../editor/Markdown.tsx";
import { DescriptionEditor } from "./DescriptionEditor.tsx";
/** Passed to {@link ProfileDescription} by `UserPublicProfile`. */ /** Passed to {@link ProfileDescription} by `UserPublicProfile`. */
export interface ProfileDescriptionProps { export interface ProfileDescriptionProps {
@@ -22,51 +26,40 @@ export interface ProfileDescriptionProps {
/** /**
* Renders the description in Markdown, or nothing when there is none. On * Renders the description in Markdown, or nothing when there is none. On
* their own profile, users get a "Who am I?" placeholder instead, and a click * their own profile, users get a "Who am I?" placeholder instead, and edit it
* or Enter swaps in {@link DescriptionEditor}. * in place, saved to `PATCH /api/me`. Other browsers see the change through
* the socket's `user_updated` event.
*/ */
export function ProfileDescription( export function ProfileDescription(
{ description, isOwnProfile, onSaved }: ProfileDescriptionProps, { description, isOwnProfile, onSaved }: ProfileDescriptionProps,
): React.JSX.Element | null { ): React.JSX.Element | null {
const [editing, setEditing] = useState(false); const { authFetch } = useAuth();
const ref = useHeightTransition<HTMLDivElement>(editing);
if (!description && !isOwnProfile) return null; if (!description && !isOwnProfile) return null;
return ( return (
<div className="profile-description" ref={ref}> <div className="profile-description">
{editing <InlineEdit
? ( value={description ?? ""}
<DescriptionEditor editable={isOwnProfile}
initialDescription={description ?? ""} label={t`Edit description`}
onCancel={() => setEditing(false)} onSave={async (saved) => {
onSaved={(saved) => { // Sent even when empty, since the server keeps the old one when it
onSaved(saved); // is left out.
setEditing(false); await expectOk<Wire<PublicUser>>(
await authFetch(`${API_URL}/api/me`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
{ description: saved } satisfies UpdateUserRequest,
),
}),
);
onSaved(saved || undefined);
}} }}
/> kind="markdown"
) maxLength={VALIDATION.USER_DESCRIPTION_MAX}
: ( placeholder={t`Who am I?`}
<div
className={`profile-description-view${
isOwnProfile ? " profile-description-view--editable" : ""
}`}
onClick={isOwnProfile ? () => setEditing(true) : undefined}
// Focusable rather than a button, so the Markdown inside keeps its
// own structure and links.
tabIndex={isOwnProfile ? 0 : undefined}
title={isOwnProfile ? t`Edit description` : undefined}
onKeyDown={isOwnProfile
? (e) => {
if (
(e.key === "Enter" || e.key === " ") &&
e.target === e.currentTarget
) {
e.preventDefault();
setEditing(true);
}
}
: undefined}
> >
{description {description
? ( ? (
@@ -75,17 +68,11 @@ export function ProfileDescription(
</Markdown> </Markdown>
) )
: ( : (
<div className="profile-description-empty"> <p className="inline-edit-empty">
<Trans>Who am I?</Trans> <Trans>Who am I?</Trans>
</div> </p>
)}
{isOwnProfile && (
<span className="profile-description-edit-btn" aria-hidden>
✎
</span>
)}
</div>
)} )}
</InlineEdit>
</div> </div>
); );
} }

View File

@@ -11,15 +11,18 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { API_URL } from "../../config/api.ts"; import { API_URL } from "../../config/api.ts";
import type { Wire } from "../../../shared/api.ts"; import type { Wire } from "../../../shared/api.ts";
import type { PublicUser, SelfUser } from "../../../shared/user.ts"; import type {
import { parseAPIResponse } from "../../utils/shared/api.ts"; PublicUser,
SelfUser,
UpdateUserRequest,
} from "../../../shared/user.ts";
import { expectOk, parseAPIResponse } from "../../utils/shared/api.ts";
import { deserializeSelfUser } from "../../utils/shared/deserialize.ts"; import { deserializeSelfUser } from "../../utils/shared/deserialize.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useHeightTransition } from "../../hooks/shared/useHeightTransition.ts";
import { Avatar } from "../shared/Avatar.tsx"; import { Avatar } from "../shared/Avatar.tsx";
import { ErrorCard } from "../shared/ErrorCard.tsx"; import { ErrorCard } from "../shared/ErrorCard.tsx";
import { FollowUserButton } from "../social/FollowButton.tsx"; import { FollowUserButton } from "../social/FollowButton.tsx";
import { EmailEditor } from "./EmailEditor.tsx"; import { InlineEdit } from "../form/InlineEdit.tsx";
import { ProfileOwnActions } from "./ProfileOwnActions.tsx"; import { ProfileOwnActions } from "./ProfileOwnActions.tsx";
import { RoleChip } from "./RoleSwitcher.tsx"; import { RoleChip } from "./RoleSwitcher.tsx";
import { userPath } from "../../../shared/paths.ts"; import { userPath } from "../../../shared/paths.ts";
@@ -38,7 +41,7 @@ export interface ProfileHeaderProps {
* Renders the avatar, username, role and inviter, and for a user from another * Renders the avatar, username, role and inviter, and for a user from another
* instance, a link to their profile there. Visitors get a follow button. On * instance, a link to their profile there. Visitors get a follow button. On
* their own profile, users change their avatar (`PUT /api/me/avatar`) and * their own profile, users change their avatar (`PUT /api/me/avatar`) and
* email, unless their account comes from another instance, and invite * email (`PATCH /api/me`), unless their account comes from another instance, and invite
* someone; their settings, the site's tools and logging out are in the * someone; their settings, the site's tools and logging out are in the
* header's avatar menu. * header's avatar menu.
*/ */
@@ -49,8 +52,6 @@ export function ProfileHeader(
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [avatarError, setAvatarError] = useState<string | null>(null); const [avatarError, setAvatarError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [emailEditing, setEmailEditing] = useState(false);
const infoRef = useHeightTransition<HTMLDivElement>(emailEditing);
const { name, host } = splitHandle(user.username); const { name, host } = splitHandle(user.username);
// A user from another instance changes their avatar and email there. // A user from another instance changes their avatar and email there.
const editable = isOwnProfile && !host; const editable = isOwnProfile && !host;
@@ -83,9 +84,15 @@ export function ProfileHeader(
} }
}; };
const handleEmailSaved = (email: string) => { const handleEmailSave = async (email: string) => {
await expectOk<Wire<PublicUser>>(
await authFetch(`${API_URL}/api/me`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email } satisfies UpdateUserRequest),
}),
);
if (me) updateSession({ user: { ...me, email } }); if (me) updateSession({ user: { ...me, email } });
setEmailEditing(false);
}; };
return ( return (
@@ -112,7 +119,7 @@ export function ProfileHeader(
</label> </label>
)} )}
</div> </div>
<div className="profile-info" ref={infoRef}> <div className="profile-info">
<div className="profile-username-row"> <div className="profile-username-row">
<h1 className="profile-username"> <h1 className="profile-username">
{name} {name}
@@ -148,34 +155,19 @@ export function ProfileHeader(
</p> </p>
)} )}
{editable && ( {editable && (
emailEditing <InlineEdit
? ( value={me?.email ?? ""}
<EmailEditor editable
initialEmail={me?.email ?? ""} label={t`Edit email`}
onCancel={() => setEmailEditing(false)} onSave={handleEmailSave}
onSaved={handleEmailSaved} kind="email"
/> required
) className="profile-email"
: (
<p
className="profile-email-display"
onClick={() => setEmailEditing(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setEmailEditing(true);
}
}}
tabIndex={0}
role="button"
title={t`Edit email`}
> >
<span className="profile-email-text">
{me?.email ?? t`Add email…`} {me?.email ?? t`Add email…`}
<span className="profile-description-edit-btn" aria-hidden>
✎
</span> </span>
</p> </InlineEdit>
)
)} )}
{avatarError && ( {avatarError && (
<ErrorCard <ErrorCard

View File

@@ -27,9 +27,9 @@ export interface TabBarProps<T extends string> {
/** An extra class for the row. */ /** An extra class for the row. */
innerClassName?: string; innerClassName?: string;
/** /**
* Optional content rendered inside the row, before the tabs (e.g. a scope * Optional content rendered before the tabs (e.g. a scope selector). Sits
* selector). Sits in the same flex line, aligned with them, and stays pinned * in the same line, aligned with them, and stays where it is while they
* at the row's start while they scroll under it. * scroll: it is outside the row that scrolls, which clips them at its edge.
*/ */
leading?: ReactNode; leading?: ReactNode;
} }
@@ -43,7 +43,6 @@ export function TabBar<T extends string>(
TabBarProps<T>, TabBarProps<T>,
): React.JSX.Element { ): React.JSX.Element {
const rowRef = useRef<HTMLDivElement>(null); const rowRef = useRef<HTMLDivElement>(null);
const leadingRef = useRef<HTMLDivElement>(null);
const [overflowing, setOverflowing] = useState(false); const [overflowing, setOverflowing] = useState(false);
// Tabs hidden past either edge, which the stylesheet fades only then, so a // Tabs hidden past either edge, which the stylesheet fades only then, so a
// fade never dims a tab that is fully in view. // fade never dims a tab that is fully in view.
@@ -69,19 +68,17 @@ export function TabBar<T extends string>(
}; };
}, [tabs.length]); }, [tabs.length]);
// When they don't all fit, keep the active tab in view, between the leading // When they don't all fit, keep the active tab in view, clear of the fade
// content pinned over the row's left edge and the fade over its right edge, // over the row's right edge, scrolling the row alone, never the page.
// scrolling the row alone, never the page.
useLayoutEffect(() => { useLayoutEffect(() => {
const row = rowRef.current; const row = rowRef.current;
const active = row?.querySelector<HTMLElement>(".feed-sort-btn.active"); const active = row?.querySelector<HTMLElement>(".feed-sort-btn.active");
if (!row || !active || !overflowing) return; if (!row || !active || !overflowing) return;
// The fade is 2rem wide (.feed-sort-scroller::after). // The fade is 2rem wide (--tab-fade-end on .feed-sort).
const fade = const fade =
parseFloat(getComputedStyle(document.documentElement).fontSize) * 2; parseFloat(getComputedStyle(document.documentElement).fontSize) * 2;
const r = row.getBoundingClientRect(), b = active.getBoundingClientRect(); const r = row.getBoundingClientRect(), b = active.getBoundingClientRect();
const left = r.left + (leadingRef.current?.offsetWidth ?? 0); if (b.left < r.left) row.scrollLeft -= r.left - b.left;
if (b.left < left) row.scrollLeft -= left - b.left;
else if (b.right > r.right - fade) { else if (b.right > r.right - fade) {
row.scrollLeft += b.right - r.right + fade; row.scrollLeft += b.right - r.right + fade;
} }
@@ -95,13 +92,11 @@ export function TabBar<T extends string>(
className ? ` ${className}` : "" className ? ` ${className}` : ""
}`} }`}
> >
{leading && <div className="feed-sort-leading">{leading}</div>}
<div <div
ref={rowRef} ref={rowRef}
className={`feed-sort${innerClassName ? ` ${innerClassName}` : ""}`} className={`feed-sort${innerClassName ? ` ${innerClassName}` : ""}`}
> >
{leading && (
<div ref={leadingRef} className="feed-sort-leading">{leading}</div>
)}
{tabs.map(({ key, label }) => ( {tabs.map(({ key, label }) => (
<button <button
key={key} key={key}

View File

@@ -0,0 +1,196 @@
/**
* Renders the community feed: who is online, the week's figures, and the
* latest things the site's members did in public, each a line with links to
* who and what it tells of. The GeoCities style's `Marquee` scrolls it, and
* any style may show it: it is a plain list, which the style lays out.
*
* @module
*/
import { Link } from "react-router";
import { Trans } from "@lingui/react/macro";
import { useLingui } from "@lingui/react";
import type {
CommunityEvent,
CommunityFeed as CommunityFeedData,
} from "../../../shared/community.ts";
import { DIGEST_MESSAGES } from "../../../shared/digest-messages.ts";
import {
communityPath,
dumpPath,
playlistPath,
userPath,
} from "../../../shared/paths.ts";
import type { OnlineUser } from "../../../shared/ws.ts";
import { relativeTime } from "../../utils/shared/relativeTime.ts";
// Past this many, the online line counts the others: "+3".
const ONLINE_MAX = 8;
/** Passed to {@link CommunityFeed} by whatever shows the feed. */
export interface CommunityFeedProps {
/** From `useCommunityFeed`. */
feed: CommunityFeedData;
/** From `useWSPresence`: the feed opens on them. */
online: OnlineUser[];
/**
* Given to every link. `-1` takes them out of the tab order, for a second
* copy of the list such as the one the marquee's loop needs.
*/
tabIndex?: number;
}
/**
* Links a name to its profile. It wraps the name, rather than take it as a
* prop, so that a sentence reads `<0>{0}</0> posted <1>{1}</1>` to Lingui, as
* the same sentence of the notifications page does, and shares its
* translation.
*/
function User(
{ children, tabIndex }: { children: string; tabIndex?: number },
) {
return <Link to={userPath(children)} tabIndex={tabIndex}>{children}</Link>;
}
/**
* Renders the feed as a `<ul>`: a line for who is online, one for the week's
* figures, which links to `/community`, one for its most upvoted dump, then
* one per event, newest first, ending on how long ago it was. A line with
* nothing to say is left out. The times are computed at render.
*/
export function CommunityFeed(
{ feed, online, tabIndex }: CommunityFeedProps,
): React.JSX.Element {
const { i18n } = useLingui();
const { totals, chatMessages, top } = feed.week;
const figures = ([
[totals.dumps, DIGEST_MESSAGES.statDumps],
[totals.upvotes, DIGEST_MESSAGES.countUpvotes],
[totals.comments, DIGEST_MESSAGES.countComments],
[chatMessages, DIGEST_MESSAGES.statChat],
] as const)
.filter(([n]) => n > 0)
.map(([n, label]) =>
`${i18n.number(n)} ${i18n._({ ...label, values: { n } })}`
)
.join(", ");
const names = (
<>
{online.slice(0, ONLINE_MAX).map((u, i) => (
<span key={u.userId}>
{i > 0 && ", "}
<User tabIndex={tabIndex}>{u.username}</User>
</span>
))}
{online.length > ONLINE_MAX && ` +${online.length - ONLINE_MAX}`}
</>
);
function sentence(event: CommunityEvent) {
switch (event.type) {
case "dump_posted":
return (
<Trans>
<User tabIndex={tabIndex}>{event.username}</User>
{" posted "}
<Link to={dumpPath(event.dump)} tabIndex={tabIndex}>
{event.dump.title}
</Link>
</Trans>
);
case "dump_upvoted":
return (
<Trans>
<User tabIndex={tabIndex}>{event.username}</User>
{" upvoted "}
<Link to={dumpPath(event.dump)} tabIndex={tabIndex}>
{event.dump.title}
</Link>
</Trans>
);
case "dump_commented":
return (
<Trans>
<User tabIndex={tabIndex}>{event.username}</User>
{" commented on "}
<Link
to={`${dumpPath(event.dump)}#comment-${event.commentId}`}
tabIndex={tabIndex}
>
{event.dump.title}
</Link>
</Trans>
);
case "playlist_created":
return (
<Trans>
<User tabIndex={tabIndex}>{event.username}</User>
{" created the playlist "}
<Link to={playlistPath(event.playlist)} tabIndex={tabIndex}>
{event.playlist.title}
</Link>
</Trans>
);
case "user_joined":
return event.invitedByUsername
? (
<Trans>
<User tabIndex={tabIndex}>{event.username}</User>
{" joined, invited by "}
<User tabIndex={tabIndex}>{event.invitedByUsername}</User>
</Trans>
)
: (
<Trans>
<User tabIndex={tabIndex}>{event.username}</User> joined
</Trans>
);
}
}
return (
<ul
className="community-feed"
aria-label={i18n._(DIGEST_MESSAGES.community)}
>
{online.length > 0 && (
<li className="community-feed-item community-feed-item--online">
<Trans>
<span className="community-feed-label">Online now:</span> {names}
</Trans>
</li>
)}
{figures && (
<li className="community-feed-item community-feed-item--week">
<Trans>
<span className="community-feed-label">This week:</span>{" "}
<Link to={communityPath()} tabIndex={tabIndex}>{figures}</Link>
</Trans>
</li>
)}
{top && (
<li className="community-feed-item community-feed-item--top">
<Trans>
<span className="community-feed-label">Top this week:</span>{" "}
<Link to={dumpPath(top)} tabIndex={tabIndex}>{top.title}</Link>
</Trans>
</li>
)}
{feed.events.map((event) => (
<li
key={event.id}
className={`community-feed-item community-feed-item--${event.type}`}
>
{sentence(event)}{" "}
<time
className="community-feed-time"
dateTime={event.at.toISOString()}
>
{relativeTime(event.at)}
</time>
</li>
))}
</ul>
);
}

View File

@@ -36,6 +36,8 @@ export const WS_URL: string = API_URL
/** The community endpoint, for the community page. */ /** The community endpoint, for the community page. */
export const COMMUNITY_URL = `${API_URL}/api/community`; export const COMMUNITY_URL = `${API_URL}/api/community`;
/** The community feed's endpoint, for `CommunityFeedProvider`. */
export const COMMUNITY_FEED_URL = `${COMMUNITY_URL}/feed`;
/** The search endpoint, for the search page. */ /** The search endpoint, for the search page. */
export const SEARCH_URL = `${API_URL}/api/search`; export const SEARCH_URL = `${API_URL}/api/search`;
/** How many items a page of a feed or list asks the API for. */ /** How many items a page of a feed or list asks the API for. */

View File

@@ -0,0 +1,36 @@
/**
* Hands the community feed, the latest things the site's members did in
* public, to whatever shows it: the GeoCities style's marquee, for now.
* `CommunityFeedProvider` provides this context and `useCommunityFeed` reads
* it.
*
* @module
*/
import { createContext } from "react";
import type { CommunityFeed } from "../../../shared/community.ts";
/** Provided by `CommunityFeedProvider` through {@link CommunityFeedContext}. */
export interface CommunityFeedContextValue {
/**
* Null until it is fetched, which waits for a first {@link watch}, and
* always for a visitor: the feed is for signed-in users.
*/
feed: CommunityFeed | null;
/**
* Says a component is showing the feed, until the returned call. The feed
* is fetched, and kept up to date, only while one is: most styles show it
* nowhere. `useCommunityFeed` calls it.
*/
watch: () => () => void;
}
/** Given to a consumer outside `CommunityFeedProvider`: no feed. */
export const defaultCommunityFeedValue: CommunityFeedContextValue = {
feed: null,
watch: () => () => {},
};
/** Provided by `CommunityFeedProvider`, and read with `useCommunityFeed`. */
export const CommunityFeedContext: React.Context<CommunityFeedContextValue> =
createContext(defaultCommunityFeedValue);

View File

@@ -0,0 +1,118 @@
/**
* Keeps the community feed for the components that show it, loaded from
* `GET /api/community/feed` and again whenever the socket tells of something
* the feed may hold. `App` mounts it around every page, so that the feed
* outlives the header it is shown in, which every page renders anew.
*
* @module
*/
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type { Wire } from "../../../shared/api.ts";
import type { CommunityFeed } from "../../../shared/community.ts";
import { COMMUNITY_FEED_URL } from "../../config/api.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useFetchState } from "../../hooks/shared/useFetchState.ts";
import { useOnReconnect, useWS, useWSEvent } from "../../hooks/ws/useWS.ts";
import { expectOk } from "../../utils/shared/api.ts";
import { deserializeCommunityFeed } from "../../utils/shared/deserialize.ts";
import {
CommunityFeedContext,
type CommunityFeedContextValue,
} from "./CommunityFeedContext.ts";
/** Passed to {@link CommunityFeedProvider} by `App`. */
export interface CommunityFeedProviderProps {
children: ReactNode;
}
// The feed is fetched again this long after the last change the socket told
// of, so that a run of upvotes costs one request.
const RELOAD_MS = 2000;
/**
* Provides {@link CommunityFeedContext}. It fetches the feed once a component
* watches it, for the signed-in user alone. The socket's messages don't carry
* a feed's lines, an upvote's naming neither its dump nor its voter, so each
* one that may change the feed has it fetched again, and the server's query
* stays the one place that decides what the feed holds. A change that comes
* while nothing watches is caught up with on the next watch.
*/
export function CommunityFeedProvider(
{ children }: CommunityFeedProviderProps,
): React.JSX.Element {
const { user, authFetch } = useAuth();
const [watched, setWatched] = useState(false);
const load = useCallback(
async (signal: AbortSignal) =>
deserializeCommunityFeed(
await expectOk<Wire<CommunityFeed>>(
await authFetch(COMMUNITY_FEED_URL, { signal }),
),
),
[authFetch],
);
const { state, reload } = useFetchState(user?.id, load, {
enabled: watched,
});
const watchers = useRef(0);
// A change came that the feed hasn't been fetched again for.
const due = useRef(false);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
const refresh = useCallback(() => {
if (!due.current || watchers.current === 0) return;
due.current = false;
reload();
}, [reload]);
const changed = useCallback(() => {
due.current = true;
clearTimeout(timer.current);
timer.current = setTimeout(refresh, RELOAD_MS);
}, [refresh]);
useEffect(() => () => clearTimeout(timer.current), []);
useWSEvent("dump", changed);
useWSEvent("vote", changed);
useWSEvent("comment", (e) => e.type !== "updated" && changed());
useWSEvent("playlist", (e) => e.type !== "dumps_updated" && changed());
// A rename changes the names the feed writes.
useWSEvent("user", changed);
useOnReconnect(changed);
// A deleted dump comes as no event, only as a change of this set.
const { deletedDumpIds } = useWS();
const seenDeleted = useRef(deletedDumpIds);
useEffect(() => {
if (seenDeleted.current === deletedDumpIds) return;
seenDeleted.current = deletedDumpIds;
changed();
}, [deletedDumpIds, changed]);
const watch = useCallback(() => {
watchers.current++;
setWatched(true);
refresh();
return () => {
watchers.current--;
};
}, [refresh]);
const feed = state.status === "loaded" ? state.data : null;
const value: CommunityFeedContextValue = useMemo(
() => ({ feed, watch }),
[feed, watch],
);
return (
<CommunityFeedContext.Provider value={value}>
{children}
</CommunityFeedContext.Provider>
);
}

View File

@@ -62,7 +62,48 @@
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
} }
/* nyt */ /* geocities: what stands in for Comic Sans where the system has none (Linux,
Android), so it comes after it in the theme's stack and is only downloaded
there. The package has no latin-ext subset. */
@font-face {
font-family: 'Comic Neue';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(@fontsource/comic-neue/files/comic-neue-latin-400-normal.woff2)
format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'Comic Neue';
font-style: italic;
font-display: swap;
font-weight: 400;
src: url(@fontsource/comic-neue/files/comic-neue-latin-400-italic.woff2)
format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'Comic Neue';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(@fontsource/comic-neue/files/comic-neue-latin-700-normal.woff2)
format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'Comic Neue';
font-style: italic;
font-display: swap;
font-weight: 700;
src: url(@fontsource/comic-neue/files/comic-neue-latin-700-italic.woff2)
format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* nyt: the headlines' serif, the text's (opsz as well as wght), the
interface's sans, and the nameplate's blackletter */
@font-face { @font-face {
font-family: 'Instrument Serif'; font-family: 'Instrument Serif';
font-style: normal; font-style: normal;
@@ -99,6 +140,96 @@
format('woff2'); format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
} }
@font-face {
font-family: 'Newsreader Variable';
font-style: normal;
font-display: swap;
font-weight: 200 800;
src: url(@fontsource-variable/newsreader/files/newsreader-latin-ext-opsz-normal.woff2)
format('woff2-variations');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'Newsreader Variable';
font-style: normal;
font-display: swap;
font-weight: 200 800;
src: url(@fontsource-variable/newsreader/files/newsreader-latin-opsz-normal.woff2)
format('woff2-variations');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'Newsreader Variable';
font-style: italic;
font-display: swap;
font-weight: 200 800;
src: url(@fontsource-variable/newsreader/files/newsreader-latin-ext-opsz-italic.woff2)
format('woff2-variations');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'Newsreader Variable';
font-style: italic;
font-display: swap;
font-weight: 200 800;
src: url(@fontsource-variable/newsreader/files/newsreader-latin-opsz-italic.woff2)
format('woff2-variations');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'Libre Franklin Variable';
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url(@fontsource-variable/libre-franklin/files/libre-franklin-latin-ext-wght-normal.woff2)
format('woff2-variations');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'Libre Franklin Variable';
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url(@fontsource-variable/libre-franklin/files/libre-franklin-latin-wght-normal.woff2)
format('woff2-variations');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'Libre Franklin Variable';
font-style: italic;
font-display: swap;
font-weight: 100 900;
src: url(@fontsource-variable/libre-franklin/files/libre-franklin-latin-ext-wght-italic.woff2)
format('woff2-variations');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'Libre Franklin Variable';
font-style: italic;
font-display: swap;
font-weight: 100 900;
src: url(@fontsource-variable/libre-franklin/files/libre-franklin-latin-wght-italic.woff2)
format('woff2-variations');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'Manufacturing Consent';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(@fontsource/manufacturing-consent/files/manufacturing-consent-latin-ext-400-normal.woff2)
format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'Manufacturing Consent';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(@fontsource/manufacturing-consent/files/manufacturing-consent-latin-400-normal.woff2)
format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* kanagawa */ /* kanagawa */
@font-face { @font-face {
@@ -138,40 +269,23 @@
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
} }
/* melange: opsz, SOFT and WONK axes as well as wght */ /* melange: the weight axis alone, which leaves the face at its text optical
size for a headline too. The family has no italic. */
@font-face { @font-face {
font-family: 'Fraunces Variable'; font-family: 'Bricolage Grotesque Variable';
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
font-weight: 100 900; font-weight: 200 800;
src: url(@fontsource-variable/fraunces/files/fraunces-latin-ext-full-normal.woff2) src: url(@fontsource-variable/bricolage-grotesque/files/bricolage-grotesque-latin-ext-wght-normal.woff2)
format('woff2-variations'); format('woff2-variations');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
} }
@font-face { @font-face {
font-family: 'Fraunces Variable'; font-family: 'Bricolage Grotesque Variable';
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
font-weight: 100 900; font-weight: 200 800;
src: url(@fontsource-variable/fraunces/files/fraunces-latin-full-normal.woff2) src: url(@fontsource-variable/bricolage-grotesque/files/bricolage-grotesque-latin-wght-normal.woff2)
format('woff2-variations');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'Fraunces Variable';
font-style: italic;
font-display: swap;
font-weight: 100 900;
src: url(@fontsource-variable/fraunces/files/fraunces-latin-ext-full-italic.woff2)
format('woff2-variations');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'Fraunces Variable';
font-style: italic;
font-display: swap;
font-weight: 100 900;
src: url(@fontsource-variable/fraunces/files/fraunces-latin-full-italic.woff2)
format('woff2-variations'); format('woff2-variations');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
} }

View File

@@ -51,7 +51,8 @@ export function useRoomBeside(
// The tabs are observed too, since their width changes without their // The tabs are observed too, since their width changes without their
// box's (a web font arriving, a tab added at sign-in), and re-observed as // box's (a web font arriving, a tab added at sign-in), and re-observed as
// they come and go. // they come and go. They are the children of a child, the row that
// scrolls them, when a scope switcher comes before it.
const resize = new ResizeObserver(measure); const resize = new ResizeObserver(measure);
const mutation = new MutationObserver(observe); const mutation = new MutationObserver(observe);
function observe() { function observe() {
@@ -60,8 +61,13 @@ export function useRoomBeside(
resize.observe(container!); resize.observe(container!);
const kept = container!.querySelector<HTMLElement>(beside!); const kept = container!.querySelector<HTMLElement>(beside!);
if (kept) { if (kept) {
for (const child of kept.children) resize.observe(child); for (const child of kept.children) {
mutation.observe(kept, { childList: true }); resize.observe(child);
if (scrolls(child)) {
for (const inner of child.children) resize.observe(inner);
}
}
mutation.observe(kept, { childList: true, subtree: true });
} }
measure(); measure();
} }
@@ -75,17 +81,26 @@ export function useRoomBeside(
return room; return room;
} }
/** Whether `el` scrolls its content sideways, as the row of tabs does. */
function scrolls(el: Element): boolean {
const overflow = getComputedStyle(el).overflowX;
return overflow === "auto" || overflow === "scroll";
}
/** /**
* Returns how wide the content of `el` runs, from its first child to its last, * Returns how wide the content of `el` runs, from its first child to its last,
* plus its padding. Its box won't do, since a flex row may squeeze it (the tabs * plus its padding. Its box won't do, since a flex row may squeeze it (the tabs
* then scroll) or stretch it, and neither will its scrollWidth, which is at * then scroll) or stretch it, and neither will its scrollWidth, which is at
* least the box. * least the box. A last child that scrolls counts the same way, at its own
* content's width: it is the one squeezed when a scope switcher sits before
* the tabs, outside the row that scrolls them.
*/ */
function contentWidth(el: HTMLElement): number { function contentWidth(el: Element): number {
const first = el.firstElementChild, last = el.lastElementChild; const first = el.firstElementChild, last = el.lastElementChild;
if (!first || !last) return 0; if (!first || !last) return 0;
const cs = getComputedStyle(el); const cs = getComputedStyle(el);
return last.getBoundingClientRect().right - const end = last.getBoundingClientRect();
return (scrolls(last) ? end.left + contentWidth(last) : end.right) -
first.getBoundingClientRect().left + parseFloat(cs.paddingLeft) + first.getBoundingClientRect().left + parseFloat(cs.paddingLeft) +
parseFloat(cs.paddingRight); parseFloat(cs.paddingRight);
} }

View File

@@ -0,0 +1,21 @@
/**
* Gives a component the community feed, by reading `CommunityFeedContext`.
*
* @module
*/
import { useContext, useEffect } from "react";
import type { CommunityFeed } from "../../../shared/community.ts";
import { CommunityFeedContext } from "../../contexts/social/CommunityFeedContext.ts";
/**
* Returns the community feed, kept up to date for as long as the component is
* mounted, or null until it is loaded and for a visitor. The `CommunityFeed`
* component renders it. Needs `CommunityFeedProvider` above, which `App`
* mounts around every page.
*/
export function useCommunityFeed(): CommunityFeed | null {
const { feed, watch } = useContext(CommunityFeedContext);
useEffect(() => watch(), [watch]);
return feed;
}

View File

@@ -33,12 +33,15 @@ import {
deserializePlaylist, deserializePlaylist,
deserializePublicUser, deserializePublicUser,
} from "../utils/shared/deserialize.ts"; } from "../utils/shared/deserialize.ts";
import { friendlyFetchError, parseAPIResponse } from "../utils/shared/api.ts"; import {
expectOk,
friendlyFetchError,
parseAPIResponse,
} from "../utils/shared/api.ts";
import { useAuth } from "../hooks/auth/useAuth.ts"; import { useAuth } from "../hooks/auth/useAuth.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts"; import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { relativeTime } from "../utils/shared/relativeTime.ts"; import { relativeTime } from "../utils/shared/relativeTime.ts";
import { useHeightTransition } from "../hooks/shared/useHeightTransition.ts";
import { useOnReconnect, useWS, useWSEvent } from "../hooks/ws/useWS.ts"; import { useOnReconnect, useWS, useWSEvent } from "../hooks/ws/useWS.ts";
import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts"; import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts";
import { Avatar } from "../components/shared/Avatar.tsx"; import { Avatar } from "../components/shared/Avatar.tsx";
@@ -52,6 +55,7 @@ import { PageError } from "../components/layout/PageError.tsx";
import { Markdown } from "../components/editor/Markdown.tsx"; import { Markdown } from "../components/editor/Markdown.tsx";
import { CommentThread } from "../components/comment/CommentThread.tsx"; import { CommentThread } from "../components/comment/CommentThread.tsx";
import { Tooltip } from "../components/shared/Tooltip.tsx"; import { Tooltip } from "../components/shared/Tooltip.tsx";
import { InlineEdit } from "../components/form/InlineEdit.tsx";
import { userPath } from "../../shared/paths.ts"; import { userPath } from "../../shared/paths.ts";
import { useCanonicalPath } from "../hooks/shared/useCanonicalPath.ts"; import { useCanonicalPath } from "../hooks/shared/useCanonicalPath.ts";
@@ -67,9 +71,10 @@ type DumpState =
* `DumpEdit` or a title edit here just saved it. Reached by a former name or * `DumpEdit` or a title edit here just saved it. Reached by a former name or
* slug, the URL takes the current ones. Its comments, related dumps and * slug, the URL takes the current ones. Its comments, related dumps and
* playlists come from their own endpoints once it is loaded, and the socket's * playlists come from their own endpoints once it is loaded, and the socket's
* events keep them current; a reconnection fetches them and the dump again. A dump deleted, or made private by its poster, while someone else * events keep them current; a reconnection fetches them and the dump again.
* views it gives way to a notice. The poster and moderators can edit the * A dump deleted, or made private by its poster, while someone else views it
* title in place, and get a link to `DumpEdit`. * gives way to a notice. The poster and moderators can edit the title and the
* note in place, and get a link to `DumpEdit` for the rest.
*/ */
export function Dump(): React.JSX.Element { export function Dump(): React.JSX.Element {
const { handle, slug } = useParams(); const { handle, slug } = useParams();
@@ -129,12 +134,6 @@ export function Dump(): React.JSX.Element {
const [relatedDumps, setRelatedDumps] = useState<Dump[]>([]); const [relatedDumps, setRelatedDumps] = useState<Dump[]>([]);
const [collections, setCollections] = useState<Playlist[]>([]); const [collections, setCollections] = useState<Playlist[]>([]);
const [titleEditing, setTitleEditing] = useState(false);
const headerRightRef = useHeightTransition<HTMLDivElement>(titleEditing);
const [titleDraft, setTitleDraft] = useState("");
const [titleSaving, setTitleSaving] = useState(false);
const [titleError, setTitleError] = useState<string | null>(null);
const { user, mediaToken, authFetch } = useAuth(); const { user, mediaToken, authFetch } = useAuth();
const { const {
voteCounts, voteCounts,
@@ -409,34 +408,17 @@ export function Dump(): React.JSX.Element {
const canEdit = !!user && const canEdit = !!user &&
(dump.userId === user.id || can(user, "dump:moderate")); (dump.userId === user.id || can(user, "dump:moderate"));
const startTitleEdit = () => { // Saves what the title or the note was changed to in place. The page stays
setTitleDraft(dump.title); // mounted across a slug change, and would keep showing the dump it had.
setTitleError(null); const saveDump = async (changes: UpdateDumpRequest) => {
setTitleEditing(true); const updatedDump = deserializeDump(
}; await expectOk<Wire<Dump>>(
await authFetch(`${API_URL}/api/dumps/${dump.id}`, {
const handleTitleSave = async () => {
const trimmed = titleDraft.trim();
if (!trimmed || trimmed.length > VALIDATION.DUMP_TITLE_MAX) return;
setTitleSaving(true);
setTitleError(null);
try {
const res = await authFetch(`${API_URL}/api/dumps/${dump.id}`, {
method: "PUT", method: "PUT",
body: JSON.stringify( body: JSON.stringify(changes),
{ title: trimmed } satisfies UpdateDumpRequest, }),
), ),
}); );
const apiResponse = parseAPIResponse<Wire<Dump>>(await res.json());
if (!apiResponse.success) {
setTitleError(apiResponse.error.message);
return;
}
const updatedDump = deserializeDump(apiResponse.data);
setTitleEditing(false);
// The page stays mounted across the slug change below, and would keep
// the old title.
setDumpState({ status: "loaded", dump: updatedDump }); setDumpState({ status: "loaded", dump: updatedDump });
if (updatedDump.slug !== dump.slug) { if (updatedDump.slug !== dump.slug) {
navigate(dumpUrl(updatedDump), { navigate(dumpUrl(updatedDump), {
@@ -444,11 +426,6 @@ export function Dump(): React.JSX.Element {
state: { dump: updatedDump }, state: { dump: updatedDump },
}); });
} }
} catch (err) {
setTitleError(friendlyFetchError(err));
} finally {
setTitleSaving(false);
}
}; };
return ( return (
@@ -475,79 +452,18 @@ export function Dump(): React.JSX.Element {
</button> </button>
)} )}
</div> </div>
<div className="dump-header-right" ref={headerRightRef}> <div className="dump-header-right">
{titleEditing <InlineEdit
? ( value={dump.title}
<div className="dump-title-editor animate-in"> editable={canEdit}
<input label={t`Edit title`}
type="text" onSave={(title) => saveDump({ title })}
className="dump-title-input" required
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleTitleSave();
} else if (e.key === "Escape") {
setTitleEditing(false);
}
}}
maxLength={VALIDATION.DUMP_TITLE_MAX} maxLength={VALIDATION.DUMP_TITLE_MAX}
disabled={titleSaving} fieldClassName="dump-title"
autoFocus
/>
<div className="dump-title-actions">
<button
type="button"
className="btn-primary"
onClick={handleTitleSave}
disabled={titleSaving || !titleDraft.trim()}
>
{titleSaving
? <Trans>Saving…</Trans>
: <Trans>Save</Trans>}
</button>
<button
type="button"
className="btn-secondary"
onClick={() => setTitleEditing(false)}
disabled={titleSaving}
>
<Trans>Cancel</Trans>
</button>
</div>
{titleError && (
<p className="dump-title-error">{titleError}</p>
)}
</div>
)
: (
<div
className={`dump-title-view${
canEdit ? " dump-title-view--editable" : ""
}`}
onClick={canEdit ? startTitleEdit : undefined}
// Reachable from the keyboard too; not a button, so the
// title inside stays a heading.
tabIndex={canEdit ? 0 : undefined}
title={canEdit ? t`Edit title` : undefined}
onKeyDown={canEdit
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
startTitleEdit();
}
}
: undefined}
> >
<h1 className="dump-title">{dump.title}</h1> <h1 className="dump-title">{dump.title}</h1>
{canEdit && ( </InlineEdit>
<span className="dump-title-edit-btn" aria-hidden>
✎
</span>
)}
</div>
)}
<div className="dump-op"> <div className="dump-op">
<Avatar <Avatar
userId={dump.userId} userId={dump.userId}
@@ -587,8 +503,25 @@ export function Dump(): React.JSX.Element {
</div> </div>
</div> </div>
{dump.comment && ( {(dump.comment || canEdit) && (
<Markdown className="dump-comment">{dump.comment}</Markdown> <InlineEdit
value={dump.comment ?? ""}
editable={canEdit}
label={t`Edit note`}
onSave={(comment) => saveDump({ comment: comment || null })}
kind="markdown"
maxLength={VALIDATION.DUMP_COMMENT_MAX}
placeholder={t`What makes it worth it?`}
>
{dump.comment
? <Markdown className="dump-comment">{dump.comment}</Markdown>
: (
<p className="inline-edit-empty">
<Trans>What makes it worth it?</Trans>
</p>
)}
</InlineEdit>
)} )}
</div> </div>

View File

@@ -190,7 +190,7 @@ export function Index(
<div className="header-center-slot"> <div className="header-center-slot">
<PresenceRow <PresenceRow
fitWithin=".app-header-center" fitWithin=".app-header-center"
fitBeside=".feed-sort" fitBeside=".feed-sort-scroller"
/> />
<FeedTabBar <FeedTabBar
categorySlug={categorySlug} categorySlug={categorySlug}
@@ -209,7 +209,10 @@ export function Index(
tab={tab} tab={tab}
followView={followView} followView={followView}
/> />
<PresenceRow fitWithin=".index-below-header" fitBeside=".feed-sort" /> <PresenceRow
fitWithin=".index-below-header"
fitBeside=".feed-sort-scroller"
/>
</div> </div>
{(tab === "hot" || tab === "new") && ( {(tab === "hot" || tab === "new") && (

View File

@@ -36,7 +36,8 @@ export interface PlaylistDetailProps {
* a private one only to its owner. `usePlaylist` loads it, by a former name or * a private one only to its owner. `usePlaylist` loads it, by a former name or
* slug too, and keeps it live. Its owner, or a user with `playlist:moderate`, * slug too, and keeps it live. Its owner, or a user with `playlist:moderate`,
* can drag its dumps into a new order and remove one, with a short window to * can drag its dumps into a new order and remove one, with a short window to
* undo, and edit or delete it in the editor that opens over its header at * undo, edit its title and description in place, and edit the rest or delete
* it in the editor that opens over its header at
* `/@:username/playlists/:slug/edit`. Both paths render the page at the same * `/@:username/playlists/:slug/edit`. Both paths render the page at the same
* place, so opening the editor keeps the playlist loaded. Anyone else there * place, so opening the editor keeps the playlist loaded. Anyone else there
* gets the page, or is asked to sign in first. * gets the page, or is asked to sign in first.
@@ -143,9 +144,10 @@ export function PlaylistDetail(
<div className="playlist-detail-header-top"> <div className="playlist-detail-header-top">
<PlaylistHeader <PlaylistHeader
playlist={playlist} playlist={playlist}
onEdit={canEdit {...(canEdit && {
? () => navigate(playlistUrl(playlist, "edit")) onEdit: () => navigate(playlistUrl(playlist, "edit")),
: undefined} onSaved: applyDetails,
})}
/> />
</div> </div>
)} )}

74
src/test/App.test.tsx Normal file
View File

@@ -0,0 +1,74 @@
import { describe, expect, test } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import { route } from "./fetch.ts";
import { makeCategory, makeSession, page } from "./fixtures.ts";
import { currentPath, renderPage } from "./render.tsx";
const music = makeCategory({ id: "c-music", slug: "music", name: "Music" });
const next = (path: string) => `/login?next=${encodeURIComponent(path)}`;
function open(url: string, session: ReturnType<typeof makeSession> | null) {
route("GET /api/dumps", () => page([]));
renderPage(url, { session, categories: [music] });
}
describe("App's routes", () => {
test.each([
"/",
"/hot",
"/new",
"/journal",
"/follow",
"/follow/playlists",
"/music",
"/music/new",
"/music/follow",
"/music/follow/playlists",
])("%s is a feed", async (url) => {
open(url, makeSession());
// The tab bar, which the feed draws at two widths.
expect(await screen.findAllByRole("button", { name: "Followed" })).not
.toHaveLength(0);
expect(currentPath()).toBe(url);
expect(screen.queryByRole("heading", { name: "404" })).toBeNull();
});
test.each([
"/hot/more",
"/follow/users",
"/follow/nope",
"/follow/playlists/more",
"/nope",
"/music/nope",
"/music/hot/more",
"/music/follow/users",
"/search/users/more",
])("%s is not found", async (url) => {
open(url, makeSession());
expect(await screen.findByRole("heading", { name: "404" }))
.toBeInTheDocument();
});
test.each([
"/follow",
"/follow/playlists",
"/music/follow/playlists",
"/notifications",
"/settings",
"/manage",
"/community",
"/community/monthly",
"/@ann/dumps/a-find/edit",
])("%s sends a guest to sign in, and back", async (url) => {
open(url, null);
await waitFor(() => expect(currentPath()).toBe(next(url)));
});
test.each(["/login", "/register"])(
"%s sends a signed-in user home",
async (url) => {
open(url, makeSession());
await waitFor(() => expect(currentPath()).toBe("/"));
},
);
});

8
src/test/before-react.ts Normal file
View File

@@ -0,0 +1,8 @@
// Runs before every test file, and before setup.ts, whose imports load React:
// what jsdom leaves out that React looks for as it loads.
// Finding no AnimationEvent, React listens for the animation events under
// their old WebKit names, and `fireEvent.animationIteration` reaches no
// handler.
(globalThis as { AnimationEvent?: unknown }).AnimationEvent ??=
class AnimationEvent extends Event {};

View File

@@ -191,6 +191,21 @@ describe("CommentThread", () => {
expect(calls("PATCH /api/comments/:id")).toHaveLength(0); expect(calls("PATCH /api/comments/:id")).toHaveLength(0);
}); });
test("a refused edit stays open and says why; Escape goes back to Edit", async () => {
route("PATCH /api/comments/:id", () => fail("Too spicy", 400));
const user = setup([makeComment({ id: "a", userId: "me", body: "old" })]);
await user.click(screen.getByRole("button", { name: "Edit" }));
await user.type(
screen.getByDisplayValue("old"),
"er{Control>}{Enter}{/Control}",
);
expect(await screen.findByRole("alert")).toHaveTextContent("Too spicy");
expect(screen.getByDisplayValue("older")).toBeInTheDocument();
await user.keyboard("{Escape}");
expect(screen.getByText("old")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Edit" })).toHaveFocus();
});
test("only the author or a moderator can edit and delete", () => { test("only the author or a moderator can edit and delete", () => {
const comments = [ const comments = [
makeComment({ id: "mine", userId: "me", body: "mine" }), makeComment({ id: "mine", userId: "me", body: "mine" }),

View File

@@ -0,0 +1,88 @@
import { describe, expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { I18nProvider } from "@lingui/react";
import { i18n } from "@lingui/core";
import { InlineEdit } from "../../../components/form/InlineEdit.tsx";
function renderEdit(
props: Partial<React.ComponentProps<typeof InlineEdit>> = {},
) {
const onSave = vi.fn(async (_: string) => {});
const user = userEvent.setup();
render(
<I18nProvider i18n={i18n}>
<InlineEdit
value="Old"
editable
label="Edit name"
onSave={onSave}
required
{...props}
>
<h2>
Old <a href="#elsewhere">link</a>
</h2>
</InlineEdit>
</I18nProvider>,
);
return { user, onSave };
}
describe("InlineEdit", () => {
test("without the right to edit, only the text shows", () => {
renderEdit({ editable: false });
expect(screen.getByRole("heading")).toBeInTheDocument();
expect(screen.queryByRole("button")).toBeNull();
});
test("a click on the text opens the field; Enter saves it trimmed", async () => {
const { user, onSave } = renderEdit();
await user.click(screen.getByRole("heading"));
const input = screen.getByRole("textbox", { name: "Edit name" });
expect(input).toHaveFocus();
await user.type(input, "er {Enter}");
expect(onSave).toHaveBeenCalledWith("Older");
expect(screen.getByRole("heading")).toBeInTheDocument();
});
test("a link in the text is followed, not edited", async () => {
const { user } = renderEdit();
await user.click(screen.getByRole("link", { name: "link" }));
expect(screen.queryByRole("textbox")).toBeNull();
});
test("left as it was, nothing is sent", async () => {
const { user, onSave } = renderEdit();
await user.click(screen.getByRole("button", { name: "Edit name" }));
await user.click(screen.getByRole("button", { name: "Save" }));
expect(onSave).not.toHaveBeenCalled();
expect(screen.queryByRole("textbox")).toBeNull();
});
test("an empty required text can't be saved", async () => {
const { user } = renderEdit();
await user.click(screen.getByRole("button", { name: "Edit name" }));
await user.clear(screen.getByRole("textbox"));
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
});
test("Escape backs out, and the focus goes back to the ✎", async () => {
const { user, onSave } = renderEdit();
screen.getByRole("button", { name: "Edit name" }).focus();
await user.keyboard("{Enter}");
await user.type(screen.getByRole("textbox"), "x{Escape}");
expect(onSave).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "Edit name" })).toHaveFocus();
});
test("a failed save stays open and says why", async () => {
const { user } = renderEdit({
onSave: () => Promise.reject(new Error("Taken")),
});
await user.click(screen.getByRole("heading"));
await user.type(screen.getByRole("textbox"), "!{Enter}");
expect(await screen.findByRole("alert")).toHaveTextContent("Taken");
expect(screen.getByRole("textbox")).toHaveValue("Old!");
});
});

View File

@@ -0,0 +1,105 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { fireEvent, screen } from "@testing-library/react";
import type { CommunityFeed } from "../../../../shared/community.ts";
import { Marquee } from "../../../components/layout/Marquee.tsx";
import { CommunityFeedContext } from "../../../contexts/social/CommunityFeedContext.ts";
import { ThemeContext } from "../../../contexts/theme/ThemeContext.ts";
import type { StyleName } from "../../../../shared/theme.ts";
import { makeCommunityFeed, makeSession } from "../../fixtures.ts";
import { renderWithContexts } from "../../render.tsx";
const joined = (username: string) =>
makeCommunityFeed({
events: [{
id: `user_joined:${username}`,
at: new Date(),
type: "user_joined",
username,
}],
});
function marquee(feed: CommunityFeed | null, style: StyleName = "geocities") {
return (
<ThemeContext.Provider
value={{
style,
colorScheme: "light",
setStyle() {},
setColorScheme() {},
}}
>
<CommunityFeedContext.Provider value={{ feed, watch: () => () => {} }}>
<Marquee />
</CommunityFeedContext.Provider>
</ThemeContext.Provider>
);
}
const session = makeSession({ username: "me" });
const track = () => document.querySelector(".site-marquee-track");
afterEach(() => vi.restoreAllMocks());
describe("Marquee", () => {
test("draws nothing in another style, nor for a visitor", () => {
const band = () => document.querySelector(".site-marquee");
const { rerender, unmount } = renderWithContexts(
marquee(joined("ann"), "smooth"),
{ session },
);
expect(band()).toBeNull();
rerender(marquee(joined("ann")));
expect(band()).toBeInTheDocument();
unmount();
renderWithContexts(marquee(joined("ann")));
expect(band()).toBeNull();
});
test("an empty band until the feed is loaded, then the feed twice over", () => {
const { rerender } = renderWithContexts(marquee(null), { session });
expect(document.querySelector(".site-marquee")).toBeEmptyDOMElement();
rerender(marquee(joined("ann")));
// The second copy is the loop's: neither read out nor tabbed to.
expect(screen.getAllByRole("link", { name: "ann" })).toHaveLength(1);
const [first, second] = [...document.querySelectorAll("a[href='/@ann']")];
expect(first).not.toHaveAttribute("tabindex");
expect(second).toHaveAttribute("tabindex", "-1");
expect(second.closest("[aria-hidden='true']")).toBeInTheDocument();
});
test("what changes mid-crawl waits for the next loop", () => {
const { rerender } = renderWithContexts(marquee(joined("ann")), {
session,
ws: { onlineUsers: [] },
});
rerender(marquee(joined("bob")));
expect(screen.getByRole("link", { name: "ann" })).toBeInTheDocument();
expect(screen.queryByRole("link", { name: "bob" })).toBeNull();
const before = track()!;
fireEvent.animationIteration(before);
expect(screen.getByRole("link", { name: "bob" })).toBeInTheDocument();
expect(screen.queryByRole("link", { name: "ann" })).toBeNull();
// A new loop is a new track, whose animation starts over.
expect(track()).not.toBe(before);
});
test("with reduced motion, the line shows once and follows the feed", () => {
vi.spyOn(globalThis, "matchMedia").mockImplementation((query) =>
({
matches: query.includes("reduce"),
media: query,
addEventListener() {},
removeEventListener() {},
}) as unknown as MediaQueryList
);
const { rerender } = renderWithContexts(marquee(joined("ann")), {
session,
});
expect(document.querySelectorAll("a[href='/@ann']")).toHaveLength(1);
rerender(marquee(joined("bob")));
expect(screen.getByRole("link", { name: "bob" })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,118 @@
import { describe, expect, test } from "vitest";
import { screen, within } from "@testing-library/react";
import { CommunityFeed } from "../../../components/social/CommunityFeed.tsx";
import { makeCommunityFeed } from "../../fixtures.ts";
import { renderWithContexts } from "../../render.tsx";
const at = new Date(Date.now() - 2 * 3_600_000);
const hit = { title: "Big hit", slug: "big-hit", ownerUsername: "ann" };
const online = (...names: string[]) =>
names.map((username) => ({ userId: username, username, hasAvatar: false }));
describe("CommunityFeed", () => {
test("a line for who is online, the week and its top dump, then one per event", () => {
renderWithContexts(
<CommunityFeed
online={online("ann", "bob")}
feed={makeCommunityFeed({
week: {
totals: { dumps: 1284, upvotes: 1, comments: 0 },
chatMessages: 3,
top: hit,
},
events: [
{ id: "a", at, type: "dump_posted", username: "ann", dump: hit },
{ id: "b", at, type: "dump_upvoted", username: "bob", dump: hit },
{
id: "c",
at,
type: "dump_commented",
username: "cy",
dump: hit,
commentId: "c9",
},
{
id: "d",
at,
type: "playlist_created",
username: "dee",
playlist: { title: "Mix", slug: "mix", ownerUsername: "dee" },
},
{
id: "e",
at,
type: "user_joined",
username: "eve",
invitedByUsername: "ann",
},
{ id: "f", at, type: "user_joined", username: "fay" },
],
})}
/>,
);
const lines = within(screen.getByRole("list", { name: "Community" }))
.getAllByRole("listitem").map((li) => li.textContent);
expect(lines).toEqual([
"Online now: ann, bob",
// A figure at zero is left out.
"This week: 1,284 dumps, 1 upvote, 3 chat messages",
"Top this week: Big hit",
"ann posted Big hit 2 hours ago",
"bob upvoted Big hit 2 hours ago",
"cy commented on Big hit 2 hours ago",
"dee created the playlist Mix 2 hours ago",
"eve joined, invited by ann 2 hours ago",
"fay joined 2 hours ago",
]);
const href = (name: string, n = 0) =>
screen.getAllByRole("link", { name })[n].getAttribute("href");
expect(href("bob")).toBe("/@bob");
expect(href("1,284 dumps, 1 upvote, 3 chat messages")).toBe("/community");
expect(href("Big hit")).toBe("/@ann/dumps/big-hit");
// The comment's line leads to the comment.
expect(href("Big hit", 3)).toBe("/@ann/dumps/big-hit#comment-c9");
expect(href("Mix")).toBe("/@dee/playlists/mix");
});
test("a line with nothing to say is left out", () => {
renderWithContexts(
<CommunityFeed online={[]} feed={makeCommunityFeed()} />,
);
expect(screen.queryAllByRole("listitem")).toEqual([]);
});
test("past eight online, the others are counted", () => {
renderWithContexts(
<CommunityFeed
online={online("a", "b", "c", "d", "e", "f", "g", "h", "i", "j")}
feed={makeCommunityFeed()}
/>,
);
expect(screen.getByRole("listitem")).toHaveTextContent(
"Online now: a, b, c, d, e, f, g, h +2",
);
});
test("tabIndex goes to every link", () => {
renderWithContexts(
<CommunityFeed
tabIndex={-1}
online={online("ann")}
feed={makeCommunityFeed({
week: {
totals: { dumps: 1, upvotes: 0, comments: 0 },
chatMessages: 0,
top: hit,
},
events: [
{ id: "a", at, type: "dump_posted", username: "ann", dump: hit },
],
})}
/>,
);
const links = screen.getAllByRole("link");
expect(links).toHaveLength(5);
for (const link of links) expect(link).toHaveAttribute("tabindex", "-1");
});
});

View File

@@ -0,0 +1,138 @@
import { describe, expect, test, vi } from "vitest";
import { act, render, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { AuthProvider } from "../../../contexts/auth/AuthProvider.tsx";
import { CommunityFeedProvider } from "../../../contexts/social/CommunityFeedProvider.tsx";
import { useCommunityFeed } from "../../../hooks/social/useCommunityFeed.ts";
import { calls, route } from "../../fetch.ts";
import {
makeCommunityFeed,
makeDump,
makeSession,
storeSession,
wire,
} from "../../fixtures.ts";
import { liveWS } from "../../live.tsx";
const FEED = "GET /api/community/feed";
const joined = (username: string) =>
wire(makeCommunityFeed({
events: [{
id: `user_joined:${username}`,
at: new Date("2026-10-01T00:00:00Z"),
type: "user_joined",
username,
}],
}));
function setup({ signedIn = true } = {}) {
if (signedIn) {
const session = makeSession();
storeSession(session);
route("GET /api/me", () => wire(session.user));
}
const ws = liveWS();
const wrapper = ({ children }: { children: ReactNode }) => (
<AuthProvider>
<ws.Wrapper>
<CommunityFeedProvider>{children}</CommunityFeedProvider>
</ws.Wrapper>
</AuthProvider>
);
return { ws, wrapper };
}
describe("CommunityFeedProvider", () => {
test("loads the feed for a component that shows it", async () => {
route(FEED, () => joined("ann"));
const { wrapper } = setup();
const { result } = renderHook(useCommunityFeed, { wrapper });
expect(result.current).toBeNull();
await waitFor(() => expect(result.current).not.toBeNull());
expect(result.current!.events[0].username).toBe("ann");
expect(result.current!.events[0].at).toEqual(
new Date("2026-10-01T00:00:00Z"),
);
expect(calls(FEED)[0].headers.get("Authorization")).toMatch(/^Bearer /);
});
test("asks for nothing while no component shows it", async () => {
route(FEED, () => joined("ann"));
const { wrapper: Wrapper } = setup();
render(<Wrapper>a page that shows no feed</Wrapper>);
await act(async () => {});
expect(calls(FEED)).toHaveLength(0);
});
test("a visitor has no feed, and never asks", async () => {
route(FEED, () => joined("ann"));
const { wrapper } = setup({ signedIn: false });
const { result } = renderHook(useCommunityFeed, { wrapper });
await act(async () => {});
expect(result.current).toBeNull();
expect(calls(FEED)).toHaveLength(0);
});
test("fetches it again once the socket's changes have settled", async () => {
let name = "ann";
route(FEED, () => joined(name));
const { ws, wrapper } = setup();
const { result } = renderHook(useCommunityFeed, { wrapper });
await waitFor(() => expect(result.current).not.toBeNull());
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
try {
name = "bob";
ws.emit("vote", { dumpId: "d", voterId: "u", action: "cast" });
act(() => vi.advanceTimersByTime(1500));
ws.emit("dump", { type: "created", dump: makeDump() });
act(() => vi.advanceTimersByTime(1500));
// The second change put the fetch off.
expect(calls(FEED)).toHaveLength(1);
act(() => vi.advanceTimersByTime(500));
expect(calls(FEED)).toHaveLength(2);
// What changes no line of the feed fetches nothing.
ws.emit("comment", { type: "updated", dumpId: "d" });
ws.emit("playlist", { type: "dumps_updated", playlistId: "p" });
act(() => vi.advanceTimersByTime(5000));
expect(calls(FEED)).toHaveLength(2);
// A deleted dump and a reconnection do.
ws.set({ deletedDumpIds: new Set(["d"]) });
act(() => vi.advanceTimersByTime(2000));
expect(calls(FEED)).toHaveLength(3);
ws.set({ connectionEpoch: 1 });
act(() => vi.advanceTimersByTime(2000));
expect(calls(FEED)).toHaveLength(4);
} finally {
vi.useRealTimers();
}
await waitFor(() => expect(result.current!.events[0].username).toBe("bob"));
});
test("a change that came while nothing showed the feed is caught up with", async () => {
route(FEED, () => joined("ann"));
const { ws, wrapper } = setup();
// The provider stays, as it does under the pages: the consumer comes and
// goes.
const Show = () => {
useCommunityFeed();
return null;
};
const view = render(<Show />, { wrapper });
await waitFor(() => expect(calls(FEED)).toHaveLength(1));
view.rerender(null);
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
try {
ws.emit("vote", { dumpId: "d", voterId: "u", action: "cast" });
act(() => vi.advanceTimersByTime(5000));
expect(calls(FEED)).toHaveLength(1);
} finally {
vi.useRealTimers();
}
view.rerender(<Show />);
await waitFor(() => expect(calls(FEED)).toHaveLength(2));
});
});

View File

@@ -3,6 +3,7 @@ import type { Session } from "../utils/auth/storage.ts";
import type { Category } from "../../shared/category.ts"; import type { Category } from "../../shared/category.ts";
import type { ChatMessage } from "../../shared/chat.ts"; import type { ChatMessage } from "../../shared/chat.ts";
import type { Comment } from "../../shared/comment.ts"; import type { Comment } from "../../shared/comment.ts";
import type { CommunityFeed } from "../../shared/community.ts";
import type { Dump } from "../../shared/dump.ts"; import type { Dump } from "../../shared/dump.ts";
import type { Notification } from "../../shared/notification.ts"; import type { Notification } from "../../shared/notification.ts";
import type { Playlist } from "../../shared/playlist.ts"; import type { Playlist } from "../../shared/playlist.ts";
@@ -142,6 +143,17 @@ export function makeNotification(
}; };
} }
/** A feed with nothing in it: a test gives it the lines it reads. */
export function makeCommunityFeed(
over: Partial<CommunityFeed> = {},
): CommunityFeed {
return {
events: [],
week: { totals: { dumps: 0, upvotes: 0, comments: 0 }, chatMessages: 0 },
...over,
};
}
/** A value as it crosses the wire: dates become ISO strings. */ /** A value as it crosses the wire: dates become ISO strings. */
export function wire<T>(value: T): T { export function wire<T>(value: T): T {
return JSON.parse(JSON.stringify(value)); return JSON.parse(JSON.stringify(value));

View File

@@ -1,11 +1,9 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { screen, waitFor } from "@testing-library/react"; import { screen, waitFor } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event"; import { userEvent } from "@testing-library/user-event";
import { Route } from "react-router";
import { Authorize } from "../../pages/Authorize.tsx";
import { calls, fail, route } from "../fetch.ts"; import { calls, fail, route } from "../fetch.ts";
import { makeSession } from "../fixtures.ts"; import { makeSession } from "../fixtures.ts";
import { currentPath, renderApp } from "../render.tsx"; import { currentPath, renderPage } from "../render.tsx";
const QUERY = const QUERY =
"?response_type=code&client_id=https%3A%2F%2Fmusic.example&redirect_uri=https%3A%2F%2Fmusic.example%2Ffederation%2Fcallback&state=s1&code_challenge=c1&code_challenge_method=S256&nonce=n1"; "?response_type=code&client_id=https%3A%2F%2Fmusic.example&redirect_uri=https%3A%2F%2Fmusic.example%2Ffederation%2Fcallback&state=s1&code_challenge=c1&code_challenge_method=S256&nonce=n1";
@@ -24,12 +22,7 @@ describe("Authorize", () => {
function open(session = ann) { function open(session = ann) {
const user = userEvent.setup(); const user = userEvent.setup();
renderApp(<Authorize />, { renderPage(`/authorize${QUERY}`, { session });
route: `/authorize${QUERY}`,
path: "/authorize",
session,
extraRoutes: <Route path="/login" element={<p>login page</p>} />,
});
return user; return user;
} }
@@ -78,12 +71,8 @@ describe("Authorize", () => {
}); });
test("a signed-out visitor logs in first and comes back", async () => { test("a signed-out visitor logs in first and comes back", async () => {
renderApp(<Authorize />, { renderPage(`/authorize${QUERY}`);
route: `/authorize${QUERY}`, await screen.findByRole("heading", { name: "Log in" });
path: "/authorize",
extraRoutes: <Route path="/login" element={<p>login page</p>} />,
});
await screen.findByText("login page");
expect(currentPath()).toBe( expect(currentPath()).toBe(
`/login?next=${encodeURIComponent(`/authorize${QUERY}`)}`, `/login?next=${encodeURIComponent(`/authorize${QUERY}`)}`,
); );

View File

@@ -1,7 +1,6 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { screen } from "@testing-library/react"; import { screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event"; import { userEvent } from "@testing-library/user-event";
import { Community } from "../../pages/Community.tsx";
import { calls, route } from "../fetch.ts"; import { calls, route } from "../fetch.ts";
import { import {
makeDump, makeDump,
@@ -10,7 +9,7 @@ import {
makeUser, makeUser,
wire, wire,
} from "../fixtures.ts"; } from "../fixtures.ts";
import { currentPath, renderApp } from "../render.tsx"; import { currentPath, renderPage } from "../render.tsx";
function stats( function stats(
{ {
@@ -35,7 +34,7 @@ function stats(
function open(url: string) { function open(url: string) {
const user = userEvent.setup(); const user = userEvent.setup();
const session = makeSession({ username: "ann" }); const session = makeSession({ username: "ann" });
renderApp(<Community />, { route: url, path: "/community/*", session }); renderPage(url, { session });
return user; return user;
} }

View File

@@ -1,10 +1,6 @@
import { describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import { act, screen, waitFor, within } from "@testing-library/react"; import { act, screen, waitFor, within } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event"; import { userEvent } from "@testing-library/user-event";
import { Link, Route } from "react-router";
import { Dump } from "../../pages/Dump.tsx";
import { DumpEdit } from "../../pages/DumpEdit.tsx";
import { RestrictedLoggedIn } from "../../pages/RestrictedLoggedIn.tsx";
import { calls, fail, route } from "../fetch.ts"; import { calls, fail, route } from "../fetch.ts";
import { import {
makeCategory, makeCategory,
@@ -15,7 +11,7 @@ import {
makeUser, makeUser,
wire, wire,
} from "../fixtures.ts"; } from "../fixtures.ts";
import { currentPath, renderApp } from "../render.tsx"; import { currentPath, renderPage } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts"; import { FakeWebSocket } from "../ws.ts";
const owner = makeSession({ id: "owner", username: "owner" }); const owner = makeSession({ id: "owner", username: "owner" });
@@ -48,15 +44,7 @@ function openDump(
url = "/@owner/dumps/a-find", url = "/@owner/dumps/a-find",
) { ) {
const user = userEvent.setup(); const user = userEvent.setup();
renderApp(<Dump />, { renderPage(url, { session, categories: [music] });
route: url,
path: "/:handle/dumps/:slug",
session,
categories: [music],
extraRoutes: (
<Route path="/:handle/dumps/:slug/edit" element={<p>edit page</p>} />
),
});
const socket = FakeWebSocket.last; const socket = FakeWebSocket.last;
socket.welcome(); socket.welcome();
return { user, socket }; return { user, socket };
@@ -147,6 +135,35 @@ describe("Dump page", () => {
expect(screen.getByRole("heading", { name: "A find" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "A find" })).toBeInTheDocument();
}); });
test("the owner writes the note in place; emptied, it goes", async () => {
dumpRoutes();
route(
"PUT /api/dumps/:id",
() =>
wire(makeDump({
id: "d1",
slug: "a-find",
title: "A find",
userId: "owner",
comment: undefined,
})),
);
const { user } = openDump(owner);
await user.click(await screen.findByText("reasons"));
await user.clear(screen.getByDisplayValue("Because **reasons**"));
await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByText("What makes it worth it?"))
.toBeInTheDocument();
expect(calls("PUT /api/dumps/:id")[0].body).toEqual({ comment: null });
});
test("a visitor sees no note where there is none", async () => {
dumpRoutes(makeDump({ slug: "a-find", title: "A find", userId: "owner" }));
openDump(other);
await screen.findByRole("heading", { name: "A find" });
expect(screen.queryByText("What makes it worth it?")).toBeNull();
});
test("others can't rename or edit it", async () => { test("others can't rename or edit it", async () => {
dumpRoutes(); dumpRoutes();
const { user } = openDump(other); const { user } = openDump(other);
@@ -160,14 +177,16 @@ describe("Dump page", () => {
dumpRoutes(); dumpRoutes();
const { user } = openDump(owner); const { user } = openDump(owner);
await user.click(await screen.findByRole("link", { name: "Edit" })); await user.click(await screen.findByRole("link", { name: "Edit" }));
expect(currentPath()).toBe("/@owner/dumps/a-find/edit"); await waitFor(() =>
expect(currentPath()).toBe("/@owner/dumps/a-find/edit")
);
}); });
test("the title can be edited from the keyboard", async () => { test("the title can be edited from the keyboard", async () => {
dumpRoutes(); dumpRoutes();
const { user } = openDump(owner); const { user } = openDump(owner);
await screen.findByRole("heading", { name: "A find" }); await screen.findByRole("heading", { name: "A find" });
screen.getByTitle("Edit title").focus(); screen.getByRole("button", { name: "Edit title" }).focus();
await user.keyboard("{Enter}"); await user.keyboard("{Enter}");
expect(screen.getByDisplayValue("A find")).toBeInTheDocument(); expect(screen.getByDisplayValue("A find")).toBeInTheDocument();
}); });
@@ -268,20 +287,9 @@ describe("Dump page", () => {
({ params }) => wire([params.id === first.id ? second : first]), ({ params }) => wire([params.id === first.id ? second : first]),
); );
const user = userEvent.setup(); const user = userEvent.setup();
renderApp(<Dump />, { // Arrived from a card, which hands its dump over with the link.
route: "/start", renderPage({ pathname: "/@owner/dumps/a-find", state: { dump: first } });
path: "/:handle/dumps/:slug",
extraRoutes: (
<Route
path="/start"
element={
<Link to="/@owner/dumps/a-find" state={{ dump: first }}>open</Link>
}
/>
),
});
FakeWebSocket.last.welcome(); FakeWebSocket.last.welcome();
await user.click(screen.getByRole("link", { name: "open" }));
await user.click(await screen.findByRole("link", { name: "Next find" })); await user.click(await screen.findByRole("link", { name: "Next find" }));
await user.click(await screen.findByRole("link", { name: "A find" })); await user.click(await screen.findByRole("link", { name: "A find" }));
expect(await screen.findByRole("heading", { name: "A find", level: 1 })) expect(await screen.findByRole("heading", { name: "A find", level: 1 }))
@@ -455,27 +463,18 @@ describe("DumpEdit page", () => {
function openEdit(dump: ReturnType<typeof makeDump>, session = owner) { function openEdit(dump: ReturnType<typeof makeDump>, session = owner) {
route("GET /api/users/:u/dumps/:slug", () => wire(dump)); route("GET /api/users/:u/dumps/:slug", () => wire(dump));
const user = userEvent.setup(); const user = userEvent.setup();
renderApp( renderPage(`/@owner/dumps/${dump.slug}/edit`, {
<RestrictedLoggedIn>
<DumpEdit />
</RestrictedLoggedIn>,
{
route: `/@owner/dumps/${dump.slug}/edit`,
path: "/:handle/dumps/:slug/edit",
session, session,
categories: [music], categories: [music],
extraRoutes: ( });
<>
<Route path="/:handle/dumps/:slug" element={<p>dump page</p>} />
<Route path="/" element={<p>home</p>} />
</>
),
},
);
FakeWebSocket.last.welcome(); FakeWebSocket.last.welcome();
return user; return user;
} }
/** Waits for the editor to have handed over to a dump's page. */
const onDumpPage = () =>
waitFor(() => expect(currentPath()).toMatch(/^\/@[^/]+\/dumps\/[^/]+$/));
const urlDump = () => const urlDump = () =>
makeDump({ makeDump({
id: "d1", id: "d1",
@@ -515,7 +514,7 @@ describe("DumpEdit page", () => {
await user.click(screen.getByRole("button", { name: "Music" })); await user.click(screen.getByRole("button", { name: "Music" }));
await user.click(screen.getByRole("button", { name: "Private" })); await user.click(screen.getByRole("button", { name: "Private" }));
await user.click(screen.getByRole("button", { name: "Save" })); await user.click(screen.getByRole("button", { name: "Save" }));
await screen.findByText("dump page"); await onDumpPage();
expect(currentPath()).toBe("/@owner/dumps/new-title"); expect(currentPath()).toBe("/@owner/dumps/new-title");
expect(calls("PUT /api/dumps/:id")[0].body).toEqual({ expect(calls("PUT /api/dumps/:id")[0].body).toEqual({
url: "https://a.test/", url: "https://a.test/",
@@ -542,7 +541,7 @@ describe("DumpEdit page", () => {
await user.type(title, "A Find!"); await user.type(title, "A Find!");
expect(link).toHaveValue("a-find"); expect(link).toHaveValue("a-find");
await user.click(screen.getByRole("button", { name: "Save" })); await user.click(screen.getByRole("button", { name: "Save" }));
await screen.findByText("dump page"); await onDumpPage();
expect(calls("PUT /api/dumps/:id")[0].body).not.toHaveProperty("slug"); expect(calls("PUT /api/dumps/:id")[0].body).not.toHaveProperty("slug");
}); });
@@ -572,7 +571,7 @@ describe("DumpEdit page", () => {
const user = openEdit(urlDump()); const user = openEdit(urlDump());
await user.clear(await screen.findByLabelText("Why?")); await user.clear(await screen.findByLabelText("Why?"));
await user.click(screen.getByRole("button", { name: "Save" })); await user.click(screen.getByRole("button", { name: "Save" }));
await screen.findByText("dump page"); await onDumpPage();
expect(calls("PUT /api/dumps/:id")[0].body).toHaveProperty("comment", null); expect(calls("PUT /api/dumps/:id")[0].body).toHaveProperty("comment", null);
}); });
@@ -642,7 +641,7 @@ describe("DumpEdit page", () => {
); );
await user.click(screen.getByRole("button", { name: "Private" })); await user.click(screen.getByRole("button", { name: "Private" }));
await user.click(screen.getByRole("button", { name: "Save" })); await user.click(screen.getByRole("button", { name: "Save" }));
await screen.findByText("dump page"); await onDumpPage();
const [put] = calls("PUT /api/dumps/:id/file"); const [put] = calls("PUT /api/dumps/:id/file");
const form = put.body as FormData; const form = put.body as FormData;
expect(form.get("file")).toBeInstanceOf(File); expect(form.get("file")).toBeInstanceOf(File);
@@ -676,7 +675,7 @@ describe("DumpEdit page", () => {
await user.click(screen.getByRole("button", { name: "Save" })); await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByText("File too large (max 50 MB)")) expect(await screen.findByText("File too large (max 50 MB)"))
.toBeInTheDocument(); .toBeInTheDocument();
expect(screen.queryByText("dump page")).not.toBeInTheDocument(); expect(currentPath()).toBe(`/@owner/dumps/${fileDump.slug}/edit`);
expect(calls("PUT /api/dumps/:id/file")).toHaveLength(1); expect(calls("PUT /api/dumps/:id/file")).toHaveLength(1);
expect(calls("PUT /api/dumps/:id")).toHaveLength(0); expect(calls("PUT /api/dumps/:id")).toHaveLength(0);
}); });
@@ -719,7 +718,7 @@ describe("DumpEdit page", () => {
name: "Delete dump", name: "Delete dump",
}), }),
); );
await screen.findByText("home"); await waitFor(() => expect(currentPath()).toBe("/"));
expect( expect(
JSON.parse( JSON.parse(
document.querySelector('[data-testid="location"]')!.getAttribute( document.querySelector('[data-testid="location"]')!.getAttribute(
@@ -760,17 +759,12 @@ describe("DumpEdit page", () => {
expect(await screen.findByText("Not an image")).toBeInTheDocument(); expect(await screen.findByText("Not an image")).toBeInTheDocument();
}); });
test("guests are sent to log in", () => { test("guests are sent to log in, and back", async () => {
renderApp( renderPage("/@owner/dumps/x/edit");
<RestrictedLoggedIn> await waitFor(() =>
<DumpEdit /> expect(currentPath()).toBe(
</RestrictedLoggedIn>, `/login?next=${encodeURIComponent("/@owner/dumps/x/edit")}`,
{ )
route: "/@owner/dumps/x/edit",
path: "/:handle/dumps/:slug/edit",
extraRoutes: <Route path="/login" element={<p>login page</p>} />,
},
); );
expect(screen.getByText("login page")).toBeInTheDocument();
}); });
}); });

View File

@@ -1,20 +1,14 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { screen } from "@testing-library/react"; import { screen, waitFor } from "@testing-library/react";
import { Route } from "react-router";
import { FederationCallback } from "../../pages/FederationCallback.tsx";
import { AUTH_STORAGE_KEY } from "../../utils/auth/storage.ts"; import { AUTH_STORAGE_KEY } from "../../utils/auth/storage.ts";
import { calls, fail, route } from "../fetch.ts"; import { calls, fail, route } from "../fetch.ts";
import { makeSession, wire } from "../fixtures.ts"; import { makeSession, page, wire } from "../fixtures.ts";
import { renderApp } from "../render.tsx"; import { currentPath, renderPage } from "../render.tsx";
/** Opens the callback, in a tab that started the sign-in of state `s`. */ /** Opens the callback, in a tab that started the sign-in of state `s`. */
function open(query: string, started = "s") { function open(query: string, started = "s") {
sessionStorage.setItem("federationState", started); sessionStorage.setItem("federationState", started);
renderApp(<FederationCallback />, { renderPage(`/federation/callback${query}`);
route: `/federation/callback${query}`,
path: "/federation/callback",
extraRoutes: <Route path="/" element={<p>home page</p>} />,
});
} }
describe("FederationCallback", () => { describe("FederationCallback", () => {
@@ -23,8 +17,9 @@ describe("FederationCallback", () => {
"POST /api/federation/sessions", "POST /api/federation/sessions",
() => wire(makeSession({ username: "alice@home.example" })), () => wire(makeSession({ username: "alice@home.example" })),
); );
route("GET /api/dumps", () => page([]));
open("?code=c&state=s&iss=https%3A%2F%2Fhome.example"); open("?code=c&state=s&iss=https%3A%2F%2Fhome.example");
await screen.findByText("home page"); await waitFor(() => expect(currentPath()).toBe("/"));
expect(calls("POST /api/federation/sessions").map((c) => c.body)).toEqual( expect(calls("POST /api/federation/sessions").map((c) => c.body)).toEqual(
[{ code: "c", state: "s" }], [{ code: "c", state: "s" }],
); );

View File

@@ -1,13 +1,6 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { act, screen, waitFor, within } from "@testing-library/react"; import { act, screen, waitFor, within } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event"; import { userEvent } from "@testing-library/user-event";
import { useLocation } from "react-router";
import { Index } from "../../pages/Index.tsx";
import {
FEED_TABS,
type FeedTab,
type FollowView,
} from "../../../shared/paths.ts";
import { calls, fail, route } from "../fetch.ts"; import { calls, fail, route } from "../fetch.ts";
import { import {
makeCategory, makeCategory,
@@ -16,41 +9,19 @@ import {
page, page,
wire, wire,
} from "../fixtures.ts"; } from "../fixtures.ts";
import { currentPath, renderApp } from "../render.tsx"; import { currentPath, renderPage } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts"; import { FakeWebSocket } from "../ws.ts";
import { FakeIntersectionObserver } from "../setup.ts"; import { FakeIntersectionObserver } from "../setup.ts";
const HOUR = 3_600_000; const HOUR = 3_600_000;
const music = makeCategory({ id: "c-music", slug: "music", name: "Music" }); const music = makeCategory({ id: "c-music", slug: "music", name: "Music" });
/** Reads a feed's path as `App`'s routes do. */
function Scoped() {
const { pathname } = useLocation();
const segments = pathname.split("/").filter(Boolean);
const isTab = (FEED_TABS as readonly string[]).includes(segments[0] ?? "");
const categorySlug = isTab ? undefined : segments[0];
const [feedTab, followView] = segments.slice(isTab ? 0 : 1);
return (
<Index
key={categorySlug ?? ""}
categorySlug={categorySlug}
feedTab={feedTab as FeedTab | undefined}
followView={followView as FollowView | undefined}
/>
);
}
function setup( function setup(
url: string, url: string,
{ session = null as ReturnType<typeof makeSession> | null } = {}, { session = null as ReturnType<typeof makeSession> | null } = {},
) { ) {
const user = userEvent.setup(); const user = userEvent.setup();
renderApp(<Scoped />, { renderPage(url, { session, categories: [music] });
route: url,
path: "*",
session,
categories: [music],
});
const socket = FakeWebSocket.last; const socket = FakeWebSocket.last;
socket.welcome(); socket.welcome();
return { user, socket }; return { user, socket };
@@ -309,10 +280,12 @@ describe("Index feeds", () => {
.toBeInTheDocument(); .toBeInTheDocument();
}); });
test("a guest on the follow tab is sent to sign in, and back", () => { test("a guest on the follow tab is sent to sign in, and back", async () => {
route("GET /api/dumps", () => page([older])); route("GET /api/dumps", () => page([older]));
setup("/music/follow"); setup("/music/follow");
expect(currentPath()).toBe("/login?next=%2Fmusic%2Ffollow"); await waitFor(() =>
expect(currentPath()).toBe("/login?next=%2Fmusic%2Ffollow")
);
}); });
test("the Android share sheet opens the new-dump form with the link", async () => { test("the Android share sheet opens the new-dump form with the link", async () => {

View File

@@ -1,10 +1,9 @@
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { screen, waitFor, within } from "@testing-library/react"; import { screen, waitFor, within } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event"; import { userEvent } from "@testing-library/user-event";
import { Manage } from "../../pages/Manage.tsx";
import { calls, fail, route } from "../fetch.ts"; import { calls, fail, route } from "../fetch.ts";
import { makeCategory, makeSession, wire } from "../fixtures.ts"; import { makeCategory, makeSession, wire } from "../fixtures.ts";
import { renderApp } from "../render.tsx"; import { renderPage } from "../render.tsx";
const instance = vi.hoisted(() => ({ const instance = vi.hoisted(() => ({
federationIncoming: true, federationIncoming: true,
@@ -31,7 +30,7 @@ async function group(label: string) {
function open(session = admin) { function open(session = admin) {
route("GET /api/federation/peers", () => [], { fallback: true }); route("GET /api/federation/peers", () => [], { fallback: true });
const user = userEvent.setup(); const user = userEvent.setup();
renderApp(<Manage />, { route: "/manage", path: "/manage", session }); renderPage("/manage", { session });
return user; return user;
} }

View File

@@ -1,12 +1,12 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { screen, waitFor } from "@testing-library/react"; import { screen, waitFor } from "@testing-library/react";
import { NotFound } from "../../pages/NotFound.tsx"; import { renderPage } from "../render.tsx";
import { renderWithContexts } from "../render.tsx";
describe("NotFound", () => { describe("NotFound", () => {
test("NotFound", async () => { test("a path the app has no page for", async () => {
renderWithContexts(<NotFound />, { route: "/nope" }); renderPage("/search/users/more");
expect(screen.getByRole("heading", { name: "404" })).toBeInTheDocument(); expect(await screen.findByRole("heading", { name: "404" }))
.toBeInTheDocument();
await waitFor(() => expect(document.title).toMatch(/^Page not found/)); await waitFor(() => expect(document.title).toMatch(/^Page not found/));
expect(screen.getByRole("link", { name: "Go home" })).toHaveAttribute( expect(screen.getByRole("link", { name: "Go home" })).toHaveAttribute(
"href", "href",

View File

@@ -1,11 +1,10 @@
import { describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import { act, screen, waitFor } from "@testing-library/react"; import { act, screen, waitFor } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event"; import { userEvent } from "@testing-library/user-event";
import { Notifications } from "../../pages/Notifications.tsx";
import type { Notification } from "../../../shared/notification.ts"; import type { Notification } from "../../../shared/notification.ts";
import { calls, deferred, fail, route } from "../fetch.ts"; import { calls, deferred, fail, route } from "../fetch.ts";
import { makeNotification, makeSession, page, wire } from "../fixtures.ts"; import { makeNotification, makeSession, page, wire } from "../fixtures.ts";
import { renderApp } from "../render.tsx"; import { renderPage } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts"; import { FakeWebSocket } from "../ws.ts";
import { FakeIntersectionObserver } from "../setup.ts"; import { FakeIntersectionObserver } from "../setup.ts";
@@ -22,10 +21,7 @@ function open(items: Notification[], { hasMore = false } = {}) {
); );
route("POST /api/me/notifications/read", () => ({})); route("POST /api/me/notifications/read", () => ({}));
const user = userEvent.setup(); const user = userEvent.setup();
const view = renderApp(<Notifications />, { const view = renderPage("/notifications", { session: me });
session: me,
route: "/notifications",
});
const socket = FakeWebSocket.last; const socket = FakeWebSocket.last;
socket.welcome({ socket.welcome({
unreadNotificationCount: items.filter((n) => !n.read).length, unreadNotificationCount: items.filter((n) => !n.read).length,
@@ -247,7 +243,7 @@ describe("Notifications", () => {
test("a failed load shows the server's reason", async () => { test("a failed load shows the server's reason", async () => {
route("GET /api/me/notifications", () => fail("Database is locked", 500)); route("GET /api/me/notifications", () => fail("Database is locked", 500));
renderApp(<Notifications />, { session: me }); renderPage("/notifications", { session: me });
const alert = await screen.findByRole("alert"); const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Failed to load"); expect(alert).toHaveTextContent("Failed to load");
expect(alert).toHaveTextContent("Database is locked"); expect(alert).toHaveTextContent("Database is locked");
@@ -303,7 +299,7 @@ describe("Notifications", () => {
const list = deferred<unknown>(); const list = deferred<unknown>();
route("GET /api/me/notifications", () => list.promise); route("GET /api/me/notifications", () => list.promise);
route("POST /api/me/notifications/read", () => ({})); route("POST /api/me/notifications/read", () => ({}));
renderApp(<Notifications />, { session: me, route: "/notifications" }); renderPage("/notifications", { session: me });
const socket = FakeWebSocket.last; const socket = FakeWebSocket.last;
socket.welcome({ unreadNotificationCount: 1 }); socket.welcome({ unreadNotificationCount: 1 });
await waitFor(() => await waitFor(() =>
@@ -343,7 +339,7 @@ describe("Notifications", () => {
: page([n.upvoted], false), : page([n.upvoted], false),
); );
route("POST /api/me/notifications/read", () => ({})); route("POST /api/me/notifications/read", () => ({}));
renderApp(<Notifications />, { session: me }); renderPage("/notifications", { session: me });
await screen.findByText(/started following you/); await screen.findByText(/started following you/);
act(() => FakeIntersectionObserver.instances.at(-1)!.report(true)); act(() => FakeIntersectionObserver.instances.at(-1)!.report(true));
expect(await screen.findByText(/^upvoted$/)).toBeInTheDocument(); expect(await screen.findByText(/^upvoted$/)).toBeInTheDocument();

View File

@@ -7,8 +7,6 @@ import {
within, within,
} from "@testing-library/react"; } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event"; import { userEvent } from "@testing-library/user-event";
import { Route } from "react-router";
import { PlaylistDetail } from "../../pages/PlaylistDetail.tsx";
import type { Dump } from "../../../shared/dump.ts"; import type { Dump } from "../../../shared/dump.ts";
import type { Playlist } from "../../../shared/playlist.ts"; import type { Playlist } from "../../../shared/playlist.ts";
import { calls, fail, route } from "../fetch.ts"; import { calls, fail, route } from "../fetch.ts";
@@ -19,7 +17,7 @@ import {
makeSession, makeSession,
wire, wire,
} from "../fixtures.ts"; } from "../fixtures.ts";
import { currentPath, renderApp } from "../render.tsx"; import { currentPath, renderPage } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts"; import { FakeWebSocket } from "../ws.ts";
const owner = makeSession({ id: "owner", username: "owner" }); const owner = makeSession({ id: "owner", username: "owner" });
@@ -54,21 +52,7 @@ function open(
() => wire({ ...pl, dumps: items }), () => wire({ ...pl, dumps: items }),
); );
const user = userEvent.setup(); const user = userEvent.setup();
renderApp(<PlaylistDetail />, { renderPage(url, { session, categories });
route: url,
path: "/:handle/playlists/:slug",
session,
categories,
extraRoutes: (
<>
<Route path="/" element={<p>home</p>} />
<Route
path="/:handle/playlists/:slug/edit"
element={<PlaylistDetail editing />}
/>
</>
),
});
const socket = FakeWebSocket.last; const socket = FakeWebSocket.last;
socket.welcome(); socket.welcome();
return { user, socket, pl }; return { user, socket, pl };
@@ -103,10 +87,7 @@ describe("PlaylistDetail", () => {
"GET /api/users/:u/playlists/:slug", "GET /api/users/:u/playlists/:slug",
() => fail("Playlist not found", 404), () => fail("Playlist not found", 404),
); );
renderApp(<PlaylistDetail />, { renderPage("/@owner/playlists/x");
route: "/@owner/playlists/x",
path: "/:handle/playlists/:slug",
});
expect(await screen.findByRole("alert")).toHaveTextContent( expect(await screen.findByRole("alert")).toHaveTextContent(
"Playlist not found", "Playlist not found",
); );
@@ -119,6 +100,7 @@ describe("PlaylistDetail", () => {
.toBeInTheDocument(); .toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Remove from playlist" })).not expect(screen.queryByRole("button", { name: "Remove from playlist" })).not
.toBeInTheDocument(); .toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Edit title" })).toBeNull();
}); });
test("the cover is versioned on the last edit", async () => { test("the cover is versioned on the last edit", async () => {
@@ -159,6 +141,29 @@ describe("PlaylistDetail", () => {
}); });
}); });
test("its owner retitles it in place, which keeps its link", async () => {
route(
"PUT /api/playlists/:id",
() =>
wire(makePlaylist({
id: "p1",
slug: "mix",
title: "Remix",
userId: "owner",
description: "Good **tunes**",
})),
);
const { user } = open();
await user.click(await screen.findByRole("heading", { name: "Mix" }));
const input = screen.getByDisplayValue("Mix");
await user.clear(input);
await user.type(input, "Remix{Enter}");
expect(await screen.findByRole("heading", { name: "Remix" }))
.toBeInTheDocument();
expect(currentPath()).toBe("/@owner/playlists/mix");
expect(calls("PUT /api/playlists/:id")[0].body).toEqual({ title: "Remix" });
});
test("the editor is a page: /edit opens it, closing goes back to the page", async () => { test("the editor is a page: /edit opens it, closing goes back to the page", async () => {
const { user } = open({ url: "/@owner/playlists/mix/edit" }); const { user } = open({ url: "/@owner/playlists/mix/edit" });
expect(await screen.findByRole("textbox", { name: "Description" })) expect(await screen.findByRole("textbox", { name: "Description" }))
@@ -476,7 +481,7 @@ describe("PlaylistDetail", () => {
playlistId: "p1", playlistId: "p1",
userId: "owner", userId: "owner",
}); });
expect(await screen.findByText("home")).toBeInTheDocument(); await waitFor(() => expect(currentPath()).toBe("/"));
}); });
test("deleting the playlist asks, then goes home", async () => { test("deleting the playlist asks, then goes home", async () => {
@@ -492,7 +497,7 @@ describe("PlaylistDetail", () => {
name: "Delete playlist", name: "Delete playlist",
}), }),
); );
expect(await screen.findByText("home")).toBeInTheDocument(); await waitFor(() => expect(currentPath()).toBe("/"));
}); });
test("a refused delete stays and says why", async () => { test("a refused delete stays and says why", async () => {
@@ -537,6 +542,6 @@ describe("PlaylistDetail", () => {
playlistId: "p1", playlistId: "p1",
userId: "owner", userId: "owner",
}); });
expect(await screen.findByText("home")).toBeInTheDocument(); await waitFor(() => expect(currentPath()).toBe("/"));
}); });
}); });

View File

@@ -1,8 +1,6 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { Route } from "react-router"; import { screen, waitFor } from "@testing-library/react";
import { screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event"; import { userEvent } from "@testing-library/user-event";
import { Playlists } from "../../pages/Playlists.tsx";
import { calls, route } from "../fetch.ts"; import { calls, route } from "../fetch.ts";
import { import {
makeCategory, makeCategory,
@@ -12,7 +10,7 @@ import {
wire, wire,
} from "../fixtures.ts"; } from "../fixtures.ts";
import type { Session } from "../../utils/auth/storage.ts"; import type { Session } from "../../utils/auth/storage.ts";
import { currentPath, renderApp } from "../render.tsx"; import { currentPath, renderPage } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts"; import { FakeWebSocket } from "../ws.ts";
const mixes = makeCategory({ const mixes = makeCategory({
@@ -25,13 +23,7 @@ const music = makeCategory({ id: "dc1", slug: "music", name: "Music" });
function open(url: string, session?: Session) { function open(url: string, session?: Session) {
const user = userEvent.setup(); const user = userEvent.setup();
renderApp(<Playlists />, { renderPage(url, { categories: [music, mixes], session });
route: url,
path: "/playlists/:categorySlug?",
categories: [music, mixes],
session,
extraRoutes: <Route path="/login" element={null} />,
});
return { user, socket: FakeWebSocket.last }; return { user, socket: FakeWebSocket.last };
} }
@@ -216,9 +208,11 @@ describe("Playlists", () => {
expect(screen.queryByRole("button", { name: "Mine" })).toBeNull(); expect(screen.queryByRole("button", { name: "Mine" })).toBeNull();
}); });
test("a guest sent to their own playlists signs in first", () => { test("a guest sent to their own playlists signs in first", async () => {
open("/playlists?mine=1"); open("/playlists?mine=1");
expect(currentPath()).toBe("/login?next=%2Fplaylists%3Fmine%3D1"); await waitFor(() =>
expect(currentPath()).toBe("/login?next=%2Fplaylists%3Fmine%3D1")
);
}); });
test("a slug that names no playlist category is not found", async () => { test("a slug that names no playlist category is not found", async () => {

View File

@@ -1,19 +1,13 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { screen } from "@testing-library/react"; import { screen, waitFor } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event"; import { userEvent } from "@testing-library/user-event";
import { Route } from "react-router";
import { ResetPassword } from "../../pages/ResetPassword.tsx";
import { calls, fail, route } from "../fetch.ts"; import { calls, fail, route } from "../fetch.ts";
import { renderWithContexts } from "../render.tsx"; import { currentPath, renderPage } from "../render.tsx";
describe("ResetPassword", () => { describe("ResetPassword", () => {
function setup(search = "?token=rst") { function setup(search = "?token=rst") {
const user = userEvent.setup(); const user = userEvent.setup();
renderWithContexts(<ResetPassword />, { renderPage(`/reset-password${search}`);
route: `/reset-password${search}`,
path: "/reset-password",
extraRoutes: <Route path="/login" element={<p>login page</p>} />,
});
return user; return user;
} }
@@ -21,7 +15,7 @@ describe("ResetPassword", () => {
route("POST /api/password-resets", () => ({})); route("POST /api/password-resets", () => ({}));
const user = setup(""); const user = setup("");
await user.type( await user.type(
screen.getByPlaceholderText("Your email address"), await screen.findByPlaceholderText("Your email address"),
" a@b.c {Enter}", " a@b.c {Enter}",
); );
await screen.findByText(/you'll receive a reset link/); await screen.findByText(/you'll receive a reset link/);
@@ -38,7 +32,7 @@ describe("ResetPassword", () => {
route("POST /api/password-resets", () => fail("Internal error", 500)); route("POST /api/password-resets", () => fail("Internal error", 500));
const user = setup(""); const user = setup("");
await user.type( await user.type(
screen.getByPlaceholderText("Your email address"), await screen.findByPlaceholderText("Your email address"),
"a@b.c{Enter}", "a@b.c{Enter}",
); );
expect(await screen.findByRole("alert")).toHaveTextContent( expect(await screen.findByRole("alert")).toHaveTextContent(
@@ -50,7 +44,10 @@ describe("ResetPassword", () => {
test("the two passwords must match", async () => { test("the two passwords must match", async () => {
const user = setup(); const user = setup();
await user.type(screen.getByPlaceholderText("New password"), "longenough"); await user.type(
await screen.findByPlaceholderText("New password"),
"longenough",
);
await user.type( await user.type(
screen.getByPlaceholderText("Confirm new password"), screen.getByPlaceholderText("Confirm new password"),
"different", "different",
@@ -64,7 +61,10 @@ describe("ResetPassword", () => {
test("sets the password, then offers login", async () => { test("sets the password, then offers login", async () => {
route("PUT /api/password-resets/:token", () => ({})); route("PUT /api/password-resets/:token", () => ({}));
const user = setup(); const user = setup();
await user.type(screen.getByPlaceholderText("New password"), "longenough"); await user.type(
await screen.findByPlaceholderText("New password"),
"longenough",
);
await user.type( await user.type(
screen.getByPlaceholderText("Confirm new password"), screen.getByPlaceholderText("Confirm new password"),
"longenough{Enter}", "longenough{Enter}",
@@ -76,7 +76,7 @@ describe("ResetPassword", () => {
newPassword: "longenough", newPassword: "longenough",
}); });
await user.click(screen.getByRole("button", { name: "Go to login" })); await user.click(screen.getByRole("button", { name: "Go to login" }));
expect(screen.getByText("login page")).toBeInTheDocument(); await waitFor(() => expect(currentPath()).toBe("/login"));
}); });
test("an expired token is reported", async () => { test("an expired token is reported", async () => {
@@ -85,7 +85,10 @@ describe("ResetPassword", () => {
() => fail("Reset link expired", 400), () => fail("Reset link expired", 400),
); );
const user = setup(); const user = setup();
await user.type(screen.getByPlaceholderText("New password"), "longenough"); await user.type(
await screen.findByPlaceholderText("New password"),
"longenough",
);
await user.type( await user.type(
screen.getByPlaceholderText("Confirm new password"), screen.getByPlaceholderText("Confirm new password"),
"longenough{Enter}", "longenough{Enter}",

View File

@@ -1,7 +1,6 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { act, screen, waitFor } from "@testing-library/react"; import { act, screen, waitFor } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event"; import { userEvent } from "@testing-library/user-event";
import { Search } from "../../pages/Search.tsx";
import { calls, fail, route } from "../fetch.ts"; import { calls, fail, route } from "../fetch.ts";
import { import {
makeCategory, makeCategory,
@@ -11,7 +10,7 @@ import {
makeUser, makeUser,
wire, wire,
} from "../fixtures.ts"; } from "../fixtures.ts";
import { currentPath, renderApp } from "../render.tsx"; import { currentPath, renderPage } from "../render.tsx";
import { FakeIntersectionObserver } from "../setup.ts"; import { FakeIntersectionObserver } from "../setup.ts";
import { FakeWebSocket } from "../ws.ts"; import { FakeWebSocket } from "../ws.ts";
@@ -40,14 +39,15 @@ function open(
categories: ReturnType<typeof makeCategory>[] = [], categories: ReturnType<typeof makeCategory>[] = [],
) { ) {
const user = userEvent.setup(); const user = userEvent.setup();
renderApp(<Search />, { route: url, path: "/search/*", session, categories }); renderPage(url, { session, categories });
return user; return user;
} }
describe("Search", () => { describe("Search", () => {
test("no query: a prompt, no request", () => { test("no query: a prompt, no request", async () => {
open("/search"); open("/search");
expect(screen.getByText("Enter a query to search.")).toBeInTheDocument(); expect(await screen.findByText("Enter a query to search."))
.toBeInTheDocument();
expect(calls("GET /api/search")).toHaveLength(0); expect(calls("GET /api/search")).toHaveLength(0);
}); });

View File

@@ -1,11 +1,10 @@
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { screen, waitFor, within } from "@testing-library/react"; import { screen, waitFor, within } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event"; import { userEvent } from "@testing-library/user-event";
import { Settings } from "../../pages/Settings.tsx";
import { DEFAULT_SETTINGS } from "../../../shared/settings.ts"; import { DEFAULT_SETTINGS } from "../../../shared/settings.ts";
import { calls, fail, route } from "../fetch.ts"; import { calls, fail, route } from "../fetch.ts";
import { makeSession, makeToken } from "../fixtures.ts"; import { makeSession, makeToken } from "../fixtures.ts";
import { renderApp } from "../render.tsx"; import { renderPage } from "../render.tsx";
import { AUTH_STORAGE_KEY } from "../../utils/auth/storage.ts"; import { AUTH_STORAGE_KEY } from "../../utils/auth/storage.ts";
import { API_URL } from "../../config/api.ts"; import { API_URL } from "../../config/api.ts";
@@ -32,7 +31,7 @@ const annSession = makeSession({
function open(session = annSession) { function open(session = annSession) {
route("GET /api/me/grants", () => [], { fallback: true }); route("GET /api/me/grants", () => [], { fallback: true });
const user = userEvent.setup(); const user = userEvent.setup();
renderApp(<Settings />, { route: "/settings", path: "/settings", session }); renderPage("/settings", { session });
return user; return user;
} }

View File

@@ -1,18 +1,14 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { screen } from "@testing-library/react"; import { screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event"; import { userEvent } from "@testing-library/user-event";
import { Unsubscribe } from "../../pages/Unsubscribe.tsx";
import { calls, fail, route } from "../fetch.ts"; import { calls, fail, route } from "../fetch.ts";
import { renderWithContexts } from "../render.tsx"; import { renderPage } from "../render.tsx";
import { makeJwt } from "../fixtures.ts"; import { makeJwt } from "../fixtures.ts";
describe("Unsubscribe", () => { describe("Unsubscribe", () => {
function setup(token: string) { function setup(token: string) {
const user = userEvent.setup(); const user = userEvent.setup();
renderWithContexts(<Unsubscribe />, { renderPage(`/unsubscribe?token=${encodeURIComponent(token)}`);
route: `/unsubscribe?token=${encodeURIComponent(token)}`,
path: "/unsubscribe",
});
return user; return user;
} }
@@ -25,7 +21,9 @@ describe("Unsubscribe", () => {
route("POST /api/unsubscribe", () => ({ cadence: "monthly" })); route("POST /api/unsubscribe", () => ({ cadence: "monthly" }));
const user = setup(token); const user = setup(token);
expect( expect(
screen.getByRole("heading", { name: "Turn off the monthly recap?" }), await screen.findByRole("heading", {
name: "Turn off the monthly recap?",
}),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(calls("POST /api/unsubscribe")).toHaveLength(0); expect(calls("POST /api/unsubscribe")).toHaveLength(0);
@@ -49,15 +47,15 @@ describe("Unsubscribe", () => {
makeJwt({ purpose: "unsubscribe", userId: "u", cadence: "weekly" }), makeJwt({ purpose: "unsubscribe", userId: "u", cadence: "weekly" }),
); );
await user.click( await user.click(
screen.getByRole("button", { name: "Turn off weekly digests" }), await screen.findByRole("button", { name: "Turn off weekly digests" }),
); );
expect(await screen.findByText("Invalid unsubscribe link")) expect(await screen.findByText("Invalid unsubscribe link"))
.toBeInTheDocument(); .toBeInTheDocument();
}); });
test("a token that isn't an unsubscribe one is refused before asking", () => { test("a token that isn't an unsubscribe one is refused before asking", async () => {
setup(makeJwt({ purpose: "media", userId: "u" })); setup(makeJwt({ purpose: "media", userId: "u" }));
expect(screen.getByRole("heading", { name: "Invalid link" })) expect(await screen.findByRole("heading", { name: "Invalid link" }))
.toBeInTheDocument(); .toBeInTheDocument();
}); });
}); });

View File

@@ -1,12 +1,10 @@
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { screen, waitFor } from "@testing-library/react"; import { screen, waitFor } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event"; import { userEvent } from "@testing-library/user-event";
import { Route } from "react-router";
import { UserLogin } from "../../pages/UserLogin.tsx";
import { AUTH_STORAGE_KEY } from "../../utils/auth/storage.ts"; import { AUTH_STORAGE_KEY } from "../../utils/auth/storage.ts";
import { calls, deferred, fail, route } from "../fetch.ts"; import { calls, deferred, fail, route } from "../fetch.ts";
import { makeSession, wire } from "../fixtures.ts"; import { makeSession, wire } from "../fixtures.ts";
import { currentPath, renderWithContexts } from "../render.tsx"; import { currentPath, renderPage } from "../render.tsx";
const instance = vi.hoisted(() => ({ federationIncoming: true })); const instance = vi.hoisted(() => ({ federationIncoming: true }));
vi.mock("../../config/instance.ts", async (importOriginal) => ({ vi.mock("../../config/instance.ts", async (importOriginal) => ({
@@ -16,16 +14,14 @@ vi.mock("../../config/instance.ts", async (importOriginal) => ({
}, },
})); }));
const home = <Route path="/" element={<p>home page</p>} />; /** Waits for the sign-in to have left the login page for `path`. */
const landsOn = (path: string) =>
waitFor(() => expect(currentPath()).toBe(path));
describe("UserLogin", () => { describe("UserLogin", () => {
function setup() { function setup() {
const user = userEvent.setup(); const user = userEvent.setup();
renderWithContexts(<UserLogin />, { renderPage("/login");
route: "/login",
path: "/login",
extraRoutes: home,
});
return user; return user;
} }
@@ -33,10 +29,10 @@ describe("UserLogin", () => {
const session = makeSession({ username: "ann" }); const session = makeSession({ username: "ann" });
route("POST /api/sessions", () => wire(session)); route("POST /api/sessions", () => wire(session));
const user = setup(); const user = setup();
await user.type(await screen.findByPlaceholderText("Username"), "ann");
expect(document.title).toMatch(/^Log in · /); expect(document.title).toMatch(/^Log in · /);
await user.type(screen.getByPlaceholderText("Username"), "ann");
await user.type(screen.getByPlaceholderText("Password"), "hunter22{Enter}"); await user.type(screen.getByPlaceholderText("Password"), "hunter22{Enter}");
await screen.findByText("home page"); await landsOn("/");
expect(calls("POST /api/sessions")[0].body).toEqual({ expect(calls("POST /api/sessions")[0].body).toEqual({
username: "ann", username: "ann",
password: "hunter22", password: "hunter22",
@@ -47,6 +43,7 @@ describe("UserLogin", () => {
test("both fields are required", async () => { test("both fields are required", async () => {
const user = setup(); const user = setup();
await screen.findByPlaceholderText("Username");
// The header has a "Log in" button too; this is the form's. // The header has a "Log in" button too; this is the form's.
await user.click( await user.click(
document.querySelector<HTMLButtonElement>( document.querySelector<HTMLButtonElement>(
@@ -54,16 +51,18 @@ describe("UserLogin", () => {
)!, )!,
); );
expect(calls("POST /api/sessions")).toHaveLength(0); expect(calls("POST /api/sessions")).toHaveLength(0);
await waitFor(() =>
expect(screen.getByPlaceholderText("Username")).toHaveAttribute( expect(screen.getByPlaceholderText("Username")).toHaveAttribute(
"aria-invalid", "aria-invalid",
"true", "true",
)
); );
}); });
test("shows the server's refusal", async () => { test("shows the server's refusal", async () => {
route("POST /api/sessions", () => fail("Invalid credentials", 401)); route("POST /api/sessions", () => fail("Invalid credentials", 401));
const user = setup(); const user = setup();
await user.type(screen.getByPlaceholderText("Username"), "ann"); await user.type(await screen.findByPlaceholderText("Username"), "ann");
await user.type(screen.getByPlaceholderText("Password"), "nope{Enter}"); await user.type(screen.getByPlaceholderText("Password"), "nope{Enter}");
const alert = await screen.findByRole("alert"); const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Login failed"); expect(alert).toHaveTextContent("Login failed");
@@ -74,7 +73,7 @@ describe("UserLogin", () => {
test("says when the server can't be reached", async () => { test("says when the server can't be reached", async () => {
route("POST /api/sessions", () => new TypeError("Failed to fetch")); route("POST /api/sessions", () => new TypeError("Failed to fetch"));
const user = setup(); const user = setup();
await user.type(screen.getByPlaceholderText("Username"), "ann"); await user.type(await screen.findByPlaceholderText("Username"), "ann");
await user.type(screen.getByPlaceholderText("Password"), "pw{Enter}"); await user.type(screen.getByPlaceholderText("Password"), "pw{Enter}");
expect(await screen.findByRole("alert")).toHaveTextContent( expect(await screen.findByRole("alert")).toHaveTextContent(
/Could not connect/, /Could not connect/,
@@ -85,7 +84,7 @@ describe("UserLogin", () => {
const pending = deferred(); const pending = deferred();
route("POST /api/sessions", () => pending.promise); route("POST /api/sessions", () => pending.promise);
const user = setup(); const user = setup();
await user.type(screen.getByPlaceholderText("Username"), "ann"); await user.type(await screen.findByPlaceholderText("Username"), "ann");
await user.type(screen.getByPlaceholderText("Password"), "pw{Enter}"); await user.type(screen.getByPlaceholderText("Password"), "pw{Enter}");
expect(await screen.findByRole("button", { name: "Logging in…" })) expect(await screen.findByRole("button", { name: "Logging in…" }))
.toBeDisabled(); .toBeDisabled();
@@ -94,40 +93,31 @@ describe("UserLogin", () => {
JSON.stringify({ success: true, data: wire(makeSession()) }), JSON.stringify({ success: true, data: wire(makeSession()) }),
), ),
); );
await screen.findByText("home page"); await landsOn("/");
}); });
test("Forgot password? leads to the reset page", () => { test("Forgot password? leads to the reset page", async () => {
setup(); setup();
expect(screen.getByRole("link", { name: "Forgot password?" })) expect(await screen.findByRole("link", { name: "Forgot password?" }))
.toHaveAttribute("href", "/reset-password"); .toHaveAttribute("href", "/reset-password");
}); });
test("a sign-in follows ?next= on this site, and only there", async () => { test("a sign-in follows ?next= on this site, and only there", async () => {
route("POST /api/sessions", () => wire(makeSession())); route("POST /api/sessions", () => wire(makeSession()));
const user = userEvent.setup(); const user = userEvent.setup();
renderWithContexts(<UserLogin />, { renderPage(`/login?next=${encodeURIComponent("/authorize?client_id=x")}`);
route: `/login?next=${encodeURIComponent("/authorize?client_id=x")}`, await user.type(await screen.findByPlaceholderText("Username"), "ann");
path: "/login",
extraRoutes: <Route path="/authorize" element={<p>authorize</p>} />,
});
await user.type(screen.getByPlaceholderText("Username"), "ann");
await user.type(screen.getByPlaceholderText("Password"), "pw{Enter}"); await user.type(screen.getByPlaceholderText("Password"), "pw{Enter}");
await screen.findByText("authorize"); await landsOn("/authorize?client_id=x");
expect(currentPath()).toBe("/authorize?client_id=x");
}); });
test("ignores a ?next= that leaves the site", async () => { test("ignores a ?next= that leaves the site", async () => {
route("POST /api/sessions", () => wire(makeSession())); route("POST /api/sessions", () => wire(makeSession()));
const user = userEvent.setup(); const user = userEvent.setup();
renderWithContexts(<UserLogin />, { renderPage(`/login?next=${encodeURIComponent("//evil.example")}`);
route: `/login?next=${encodeURIComponent("//evil.example")}`, await user.type(await screen.findByPlaceholderText("Username"), "ann");
path: "/login",
extraRoutes: home,
});
await user.type(screen.getByPlaceholderText("Username"), "ann");
await user.type(screen.getByPlaceholderText("Password"), "pw{Enter}"); await user.type(screen.getByPlaceholderText("Password"), "pw{Enter}");
await screen.findByText("home page"); await landsOn("/");
}); });
describe("with an account on another instance", () => { describe("with an account on another instance", () => {
@@ -181,10 +171,11 @@ describe("UserLogin", () => {
); );
}); });
test("isn't offered where accounts of other instances don't sign in", () => { test("isn't offered where accounts of other instances don't sign in", async () => {
instance.federationIncoming = false; instance.federationIncoming = false;
setup(); setup();
expect(screen.getByPlaceholderText("Username")).toBeInTheDocument(); expect(await screen.findByPlaceholderText("Username"))
.toBeInTheDocument();
expect(screen.queryByText(/another gerbeur/)).not.toBeInTheDocument(); expect(screen.queryByText(/another gerbeur/)).not.toBeInTheDocument();
expect(calls("GET /api/federation/peers")).toHaveLength(0); expect(calls("GET /api/federation/peers")).toHaveLength(0);
}); });
@@ -197,7 +188,7 @@ describe("UserLogin", () => {
); );
const user = setup(); const user = setup();
await user.type( await user.type(
screen.getByPlaceholderText("Your gerbeur's URL"), await screen.findByPlaceholderText("Your gerbeur's URL"),
"films.example{Enter}", "films.example{Enter}",
); );
expect(await screen.findByRole("alert")).toHaveTextContent( expect(await screen.findByRole("alert")).toHaveTextContent(

View File

@@ -2,7 +2,6 @@ import { API_URL } from "../../config/api.ts";
import { describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import { act, screen, waitFor, within } from "@testing-library/react"; import { act, screen, waitFor, within } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event"; import { userEvent } from "@testing-library/user-event";
import { UserPublicProfile } from "../../pages/UserPublicProfile.tsx";
import type { PublicUser } from "../../../shared/user.ts"; import type { PublicUser } from "../../../shared/user.ts";
import { calls, fail, route } from "../fetch.ts"; import { calls, fail, route } from "../fetch.ts";
import { import {
@@ -14,7 +13,7 @@ import {
page, page,
wire, wire,
} from "../fixtures.ts"; } from "../fixtures.ts";
import { currentPath, renderApp } from "../render.tsx"; import { currentPath, renderPage } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts"; import { FakeWebSocket } from "../ws.ts";
import { AUTH_STORAGE_KEY } from "../../utils/auth/storage.ts"; import { AUTH_STORAGE_KEY } from "../../utils/auth/storage.ts";
import { FakeIntersectionObserver } from "../setup.ts"; import { FakeIntersectionObserver } from "../setup.ts";
@@ -47,12 +46,7 @@ function open(
route("GET /api/users/:u/upvoted", () => page(votes)); route("GET /api/users/:u/upvoted", () => page(votes));
route("GET /api/users/:u/playlists", () => page(playlists)); route("GET /api/users/:u/playlists", () => page(playlists));
const u = userEvent.setup(); const u = userEvent.setup();
renderApp(<UserPublicProfile />, { renderPage(url, { session, categories });
route: url,
path: "/:handle/*",
session,
categories,
});
return { user: u, socket: FakeWebSocket.last }; return { user: u, socket: FakeWebSocket.last };
} }
@@ -72,7 +66,8 @@ describe("UserPublicProfile", () => {
`${API_URL}/api/users/@alice%40home.example/home`, `${API_URL}/api/users/@alice%40home.example/home`,
); );
expect(screen.queryByTitle("Change avatar")).not.toBeInTheDocument(); expect(screen.queryByTitle("Change avatar")).not.toBeInTheDocument();
expect(screen.queryByTitle("Edit email")).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Edit email" })).not
.toBeInTheDocument();
expect(screen.queryByText("O.G.")).not.toBeInTheDocument(); expect(screen.queryByText("O.G.")).not.toBeInTheDocument();
expect(calls("GET /api/users/:u")[0].path).toBe( expect(calls("GET /api/users/:u")[0].path).toBe(
"/api/users/@alice%40home.example", "/api/users/@alice%40home.example",
@@ -107,10 +102,7 @@ describe("UserPublicProfile", () => {
test("an unknown user", async () => { test("an unknown user", async () => {
route("GET /api/users/:u", () => fail("User not found", 404)); route("GET /api/users/:u", () => fail("User not found", 404));
renderApp(<UserPublicProfile />, { renderPage("/@nobody");
route: "/@nobody",
path: "/:handle/*",
});
expect(await screen.findByRole("alert")).toHaveTextContent( expect(await screen.findByRole("alert")).toHaveTextContent(
"User not found", "User not found",
); );
@@ -133,11 +125,9 @@ describe("UserPublicProfile", () => {
}); });
test("a tab or view the profile hasn't is not found", async () => { test("a tab or view the profile hasn't is not found", async () => {
for (const url of ["/@ann/nope", "/@ann/follow/users", "/@ann/dumps/x"]) { // `/@ann/dumps/x` is a dump's page, not the profile's.
const { unmount } = renderApp(<UserPublicProfile />, { for (const url of ["/@ann/nope", "/@ann/follow/users", "/@ann/dumps/x/y"]) {
route: url, const { unmount } = renderPage(url);
path: "/:handle/*",
});
expect(await screen.findByText("This page does not exist.")) expect(await screen.findByText("This page does not exist."))
.toBeInTheDocument(); .toBeInTheDocument();
unmount(); unmount();
@@ -462,10 +452,8 @@ describe("UserPublicProfile", () => {
(req) => wire({ ...ann, ...(req.body as object) }), (req) => wire({ ...ann, ...(req.body as object) }),
); );
const { user } = open({ session: annSession }); const { user } = open({ session: annSession });
const view = (await screen.findByText("dump")).closest( await screen.findByText("dump");
"[title='Edit description']", screen.getByRole("button", { name: "Edit description" }).focus();
) as HTMLElement;
view.focus();
await user.keyboard("{Enter}"); await user.keyboard("{Enter}");
const box = screen.getByPlaceholderText("Who am I?"); const box = screen.getByPlaceholderText("Who am I?");
await user.clear(box); await user.clear(box);
@@ -480,14 +468,11 @@ describe("UserPublicProfile", () => {
(req) => wire({ ...ann, ...(req.body as object) }), (req) => wire({ ...ann, ...(req.body as object) }),
); );
const { user } = open({ session: annSession }); const { user } = open({ session: annSession });
await user.click( await user.click(await screen.findByText("ann@x.test"));
await screen.findByRole("button", { name: /ann@x\.test/ }),
);
const input = screen.getByDisplayValue("ann@x.test"); const input = screen.getByDisplayValue("ann@x.test");
await user.clear(input); await user.clear(input);
await user.type(input, "new@x.test{Enter}"); await user.type(input, "new@x.test{Enter}");
expect(await screen.findByRole("button", { name: /new@x\.test/ })) expect(await screen.findByText("new@x.test")).toBeInTheDocument();
.toBeInTheDocument();
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.email) expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.email)
.toBe("new@x.test"); .toBe("new@x.test");
}); });
@@ -495,9 +480,7 @@ describe("UserPublicProfile", () => {
test("a refused email says why", async () => { test("a refused email says why", async () => {
route("PATCH /api/me", () => fail("Email already in use", 409)); route("PATCH /api/me", () => fail("Email already in use", 409));
const { user } = open({ session: annSession }); const { user } = open({ session: annSession });
await user.click( await user.click(await screen.findByText("ann@x.test"));
await screen.findByRole("button", { name: /ann@x\.test/ }),
);
await user.type(screen.getByDisplayValue("ann@x.test"), "x{Enter}"); await user.type(screen.getByDisplayValue("ann@x.test"), "x{Enter}");
expect(await screen.findByRole("alert")).toHaveTextContent( expect(await screen.findByRole("alert")).toHaveTextContent(
"Email already in use", "Email already in use",

View File

@@ -1,35 +1,31 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { screen } from "@testing-library/react"; import { screen, waitFor } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event"; import { userEvent } from "@testing-library/user-event";
import { Route } from "react-router"; import { calls, deferred, fail, route } from "../fetch.ts";
import { UserRegister } from "../../pages/UserRegister.tsx";
import { calls, fail, route } from "../fetch.ts";
import { makeSession, wire } from "../fixtures.ts"; import { makeSession, wire } from "../fixtures.ts";
import { renderWithContexts } from "../render.tsx"; import { currentPath, renderPage } from "../render.tsx";
const home = <Route path="/" element={<p>home page</p>} />;
describe("UserRegister", () => { describe("UserRegister", () => {
function setup(search = "?token=inv-1") { function setup(search = "?token=inv-1") {
const user = userEvent.setup(); const user = userEvent.setup();
renderWithContexts(<UserRegister />, { renderPage(`/register${search}`);
route: `/register${search}`,
path: "/register",
extraRoutes: home,
});
return user; return user;
} }
test("no token: an invalid invite, no request", () => { test("no token: an invalid invite, no request", async () => {
setup(""); setup("");
expect(screen.getByRole("alert")).toHaveTextContent("Invalid invite"); expect(await screen.findByRole("alert")).toHaveTextContent(
"Invalid invite",
);
expect(calls("GET /api/invites/:t")).toHaveLength(0); expect(calls("GET /api/invites/:t")).toHaveLength(0);
}); });
test("an unknown token is invalid", async () => { test("an unknown token is invalid", async () => {
route("GET /api/invites/:t", () => fail("nope", 404)); const invite = deferred();
route("GET /api/invites/:t", () => invite.promise);
setup(); setup();
expect(screen.getByText("Checking invite…")).toBeInTheDocument(); expect(await screen.findByText("Checking invite…")).toBeInTheDocument();
invite.resolve(fail("nope", 404));
expect(await screen.findByRole("alert")).toHaveTextContent("already used"); expect(await screen.findByRole("alert")).toHaveTextContent("already used");
}); });
@@ -44,7 +40,7 @@ describe("UserRegister", () => {
screen.getByPlaceholderText("Password (min. 8 characters)"), screen.getByPlaceholderText("Password (min. 8 characters)"),
"longenough{Enter}", "longenough{Enter}",
); );
await screen.findByText("home page"); await waitFor(() => expect(currentPath()).toBe("/"));
expect(calls("POST /api/users")[0].body).toEqual({ expect(calls("POST /api/users")[0].body).toEqual({
username: "new_one", username: "new_one",
password: "longenough", password: "longenough",

View File

@@ -2,7 +2,13 @@ import { type ReactElement, type ReactNode, useState } from "react";
import { render, type RenderOptions } from "@testing-library/react"; import { render, type RenderOptions } from "@testing-library/react";
import { I18nProvider } from "@lingui/react"; import { I18nProvider } from "@lingui/react";
import { i18n } from "@lingui/core"; import { i18n } from "@lingui/core";
import { MemoryRouter, Route, Routes, useLocation } from "react-router"; import {
type InitialEntry,
MemoryRouter,
Route,
Routes,
useLocation,
} from "react-router";
import type { Session } from "../utils/auth/storage.ts"; import type { Session } from "../utils/auth/storage.ts";
import type { Category } from "../../shared/category.ts"; import type { Category } from "../../shared/category.ts";
@@ -30,6 +36,7 @@ import {
type FollowContextValue, type FollowContextValue,
} from "../contexts/social/FollowContext.ts"; } from "../contexts/social/FollowContext.ts";
import { FollowProvider } from "../contexts/social/FollowProvider.tsx"; import { FollowProvider } from "../contexts/social/FollowProvider.tsx";
import { CommunityFeedProvider } from "../contexts/social/CommunityFeedProvider.tsx";
import { import {
CategoriesContext, CategoriesContext,
type CategoriesContextValue, type CategoriesContextValue,
@@ -46,6 +53,7 @@ import {
import { PlayerProvider } from "../contexts/player/PlayerProvider.tsx"; import { PlayerProvider } from "../contexts/player/PlayerProvider.tsx";
import { ThemeProvider } from "../contexts/theme/ThemeProvider.tsx"; import { ThemeProvider } from "../contexts/theme/ThemeProvider.tsx";
import { ChatProvider } from "../contexts/chat/ChatProvider.tsx"; import { ChatProvider } from "../contexts/chat/ChatProvider.tsx";
import { AppRoutes } from "../App.tsx";
import { route } from "./fetch.ts"; import { route } from "./fetch.ts";
import { storeSession } from "./fixtures.ts"; import { storeSession } from "./fixtures.ts";
@@ -69,8 +77,11 @@ export function currentPath(): string {
} }
interface RouterOptions { interface RouterOptions {
/** Initial URL, or several for a history stack (the last is current). */ /**
route?: string | string[]; * Initial URL, or several for a history stack (the last is current). An
* entry may be a location, to arrive with the `state` a link hands over.
*/
route?: InitialEntry | InitialEntry[];
/** Route pattern the element is mounted on, for useParams. */ /** Route pattern the element is mounted on, for useParams. */
path?: string; path?: string;
/** Extra routes to mount next to it, e.g. a redirect target. */ /** Extra routes to mount next to it, e.g. a redirect target. */
@@ -254,6 +265,7 @@ export function renderApp(ui: ReactElement, opts: AppOptions = {}) {
<PlayerProvider> <PlayerProvider>
<WSProvider> <WSProvider>
<FollowProvider> <FollowProvider>
<CommunityFeedProvider>
<CategoriesProvider> <CategoriesProvider>
<MemoryRouter <MemoryRouter
initialEntries={entries} initialEntries={entries}
@@ -268,6 +280,7 @@ export function renderApp(ui: ReactElement, opts: AppOptions = {}) {
</ChatProvider> </ChatProvider>
</MemoryRouter> </MemoryRouter>
</CategoriesProvider> </CategoriesProvider>
</CommunityFeedProvider>
</FollowProvider> </FollowProvider>
</WSProvider> </WSProvider>
</PlayerProvider> </PlayerProvider>
@@ -277,3 +290,18 @@ export function renderApp(ui: ReactElement, opts: AppOptions = {}) {
); );
return render(ui, { wrapper: Wrapper }); return render(ui, { wrapper: Wrapper });
} }
/**
* Opens `url` as a visit does: `App`'s own route table inside its providers
* (`renderApp`), so the page under test is whichever one the app routes that
* URL to, behind its guard, and a redirect lands on the real page. A page's
* test opens it this way; mounting the page on a pattern typed in the test
* says nothing of whether the app serves that path. Every page is loaded with
* its route, so the first query after it waits (`findBy`, `waitFor`).
*/
export function renderPage(
url: NonNullable<AppOptions["route"]>,
opts: Pick<AppOptions, "session" | "categories"> = {},
) {
return renderApp(<AppRoutes />, { ...opts, route: url });
}

View File

@@ -1,33 +1,66 @@
/* ══════════════════════════════════════════════════════════════════════ /* ══════════════════════════════════════════════════════════════════════
Brutalist theme — pastel palette, hard edges, bold type Brutalist theme — a pastel palette drawn the hard way: every box is ruled
in ink and casts a solid offset shadow of that ink, type is big and tight,
and the palette's colours come as flat blocks (a teal tag, an apricot
pull-quote, an inked label) rather than as tints.
Palette: Eggshell · Burnt Peach · Twilight Indigo · Muted Teal · Apricot Cream Palette: Eggshell · Burnt Peach · Twilight Indigo · Muted Teal · Apricot Cream
Light scheme → indigo ink on eggshell.
Dark scheme → the same drawing on Twilight Indigo paper: the lines and
the shadows stay dark, a deeper ink than the page, and only
the type turns to eggshell. Reversed into eggshell lines,
every frame on a page glared.
══════════════════════════════════════════════════════════════════════ */ ══════════════════════════════════════════════════════════════════════ */
/* ── Palette (light-dark: see tokens.css) ─────────────────────────── */ /* ── Palette (light-dark: see tokens.css) ─────────────────────────── */
:root[data-style="brutalist"] { :root[data-style="brutalist"] {
--color-text: light-dark(#3d405b, #f4f1de); --color-text: light-dark(#3d405b, #f4f1de);
--color-text-secondary: light-dark(#5a5e80, #c8c5b2); --color-text-secondary: light-dark(#5a5e80, #c8c5b2);
--color-text-muted: light-dark(#7a7e9a, #8e8c80); --color-text-muted: light-dark(#63678a, #b4b1a2);
--color-on-accent: light-dark(#f4f1de, #3d405b); /* Dark ink on the accent in both schemes: eggshell on Burnt Peach had too
--color-bg: light-dark(#f4f1de, #252838); little contrast to read a button by. */
--color-surface: light-dark(#f8f5e8, #2e3145); --color-on-accent: light-dark(#1f2136, #252838);
--color-border: light-dark(#8a8da8, #4a4e6a); /* The dark page is a shade under Twilight Indigo, which its cards are. */
--color-border-subtle: light-dark(rgba(61, 64, 91, 0.25), rgba(74, 78, 106, 0.4)); --color-bg: light-dark(#f4f1de, #33364e);
--color-surface: light-dark(#f8f5e8, #3d405b);
/* The ink every frame and every hard shadow is drawn in. It is darker than
the page in both schemes: on eggshell it is the text's own indigo, on
indigo it is the night under it. What is written on a block of it is
eggshell in either. */
--brut-ink: light-dark(#3d405b, #171829);
--brut-on-ink: #f4f1de;
/* The page's type, under a name of its own: a colored block redefines
--color-text for what is written on it, and a button of the page's
surface set on that block is still written in the page's type. */
--brut-type: var(--color-text);
--color-border: var(--brut-ink);
--color-border-subtle: light-dark(rgba(61, 64, 91, 0.25), rgba(23, 24, 41, 0.45));
--color-accent: light-dark(#e07a5f, #f2cc8f); --color-accent: light-dark(#e07a5f, #f2cc8f);
--color-accent-hover: light-dark(#c05040, #d4a860); --color-accent-hover: light-dark(#c05040, #d4a860);
--color-link: light-dark(#b85a3a, #f2cc8f); --color-link: light-dark(#a3482a, #f2cc8f);
--color-link-hover: light-dark(#943020, #d4a860); --color-link-hover: light-dark(#943020, #d4a860);
--color-danger: light-dark(#c04030, #e07a5f); --color-danger: light-dark(#c04030, #e07a5f);
--color-danger-bg: light-dark(#fae4e0, #3a1e18); --color-danger-bg: light-dark(#fae4e0, #4f2c2c);
--color-danger-hover: light-dark(#a02820, #c85a40); --color-danger-hover: light-dark(#a02820, #c85a40);
--color-success: light-dark(#3a7860, #81b29a); --color-success: light-dark(#3a7860, #81b29a);
--color-success-bg: light-dark(#e0f0ea, #1a2e26); --color-success-bg: light-dark(#e0f0ea, #2b4a45);
--color-success-hover: light-dark(#286048, #5a9878); --color-success-hover: light-dark(#286048, #5a9878);
--color-overlay: light-dark(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.6)); --color-overlay: light-dark(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.6));
--color-header-user-bg: light-dark(rgba(224, 122, 95, 0.08), rgba(242, 204, 143, 0.07)); --color-header-user-bg: light-dark(rgba(224, 122, 95, 0.08), rgba(242, 204, 143, 0.07));
--color-header-user-bg-hover: light-dark(rgba(224, 122, 95, 0.16), rgba(242, 204, 143, 0.14)); --color-header-user-bg-hover: light-dark(rgba(224, 122, 95, 0.16), rgba(242, 204, 143, 0.14));
--color-option-bg: light-dark(#ede9d4, #222436); --color-option-bg: light-dark(#ede9d4, #2d3046);
--color-option-border: light-dark(#e0dcc8, #252838); --color-option-border: light-dark(#e0dcc8, #33364e);
/* The palette's other two colours, used as flat blocks with dark type on
them in either scheme. */
--brut-teal: #81b29a;
--brut-apricot: #f2cc8f;
--brut-on-block: #1f2136;
/* The offset of a box's shadow, and of the one it throws when pointed at. */
--brut-drop: 4px 4px 0;
--brut-drop-lifted: 6px 6px 0;
--card-border: var(--brut-ink);
--card-hover-border: var(--brut-ink);
} }
/* ══════════════════════════════════════════════════════════════════════ /* ══════════════════════════════════════════════════════════════════════
@@ -53,10 +86,42 @@
font-weight: 500; font-weight: 500;
} }
/* Only section-level headings get the uppercase treatment */ /* Headlines are big and set tight, at the heaviest weight Space Grotesk
has (700). Only section labels are capitals. */
[data-style="brutalist"] h1 { [data-style="brutalist"] h1 {
font-weight: 900; font-weight: 700;
letter-spacing: 0.03em; letter-spacing: -0.025em;
}
[data-style="brutalist"] .dump-title {
font-size: clamp(1.9rem, 1.2rem + 2.6vw, 2.75rem);
line-height: 1.05;
}
[data-style="brutalist"] .page-title {
font-size: 2.5rem;
line-height: 1.05;
}
[data-style="brutalist"] .dump-card-title,
[data-style="brutalist"] .playlist-card-title {
font-size: 1.1rem;
font-weight: 700;
letter-spacing: -0.01em;
line-height: 1.25;
}
[data-style="brutalist"] .journal-card-title {
font-weight: 700;
letter-spacing: -0.01em;
}
/* The unread mark is a square, like everything else. */
[data-style="brutalist"] .unread-dot {
width: 9px;
height: 9px;
border-radius: 0;
top: -0.05em;
} }
/* Circles App.css draws with a literal 50% (not the radius scale) — square /* Circles App.css draws with a literal 50% (not the radius scale) — square
@@ -95,7 +160,6 @@
[data-style="brutalist"] .modal-close-btn, [data-style="brutalist"] .modal-close-btn,
[data-style="brutalist"] .playlist-remove-btn, [data-style="brutalist"] .playlist-remove-btn,
[data-style="brutalist"] .playlist-card-delete-btn, [data-style="brutalist"] .playlist-card-delete-btn,
[data-style="brutalist"] .vote-btn,
[data-style="brutalist"] .emoji-picker-float [frimousse-emoji], [data-style="brutalist"] .emoji-picker-float [frimousse-emoji],
[data-style="brutalist"] .emoji-picker-close-btn, [data-style="brutalist"] .emoji-picker-close-btn,
[data-style="brutalist"] .text-editor-emoji-btn, [data-style="brutalist"] .text-editor-emoji-btn,
@@ -108,7 +172,8 @@
[data-style="brutalist"] .user-menu-trigger, [data-style="brutalist"] .user-menu-trigger,
[data-style="brutalist"] .user-menu-item, [data-style="brutalist"] .user-menu-item,
[data-style="brutalist"] .form-cancel, [data-style="brutalist"] .form-cancel,
[data-style="brutalist"] .form-label-action { [data-style="brutalist"] .form-label-action,
[data-style="brutalist"] .inline-edit-mark {
border: none !important; border: none !important;
box-shadow: none !important; box-shadow: none !important;
} }
@@ -120,7 +185,9 @@
[data-style="brutalist"] .form-cancel:hover:not(:disabled), [data-style="brutalist"] .form-cancel:hover:not(:disabled),
[data-style="brutalist"] .form-cancel:active, [data-style="brutalist"] .form-cancel:active,
[data-style="brutalist"] .form-label-action:hover:not(:disabled), [data-style="brutalist"] .form-label-action:hover:not(:disabled),
[data-style="brutalist"] .form-label-action:active { [data-style="brutalist"] .form-label-action:active,
[data-style="brutalist"] .inline-edit-mark:hover:not(:disabled),
[data-style="brutalist"] .inline-edit-mark:active {
transform: none !important; transform: none !important;
box-shadow: none !important; box-shadow: none !important;
} }
@@ -128,7 +195,6 @@
[data-style="brutalist"] .modal-close-btn:hover, [data-style="brutalist"] .modal-close-btn:hover,
[data-style="brutalist"] .playlist-remove-btn:hover, [data-style="brutalist"] .playlist-remove-btn:hover,
[data-style="brutalist"] .playlist-card-delete-btn:hover, [data-style="brutalist"] .playlist-card-delete-btn:hover,
[data-style="brutalist"] .vote-btn:hover:not([aria-disabled="true"]),
[data-style="brutalist"] .emoji-picker-float [frimousse-emoji]:hover, [data-style="brutalist"] .emoji-picker-float [frimousse-emoji]:hover,
[data-style="brutalist"] .emoji-picker-close-btn:hover, [data-style="brutalist"] .emoji-picker-close-btn:hover,
[data-style="brutalist"] .text-editor-emoji-btn:hover:not(:disabled), [data-style="brutalist"] .text-editor-emoji-btn:hover:not(:disabled),
@@ -159,26 +225,139 @@
} }
/* ── Cards ───────────────────────────────────────────────────────── */ /* ── Cards ───────────────────────────────────────────────────────── */
/* A card pointed at lifts off the page, up and left, and the shadow it
leaves behind is the accent's: its frame stays ink. */
[data-style="brutalist"] .dump-card, [data-style="brutalist"] .dump-card,
[data-style="brutalist"] .playlist-card { [data-style="brutalist"] .playlist-card,
border: 2px solid var(--color-border); [data-style="brutalist"] .journal-card {
box-shadow: 3px 3px 0 var(--color-border); border: 2px solid var(--brut-ink);
box-shadow: var(--brut-drop) var(--brut-ink);
transition: transition:
box-shadow 0.08s ease, box-shadow 0.08s ease,
transform 0.08s ease, transform 0.08s ease,
border-color 0.1s,
grid-template-rows 0.32s ease, grid-template-rows 0.32s ease,
opacity 0.25s ease, opacity 0.25s ease,
filter 0.3s ease; filter 0.3s ease;
} }
[data-style="brutalist"] .dump-card:hover, [data-style="brutalist"] .dump-card:hover,
[data-style="brutalist"] .playlist-card:hover { [data-style="brutalist"] .playlist-card:hover,
border-color: var(--color-accent); [data-style="brutalist"] .journal-card:hover {
box-shadow: 3px 3px 0 var(--color-accent); border-color: var(--brut-ink);
box-shadow: var(--brut-drop-lifted) var(--color-accent);
transform: translate(-2px, -2px); transform: translate(-2px, -2px);
} }
/* A thumbnail is framed in ink too, where the base draws a hairline matte. */
[data-style="brutalist"] .dump-card-preview,
[data-style="brutalist"] .playlist-card-preview {
border: 2px solid var(--brut-ink);
box-shadow: none;
}
[data-style="brutalist"] .dump-card-inner:hover .dump-card-preview,
[data-style="brutalist"] .playlist-card-inner:hover .playlist-card-preview {
transform: none;
box-shadow: none;
}
/* The related dumps under a dump keep the frame at full weight, smaller. */
[data-style="brutalist"] .related-section .dump-card {
border-width: 2px;
box-shadow: 3px 3px 0 var(--brut-ink);
opacity: 1;
}
[data-style="brutalist"] .related-section .dump-card-title {
font-size: var(--text-base);
}
/* ── Journal — the mosaic's tiles are boxes too ───────────────────── */
/* The gap grows to hold each tile's shadow clear of its neighbour. */
[data-style="brutalist"] .journal-grid {
gap: 1rem;
}
/* A quote is set on a block of Apricot Cream, a tile with neither picture
nor note on one of Muted Teal, both in dark type whatever the scheme. */
[data-style="brutalist"] .journal-card--quote {
background: var(--brut-apricot);
--color-text: var(--brut-on-block);
--color-text-secondary: var(--brut-on-block);
--color-link: var(--brut-on-block);
--color-link-hover: var(--brut-on-block);
color: var(--brut-on-block);
}
[data-style="brutalist"] .journal-card--text {
background: var(--brut-teal);
--color-text: var(--brut-on-block);
--color-text-secondary: var(--brut-on-block);
color: var(--brut-on-block);
}
[data-style="brutalist"] :is(.journal-card--quote, .journal-card--text) .journal-card-title:hover {
color: var(--brut-on-block);
text-decoration: underline;
text-decoration-thickness: 2px;
}
[data-style="brutalist"] .journal-card-quotemark {
color: var(--brut-on-block);
opacity: 0.25;
}
/* A link in the quoted note would take the accent, which in the dark scheme
is the block's own apricot. */
[data-style="brutalist"] .journal-card-quote-body a {
color: inherit;
}
[data-style="brutalist"] :is(.journal-card--quote, .journal-card--text) .vote-btn:not(.vote-btn--active) {
background: var(--color-surface);
color: var(--brut-type);
}
/* ── Role badges — an admin's is an inked label, like a section's; a
moderator's a block of Muted Teal. The base sets them on the accent
and on the success green in the accent's text colour, which here is
dark type on Burnt Peach and dark on dark. ─────────────────────── */
[data-style="brutalist"] .profile-role-chip {
border: 2px solid var(--brut-ink);
background: var(--brut-ink);
color: var(--brut-on-ink);
}
[data-style="brutalist"] .profile-role-chip--moderator {
background: var(--brut-teal);
color: var(--brut-on-block);
}
/* Follow is written in ink: the accent as a text colour is too pale to read
on eggshell. Followed, or pointed at, it fills with the accent. */
[data-style="brutalist"] .follow-btn {
color: var(--color-text);
}
[data-style="brutalist"] .follow-btn:hover:not(:disabled),
[data-style="brutalist"] .follow-btn--following {
background: var(--color-accent);
color: var(--color-on-accent);
}
/* ── Categories — a tag is a teal block, ruled in ink ─────────────── */
[data-style="brutalist"] .category-link {
border: 2px solid var(--brut-ink);
background: var(--brut-teal);
color: var(--brut-on-block);
font-weight: 700;
}
[data-style="brutalist"] .category-link:hover {
background: var(--color-accent);
color: var(--color-on-accent);
}
/* Don't shift cards that are animating out */ /* Don't shift cards that are animating out */
[data-style="brutalist"] .dump-card--fading, [data-style="brutalist"] .dump-card--fading,
[data-style="brutalist"] .dump-card--dismissing { [data-style="brutalist"] .dump-card--dismissing {
@@ -206,15 +385,54 @@
--journal-title-hover-shadow: 3px 3px 0 var(--color-text); --journal-title-hover-shadow: 3px 3px 0 var(--color-text);
} }
/* ── Dump post detail header ─────────────────────────────────────── */ /* ── A dump's page — every panel is the same box ──────────────────── */
[data-style="brutalist"] .dump-post-header { [data-style="brutalist"] .dump-post-header,
border: 2px solid var(--color-border); [data-style="brutalist"] .dump-rich-content,
box-shadow: 3px 3px 0 var(--color-border); [data-style="brutalist"] .playlist-detail-header:not(.playlist-detail-header--editing),
[data-style="brutalist"] .form-page-header,
[data-style="brutalist"] .form-panel,
[data-style="brutalist"] .comment-top-form,
[data-style="brutalist"] .auth-card {
border: 2px solid var(--brut-ink);
box-shadow: var(--brut-drop) var(--brut-ink);
} }
/* ── App header ──────────────────────────────────────────────────── */ /* Comments nest, and a shadow on each would pile up: the frame alone. */
[data-style="brutalist"] .comment-node-inner {
border: 2px solid var(--brut-ink);
}
[data-style="brutalist"] .dump-comment {
border-left-width: 6px;
opacity: 1;
}
/* ── App header — a heavy rule under it, the site's name in capitals ── */
[data-style="brutalist"] .app-header { [data-style="brutalist"] .app-header {
border-bottom: 2px solid var(--color-border); border-bottom: 3px solid var(--brut-ink);
}
/* Pointed at, the name is stamped like a headline over a photo in the
journal (.journal-card--image .journal-card-title above): a block of the
accent with a hard shadow, here in the ink the header's buttons cast
theirs in. The padding is always there, cancelled by an equal negative
margin, so the block grows around the name without moving it. */
[data-style="brutalist"] .app-header-brand {
text-transform: uppercase;
letter-spacing: 0.06em;
white-space: nowrap;
padding: 0.12em 0.35em;
margin: -0.12em -0.35em;
transition:
color var(--dur-base),
background-color var(--dur-base),
box-shadow var(--dur-base);
}
[data-style="brutalist"] .app-header-brand:hover {
background-color: var(--color-accent);
color: var(--color-on-accent);
box-shadow: 3px 3px 0 var(--brut-ink);
} }
[data-style="brutalist"] .app-header-user { [data-style="brutalist"] .app-header-user {
@@ -250,7 +468,7 @@
[data-style="brutalist"] .modal-card, [data-style="brutalist"] .modal-card,
[data-style="brutalist"] .confirm-modal { [data-style="brutalist"] .confirm-modal {
border: 2px solid var(--color-border); border: 2px solid var(--color-border);
box-shadow: 4px 4px 0 var(--color-border); box-shadow: 8px 8px 0 var(--color-border);
} }
[data-style="brutalist"] .modal-header { [data-style="brutalist"] .modal-header {
@@ -271,8 +489,40 @@
border-bottom-width: 2px; border-bottom-width: 2px;
} }
/* The comments and the related dumps open on the theme's rule, and their
label hangs from it as an inked tab. */
[data-style="brutalist"] .comment-section,
[data-style="brutalist"] .related-section {
padding-top: 0;
}
[data-style="brutalist"] .comment-section-title,
[data-style="brutalist"] .related-section .related-section-title {
width: fit-content;
margin-bottom: 1.25rem;
padding: 0.3rem 0.7rem;
background: var(--brut-ink);
color: var(--brut-on-ink);
}
/* A page group's label is ink, and so is the rule that runs on from it. */
[data-style="brutalist"] .page-group-label {
color: var(--color-text);
}
[data-style="brutalist"] .page-group-label::after {
height: 2px;
background: var(--brut-ink);
}
[data-style="brutalist"] .page-card,
[data-style="brutalist"] .notification-item {
border-color: var(--brut-ink);
}
/* ── Rich content cards ──────────────────────────────────────────── */ /* ── Rich content cards ──────────────────────────────────────────── */
[data-style="brutalist"] .rich-content-card { [data-style="brutalist"] .rich-content-card {
border-width: 2px;
box-shadow: 3px 3px 0 var(--color-border); box-shadow: 3px 3px 0 var(--color-border);
} }
@@ -389,3 +639,17 @@
transform: none !important; transform: none !important;
box-shadow: none !important; box-shadow: none !important;
} }
/* ── Vote — a boxed button like the rest, filled once cast ───────── */
[data-style="brutalist"] .vote-btn--active,
[data-style="brutalist"] .vote-btn--active:hover:not([aria-disabled="true"]) {
border-color: var(--brut-ink);
}
/* Over a photo it keeps the theme's solid face rather than the shared
white-on-glass. */
[data-style="brutalist"] .journal-card--image .journal-card-overlay .vote-btn:not(.vote-btn--active) {
background: var(--color-surface);
color: var(--color-text);
border-color: var(--brut-ink);
}

View File

@@ -1,7 +1,10 @@
/* ══════════════════════════════════════════════════════════════════════ /* ══════════════════════════════════════════════════════════════════════
GeoCities / NeoCities theme — turn-of-the-millennium personal-homepage GeoCities / NeoCities theme — a turn-of-the-millennium personal homepage
maximalism: tiled starfield wallpaper, Comic Sans, ridged 3D boxes, as it looked on the desktop of its day: tiled wallpaper, Comic Sans,
clashing neon accents, rainbow dividers, blinking badges. ridged table boxes, rainbow rules, WordArt headings, a marquee, and the
grey bevelled controls every form button was then, whatever the page
around it. The header is the taskbar, the site's name its Start button,
and a menu or a dialog is drawn as that desktop drew one.
Dark scheme → NeoCities "neon on black" revival. Dark scheme → NeoCities "neon on black" revival.
Light scheme → classic GeoCities pastel-on-wallpaper. Light scheme → classic GeoCities pastel-on-wallpaper.
@@ -27,8 +30,11 @@
--color-border-subtle: light-dark(rgba(208, 0, 208, 0.25), rgba(0, 255, 255, 0.28)); --color-border-subtle: light-dark(rgba(208, 0, 208, 0.25), rgba(0, 255, 255, 0.28));
--color-accent: light-dark(#d000d0, #ff00ff); --color-accent: light-dark(#d000d0, #ff00ff);
--color-accent-hover: light-dark(#a000a0, #ff5cff); --color-accent-hover: light-dark(#a000a0, #ff5cff);
/* The three link colours of an unstyled page: unvisited, visited (below)
and the red of one being pressed, which hovering shows here. */
--color-link: light-dark(#0000ee, #00ffff); --color-link: light-dark(#0000ee, #00ffff);
--color-link-hover: light-dark(#551a8b, #ffff00); --color-link-hover: light-dark(#ee0000, #ffff00);
--geo-visited: light-dark(#551a8b, #e39bff);
--color-danger: light-dark(#cc0033, #ff3860); --color-danger: light-dark(#cc0033, #ff3860);
--color-danger-bg: light-dark(#ffe0e8, #3a0a1a); --color-danger-bg: light-dark(#ffe0e8, #3a0a1a);
--color-danger-hover: light-dark(#990022, #ff6b8a); --color-danger-hover: light-dark(#990022, #ff6b8a);
@@ -40,6 +46,25 @@
--color-header-user-bg-hover: light-dark(rgba(208, 0, 208, 0.16), rgba(0, 255, 255, 0.2)); --color-header-user-bg-hover: light-dark(rgba(208, 0, 208, 0.16), rgba(0, 255, 255, 0.2));
--color-option-bg: light-dark(#fff0fb, #14143a); --color-option-bg: light-dark(#fff0fb, #14143a);
--color-option-border: light-dark(#d000d0, #00ffff); --color-option-border: light-dark(#d000d0, #00ffff);
/* A control's grey face and the four tones of its bevel. They are the
same in both schemes, as a form button was on a page of any colour. */
--geo-face: #c0c0c0;
--geo-face-ink: #000000;
--geo-face-light: #ffffff;
--geo-face-soft: #dfdfdf;
--geo-face-shade: #808080;
--geo-face-dark: #0a0a0a;
--geo-raised: inset -1px -1px 0 var(--geo-face-shade),
inset 1px 1px 0 var(--geo-face-soft);
--geo-sunken: inset 1px 1px 0 var(--geo-face-shade);
/* A pressed toggle's face: the two-pixel checker a 16-colour display
dithered between white and grey. */
--geo-dither: repeating-conic-gradient(#ffffff 0% 25%, #c0c0c0 0% 50%) 0 0 /
2px 2px;
--geo-titlebar: linear-gradient(90deg, #000080, #1084d0);
--geo-select: #000080;
/* The taskbar is grey too. */
--header-bg: var(--geo-face);
--geo-rainbow: linear-gradient( --geo-rainbow: linear-gradient(
90deg, 90deg,
#ff004d, #ff004d,
@@ -55,6 +80,8 @@
radial-gradient(1.5px 1.5px at 170px 40px, #ff00ff, transparent), radial-gradient(1.5px 1.5px at 170px 40px, #ff00ff, transparent),
radial-gradient(1px 1px at 50px 190px, #ffee00, transparent); radial-gradient(1px 1px at 50px 190px, #ffee00, transparent);
--geo-pattern-size: 200px 200px; --geo-pattern-size: 200px 200px;
/* A heading's WordArt fill, kept to hues that read on the page. */
--geo-wordart: linear-gradient(90deg, #ff5cff, #ffee00, #39ff14, #00ffff);
} }
/* Non-colour values can't go through light-dark(), so the light variants /* Non-colour values can't go through light-dark(), so the light variants
@@ -70,9 +97,9 @@
#0077dd, #0077dd,
#8800cc #8800cc
); );
--geo-pattern: radial-gradient(rgba(208, 0, 208, 0.16) 1.5px, transparent 1.5px), --geo-pattern: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cpath fill='%23ffa3e2' d='M20 8l3 9 9 3-9 3-3 9-3-9-9-3 9-3z'/%3E%3Cpath fill='%2399dcff' d='M84 58l2.4 7.6 7.6 2.4-7.6 2.4L84 78l-2.4-7.6-7.6-2.4 7.6-2.4z'/%3E%3Cpath fill='%23d2b6ff' d='M38 82l1.8 5.2 5.2 1.8-5.2 1.8L38 96l-1.8-5.2-5.2-1.8 5.2-1.8z'/%3E%3Ccircle fill='%23ffd866' cx='62' cy='26' r='2.6'/%3E%3Ccircle fill='%23ffa3e2' cx='104' cy='106' r='2.2'/%3E%3Ccircle fill='%2399dcff' cx='8' cy='62' r='1.8'/%3E%3C/svg%3E");
radial-gradient(rgba(0, 179, 255, 0.14) 1.5px, transparent 1.5px); --geo-pattern-size: 120px 120px;
--geo-pattern-size: 22px 22px, 22px 22px; --geo-wordart: linear-gradient(90deg, #e6003f, #d000d0, #0057e0, #008a3a);
} }
} }
@@ -86,9 +113,9 @@
#0077dd, #0077dd,
#8800cc #8800cc
); );
--geo-pattern: radial-gradient(rgba(208, 0, 208, 0.16) 1.5px, transparent 1.5px), --geo-pattern: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cpath fill='%23ffa3e2' d='M20 8l3 9 9 3-9 3-3 9-3-9-9-3 9-3z'/%3E%3Cpath fill='%2399dcff' d='M84 58l2.4 7.6 7.6 2.4-7.6 2.4L84 78l-2.4-7.6-7.6-2.4 7.6-2.4z'/%3E%3Cpath fill='%23d2b6ff' d='M38 82l1.8 5.2 5.2 1.8-5.2 1.8L38 96l-1.8-5.2-5.2-1.8 5.2-1.8z'/%3E%3Ccircle fill='%23ffd866' cx='62' cy='26' r='2.6'/%3E%3Ccircle fill='%23ffa3e2' cx='104' cy='106' r='2.2'/%3E%3Ccircle fill='%2399dcff' cx='8' cy='62' r='1.8'/%3E%3C/svg%3E");
radial-gradient(rgba(0, 179, 255, 0.14) 1.5px, transparent 1.5px); --geo-pattern-size: 120px 120px;
--geo-pattern-size: 22px 22px, 22px 22px; --geo-wordart: linear-gradient(90deg, #e6003f, #d000d0, #0057e0, #008a3a);
} }
/* ══════════════════════════════════════════════════════════════════════ /* ══════════════════════════════════════════════════════════════════════
@@ -96,8 +123,10 @@
══════════════════════════════════════════════════════════════════════ */ ══════════════════════════════════════════════════════════════════════ */
/* ── Typography — the one true homepage font ──────────────────────── */ /* ── Typography — the one true homepage font ──────────────────────── */
/* Comic Sans where the system has it (Windows, macOS), Comic Neue, which
fonts.css bundles, everywhere else. */
[data-style="geocities"] { [data-style="geocities"] {
--font-body: "Comic Sans MS", "Comic Sans", "Chalkboard SE", "Comic Neue", --font-body: "Comic Sans MS", "Comic Sans", "Comic Neue", "Chalkboard SE",
"Marker Felt", cursive; "Marker Felt", cursive;
font-family: var(--font-body); font-family: var(--font-body);
font-weight: 400; font-weight: 400;
@@ -114,24 +143,62 @@
background-attachment: fixed; background-attachment: fixed;
} }
/* ── Loud headings with a soft glow ──────────────────────────────── */ /* ── Headings — WordArt: a gradient run through the letters ───────── */
[data-style="geocities"] h1, /* The fill replaces the text's colour, which an emoji is painted with too:
[data-style="geocities"] .app-header-brand { a title's icon takes its own colour back, or it would vanish. */
[data-style="geocities"] h1 {
width: fit-content;
max-width: 100%;
font-weight: 700; font-weight: 700;
letter-spacing: 0.02em; letter-spacing: 0.02em;
text-shadow: 0 0 6px color-mix(in srgb, var(--color-accent) 60%, transparent); background: var(--geo-wordart);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
filter: drop-shadow(
2px 2px 0 color-mix(in srgb, var(--color-text) 22%, transparent)
);
} }
/* ── Links — always underlined, bold ─────────────────────────────── */ [data-style="geocities"] h1 :is(.page-title-icon, .emoji, img) {
color: var(--color-text);
}
/* ── Links — underlined and bold, purple once followed ────────────── */
[data-style="geocities"] a, [data-style="geocities"] a,
[data-style="geocities"] .md a { [data-style="geocities"] .md a {
text-decoration: underline; text-decoration: underline;
font-weight: 700; font-weight: 700;
} }
/* …except navigation chrome and avatar links, which aren't prose. */ [data-style="geocities"] .md a:visited {
color: var(--geo-visited);
}
/* A card's title is the link to its page, so it is drawn as one: which dumps
were already opened shows in the list, as it did on a page of links. Over a
photo the title stays white on its scrim. */
[data-style="geocities"] :is(.dump-card-title, .playlist-card-title),
[data-style="geocities"] .journal-card:not(.journal-card--image) .journal-card-title {
color: var(--color-link);
}
[data-style="geocities"] :is(.dump-card-title, .playlist-card-title):visited,
[data-style="geocities"] .journal-card:not(.journal-card--image) .journal-card-title:visited {
color: var(--geo-visited);
}
[data-style="geocities"] .dump-card-inner:hover .dump-card-title,
[data-style="geocities"] .playlist-card-inner:hover .playlist-card-title,
[data-style="geocities"] .journal-card:not(.journal-card--image) .journal-card-title:hover {
color: var(--color-link-hover);
}
/* …except navigation chrome, links drawn as buttons and avatar links, which
aren't prose. */
[data-style="geocities"] a:has(.avatar-img), [data-style="geocities"] a:has(.avatar-img),
[data-style="geocities"] a:has(.avatar-initials), [data-style="geocities"] a:has(.avatar-initials),
[data-style="geocities"] a:is(.btn-primary, .btn-secondary, .btn-danger, .btn-border),
[data-style="geocities"] .index-presence-avatar, [data-style="geocities"] .index-presence-avatar,
[data-style="geocities"] .app-header-brand, [data-style="geocities"] .app-header-brand,
[data-style="geocities"] .app-header-user, [data-style="geocities"] .app-header-user,
@@ -140,30 +207,42 @@
text-decoration: none; text-decoration: none;
} }
/* ── Blink + marquee-ish keyframes ───────────────────────────────── */ /* ── Blink, and the marquee's crawl ──────────────────────────────── */
@keyframes geo-blink { @keyframes geo-blink {
50% { 50% {
opacity: 0; opacity: 0;
} }
} }
/* ── Buttons — chunky Win95 outset bevel, press = inset ──────────── */ /* The track is the feed twice over, so sliding it left by half its width
/* `.dump-fab` is exempt from the generic button chrome — it keeps its own ends where it began and the loop has no seam. */
shape, glow and animations — but this squared-off retro theme squares it. */ @keyframes geo-marquee {
to {
translate: -50% 0;
}
}
/* ── Controls — the grey bevelled button of the day ──────────────── */
/* `.dump-fab` and `.chat-fab` are exempt from the button chrome — they keep
their own shape, glow and animations — but this squared-off theme squares
them. */
[data-style="geocities"] .dump-fab, [data-style="geocities"] .dump-fab,
[data-style="geocities"] .chat-fab { [data-style="geocities"] .chat-fab {
--fab-radius: 0; --fab-radius: 0;
} }
[data-style="geocities"] button:not(.dump-fab, .chat-fab) { /* A button with no class of its own (the guest's "Log in") is one of these
border: 3px outset var(--color-border); too. It weighs nothing, so a class that draws a button differently keeps
box-shadow: none; its own background and border. */
:where([data-style="geocities"]) button:where(:not(.dump-fab, .chat-fab)) {
background: var(--geo-face);
color: var(--geo-face-ink);
border: 2px solid;
border-color: var(--geo-face-light) var(--geo-face-dark) var(--geo-face-dark)
var(--geo-face-light);
box-shadow: var(--geo-raised);
font-weight: 700; font-weight: 700;
transition: border-color 0.1s, background 0.12s; transition: none;
}
[data-style="geocities"] button:active:not(:disabled) {
border-style: inset;
} }
[data-style="geocities"] .btn-primary, [data-style="geocities"] .btn-primary,
@@ -176,17 +255,147 @@
[data-style="geocities"] .invite-btn, [data-style="geocities"] .invite-btn,
[data-style="geocities"] .btn-add-playlist, [data-style="geocities"] .btn-add-playlist,
[data-style="geocities"] .load-more-btn, [data-style="geocities"] .load-more-btn,
[data-style="geocities"] .search-bar-btn { [data-style="geocities"] .search-bar-btn,
border: 3px outset var(--color-border); [data-style="geocities"] .new-item-toggle,
box-shadow: none; [data-style="geocities"] .category-chip,
[data-style="geocities"] .app-header-user,
[data-style="geocities"] .nav-search-btn,
[data-style="geocities"] .chat-button,
[data-style="geocities"] .notification-bell,
[data-style="geocities"] .modal-close-btn {
background: var(--geo-face);
color: var(--geo-face-ink);
border: 2px solid;
border-color: var(--geo-face-light) var(--geo-face-dark) var(--geo-face-dark)
var(--geo-face-light);
border-radius: 0;
box-shadow: var(--geo-raised);
font-weight: 700;
opacity: 1;
transform: none;
transition: none;
} }
/* The shared .vote-btn declares `background: transparent`, which leaves the /* A desktop of that day had no hover: the label turning link-blue is the one
chunky outset bevel see-through against the card. Give the resting state a concession to a pointer. */
solid raised-panel fill; the voted (--active) state keeps its accent [data-style="geocities"] .btn-secondary:hover:not(:disabled, [aria-disabled="true"]),
background, so scope this to non-active buttons. */ [data-style="geocities"] .btn-danger:hover:not(:disabled, [aria-disabled="true"]),
[data-style="geocities"] .vote-btn:not(.vote-btn--active) { [data-style="geocities"] .btn-border:hover:not(:disabled, [aria-disabled="true"]),
background: var(--color-surface); [data-style="geocities"] .follow-btn:hover:not(:disabled, [aria-disabled="true"]),
[data-style="geocities"] .vote-btn:hover:not(:disabled, [aria-disabled="true"]),
[data-style="geocities"] .feed-sort-btn:hover:not(:disabled, [aria-disabled="true"]),
[data-style="geocities"] .invite-btn:hover:not(:disabled, [aria-disabled="true"]),
[data-style="geocities"] .btn-add-playlist:hover:not(:disabled, [aria-disabled="true"]),
[data-style="geocities"] .load-more-btn:hover:not(:disabled, [aria-disabled="true"]),
[data-style="geocities"] .search-bar-btn:hover:not(:disabled, [aria-disabled="true"]),
[data-style="geocities"] .new-item-toggle:hover:not(:disabled, [aria-disabled="true"]),
[data-style="geocities"] .category-chip:hover:not(:disabled, [aria-disabled="true"]),
[data-style="geocities"] .app-header-user:hover:not(:disabled, [aria-disabled="true"]),
[data-style="geocities"] .nav-search-btn:hover:not(:disabled, [aria-disabled="true"]),
[data-style="geocities"] .chat-button:hover:not(:disabled, [aria-disabled="true"]),
[data-style="geocities"] .notification-bell:hover:not(:disabled, [aria-disabled="true"]),
[data-style="geocities"] .modal-close-btn:hover:not(:disabled, [aria-disabled="true"]) {
background: var(--geo-face);
color: #0000a8;
border-color: var(--geo-face-light) var(--geo-face-dark) var(--geo-face-dark)
var(--geo-face-light);
transform: none;
}
[data-style="geocities"] .btn-danger:hover:not(:disabled) {
color: #a80000;
}
/* Pressed: the bevel turns inside out and the label moves down and right a
pixel with the face. */
[data-style="geocities"] button:not(.dump-fab, .chat-fab, .btn--ghost, .user-menu-trigger, .user-menu-item, .inline-edit-mark):active:not(:disabled, [aria-disabled="true"]),
[data-style="geocities"] .btn-primary:active:not(:disabled, [aria-disabled="true"]),
[data-style="geocities"] .btn-secondary:active:not(:disabled, [aria-disabled="true"]),
[data-style="geocities"] .btn-border:active:not(:disabled, [aria-disabled="true"]),
[data-style="geocities"] .app-header-user:active:not(:disabled, [aria-disabled="true"]) {
border-color: var(--geo-face-dark) var(--geo-face-light) var(--geo-face-light)
var(--geo-face-dark);
box-shadow: var(--geo-sunken);
transform: translate(1px, 1px);
}
/* A toggle that is on stays pressed, on the dithered face: the open tab, a
vote cast, a user followed, the chosen option of a segmented control. */
[data-style="geocities"] .feed-sort-btn.active,
[data-style="geocities"] .vote-btn--active,
[data-style="geocities"] .follow-btn--following,
[data-style="geocities"] .visibility-toggle button.active,
[data-style="geocities"] .category-chip-toggle--active,
[data-style="geocities"] .nav-search-btn[aria-expanded="true"] {
background: var(--geo-dither);
color: var(--geo-face-ink);
border: 2px solid;
border-color: var(--geo-face-dark) var(--geo-face-light) var(--geo-face-light)
var(--geo-face-dark);
box-shadow: var(--geo-sunken);
}
[data-style="geocities"] :is(.feed-sort-btn.active, .vote-btn--active, .follow-btn--following):hover:not(:disabled, [aria-disabled="true"]) {
background: var(--geo-dither);
border-color: var(--geo-face-dark) var(--geo-face-light) var(--geo-face-light)
var(--geo-face-dark);
}
/* Greyed out: the label is engraved, a shade over its own white shadow. */
[data-style="geocities"] :is(button, .btn-primary, .btn-secondary, .vote-btn):is(:disabled, [aria-disabled="true"]):not(.btn--ghost, .dump-fab, .chat-fab) {
color: var(--geo-face-shade);
text-shadow: 1px 1px 0 var(--geo-face-light);
opacity: 1;
}
/* The one loud button: the same bevel cut from the accent, its lit and
shaded edges mixed from it. */
[data-style="geocities"] .btn-primary,
[data-style="geocities"] .btn-primary:hover:not(:disabled) {
background: var(--color-accent);
color: var(--color-on-accent);
border-color: color-mix(in srgb, var(--color-accent) 40%, #ffffff)
color-mix(in srgb, var(--color-accent) 35%, #000000)
color-mix(in srgb, var(--color-accent) 35%, #000000)
color-mix(in srgb, var(--color-accent) 40%, #ffffff);
box-shadow: inset -1px -1px 0 color-mix(in srgb, var(--color-accent) 65%,
#000000),
inset 1px 1px 0 color-mix(in srgb, var(--color-accent) 70%, #ffffff);
text-shadow: none;
}
[data-style="geocities"] .btn-primary:hover:not(:disabled) {
background: var(--color-accent-hover);
}
[data-style="geocities"] .btn-primary:disabled {
background: var(--geo-face);
border-color: var(--geo-face-light) var(--geo-face-dark) var(--geo-face-dark)
var(--geo-face-light);
box-shadow: var(--geo-raised);
}
/* The header's dump button presses like the rest, so it neither lifts nor
rocks. */
[data-style="geocities"] .app-header-nav .btn-primary:hover:not(:disabled) {
translate: none;
animation: none;
}
/* Over a photo the shared rule turns a vote into white on glass; here it is
a grey button wherever it sits. */
[data-style="geocities"] .journal-card--image .journal-card-overlay .vote-btn:not(.vote-btn--active),
[data-style="geocities"] .journal-card--image .journal-card-overlay .vote-btn:not(.vote-btn--active):hover:not([aria-disabled="true"]) {
background: var(--geo-face);
color: var(--geo-face-ink);
border-color: var(--geo-face-light) var(--geo-face-dark) var(--geo-face-dark)
var(--geo-face-light);
}
/* Keyboard focus is the dotted rectangle inside the face. */
[data-style="geocities"] :is(button, .btn-primary, .btn-secondary, .btn-border, .app-header-user):focus-visible {
outline: 1px dotted var(--geo-face-ink);
outline-offset: -5px;
} }
/* Turn the tint up and give it hard diagonal bands — a flat wash reads as an /* Turn the tint up and give it hard diagonal bands — a flat wash reads as an
@@ -204,7 +413,6 @@
} }
/* Ghost / icon-only buttons stay flat */ /* Ghost / icon-only buttons stay flat */
[data-style="geocities"] .modal-close-btn,
[data-style="geocities"] .playlist-remove-btn, [data-style="geocities"] .playlist-remove-btn,
[data-style="geocities"] .playlist-card-delete-btn, [data-style="geocities"] .playlist-card-delete-btn,
[data-style="geocities"] .emoji-picker-float [frimousse-emoji], [data-style="geocities"] .emoji-picker-float [frimousse-emoji],
@@ -212,7 +420,6 @@
[data-style="geocities"] .text-editor-emoji-btn, [data-style="geocities"] .text-editor-emoji-btn,
[data-style="geocities"] .emoji-picker-float [frimousse-skin-tone-selector], [data-style="geocities"] .emoji-picker-float [frimousse-skin-tone-selector],
[data-style="geocities"] .btn--ghost, [data-style="geocities"] .btn--ghost,
[data-style="geocities"] .new-item-toggle,
[data-style="geocities"] .audio-player-btn, [data-style="geocities"] .audio-player-btn,
[data-style="geocities"] .audio-player-vol-btn, [data-style="geocities"] .audio-player-vol-btn,
[data-style="geocities"] .rich-content-thumbnail-btn, [data-style="geocities"] .rich-content-thumbnail-btn,
@@ -225,22 +432,33 @@
box-shadow: none !important; box-shadow: none !important;
} }
/* ── Segmented controls ──────────────────────────────────────────── */ /* ── Segmented controls — a sunken well of flat options ───────────── */
[data-style="geocities"] .visibility-toggle { [data-style="geocities"] .visibility-toggle {
border: 3px ridge var(--color-border); background: var(--geo-face);
border: 2px solid;
border-color: var(--geo-face-shade) var(--geo-face-light)
var(--geo-face-light)
var(--geo-face-shade);
padding: 2px;
} }
[data-style="geocities"] .visibility-toggle button { [data-style="geocities"] .visibility-toggle button {
/* Zero the width (not just the style): the generic `button:active` rule flips background: var(--geo-face);
border-style to `inset`, and a `none` border keeps its `medium` width — so color: var(--geo-face-ink);
on press a 3px border would reappear inside the toggle's own frame. */ border: 2px solid transparent;
border: 0; box-shadow: none;
}
[data-style="geocities"] .visibility-toggle button:not(.active):hover {
background: var(--geo-face);
color: #0000a8;
} }
/* ── Cards & panels — ridged "table border" with a soft neon halo ─── */ /* ── Cards & panels — ridged "table border" with a soft neon halo ─── */
[data-style="geocities"] .dump-card, [data-style="geocities"] .dump-card,
[data-style="geocities"] .playlist-card, [data-style="geocities"] .playlist-card,
[data-style="geocities"] .dump-post-header, [data-style="geocities"] .dump-post-header,
[data-style="geocities"] .dump-rich-content,
[data-style="geocities"] .auth-card, [data-style="geocities"] .auth-card,
[data-style="geocities"] .notification-item, [data-style="geocities"] .notification-item,
[data-style="geocities"] .page-card, [data-style="geocities"] .page-card,
@@ -270,6 +488,23 @@
box-shadow: 0 0 16px color-mix(in srgb, var(--color-accent) 75%, transparent); box-shadow: 0 0 16px color-mix(in srgb, var(--color-accent) 75%, transparent);
} }
/* An unread dump wears the "new!" burst a page pinned on its latest link: a
twelve-pointed star, blinking. */
[data-style="geocities"] .unread-dot {
width: 13px;
height: 13px;
top: 0.05em;
border-radius: 0;
background: light-dark(#ff004d, #ffee00);
clip-path: polygon(
50% 0%, 61% 14%, 79% 7%, 80% 26%, 98% 31%, 87% 46%,
100% 61%, 82% 67%, 82% 86%, 64% 82%, 50% 100%, 36% 82%,
18% 86%, 18% 67%, 0% 61%, 13% 46%, 2% 31%, 20% 26%,
21% 7%, 39% 14%
);
animation: geo-blink 1.3s step-start infinite;
}
/* A pointed-at headline over a photo lights up like a marquee: neon yellow /* A pointed-at headline over a photo lights up like a marquee: neon yellow
on a black plate, ringed and glowing in the accent. */ on a black plate, ringed and glowing in the accent. */
[data-style="geocities"] .journal-card--image .journal-card-title { [data-style="geocities"] .journal-card--image .journal-card-title {
@@ -294,15 +529,141 @@
letter-spacing: 0.15em; letter-spacing: 0.15em;
} }
/* ── App header — rainbow divider ────────────────────────────────── */ /* A list's end is "under construction": hazard tape above and below the
line that says there is no more. */
[data-style="geocities"] .feed-end {
width: fit-content;
max-width: calc(100% - 2rem);
margin: 1.75rem auto 0;
padding: 0.6rem 1.25rem;
border-block: 10px solid;
border-image: repeating-linear-gradient(
-45deg,
#111111 0 10px,
#ffcc00 10px 20px
) 10;
background: var(--color-surface);
color: var(--color-text);
font-weight: 700;
opacity: 1;
}
[data-style="geocities"] .feed-end::before,
[data-style="geocities"] .feed-end::after {
content: "🚧";
margin-inline: 0.6em;
}
/* ── App header — the taskbar ────────────────────────────────────── */
/* Grey in both schemes, lit along its top edge, with the rainbow rule under
it. What it holds is drawn in the controls' ink rather than the page's, so
the text tokens are set again for everything inside it. */
[data-style="geocities"] .app-header, [data-style="geocities"] .app-header,
[data-style="geocities"] .header-search-below { [data-style="geocities"] .header-search-below {
--color-text: var(--geo-face-ink);
--color-text-secondary: #202020;
--color-text-muted: #404040;
color: var(--geo-face-ink);
box-shadow: inset 0 2px 0 var(--geo-face-light);
border-bottom: 4px solid; border-bottom: 4px solid;
border-image: var(--geo-rainbow) 1; border-image: var(--geo-rainbow) 1;
} }
[data-style="geocities"] .app-header-user { /* The site's name is the Start button. */
border: 3px outset var(--color-border); [data-style="geocities"] .app-header-brand,
[data-style="geocities"] .app-header-brand:hover {
display: inline-flex;
align-items: center;
gap: 0.35em;
padding: 0.2rem 0.6rem 0.2rem 0.45rem;
background: var(--geo-face);
color: var(--geo-face-ink);
border: 2px solid;
border-color: var(--geo-face-light) var(--geo-face-dark) var(--geo-face-dark)
var(--geo-face-light);
box-shadow: var(--geo-raised);
font-size: var(--text-base);
font-weight: 700;
letter-spacing: 0;
white-space: nowrap;
}
[data-style="geocities"] .app-header-brand:active {
border-color: var(--geo-face-dark) var(--geo-face-light) var(--geo-face-light)
var(--geo-face-dark);
box-shadow: var(--geo-sunken);
}
/* A search field on the taskbar is a white well, typed in black. */
[data-style="geocities"] .app-header .search-bar-input,
[data-style="geocities"] .header-search-below .search-bar-input {
background: #ffffff !important;
color: #000000;
}
/* ── The marquee — the community feed crawling under the taskbar ─── */
/* The band keeps its height while it is empty, before the feed has loaded. */
[data-style="geocities"] .site-marquee {
overflow: hidden;
white-space: nowrap;
background: light-dark(#ffffcc, #000000);
color: light-dark(#cc0033, #39ff14);
border-bottom: 2px solid var(--color-border);
font-weight: 700;
font-size: var(--text-sm);
line-height: 1.9;
min-height: 1.9em;
}
/* Marquee.tsx sets --marquee-length, one copy's width in pixels: a loop lasts
what it takes to cross it at 50 pixels a second, however much the feed
holds. */
[data-style="geocities"] .site-marquee-track {
display: inline-block;
animation: geo-marquee calc(var(--marquee-length, 2000) * 1s / 50) linear
infinite;
}
[data-style="geocities"] .site-marquee-track > *,
[data-style="geocities"] .site-marquee .community-feed {
display: inline-block;
}
[data-style="geocities"] .site-marquee .community-feed-item {
display: inline;
}
/* A star before each line, the first included, so that the two copies meet
on one as well. */
[data-style="geocities"] .site-marquee .community-feed-item::before {
content: "✦";
margin-inline: 1.25rem;
}
[data-style="geocities"] .site-marquee a {
color: inherit;
}
[data-style="geocities"] .site-marquee a:hover {
color: var(--color-link-hover);
}
[data-style="geocities"] .site-marquee .community-feed-time {
font-weight: 400;
opacity: 0.75;
}
/* The crawl stops under the pointer, and for a link the keyboard has
focused: the line then stands at its start, and the browser scrolls it to
that link. A link the pointer presses takes the focus too, but is left
where it is, paused under the pointer: brought back to the start, it would
slip away before the button came up, and the click with it. */
[data-style="geocities"] .site-marquee:hover .site-marquee-track {
animation-play-state: paused;
}
[data-style="geocities"] .site-marquee:has(:focus-visible) .site-marquee-track {
animation: none;
} }
/* ── Rainbow dividers — on each element's real edge ──────────────── */ /* ── Rainbow dividers — on each element's real edge ──────────────── */
@@ -325,6 +686,25 @@
letter-spacing: 0.06em; letter-spacing: 0.06em;
} }
/* A label that sits straight on the wallpaper gets a strip of the page's
plain colour behind it, so no star runs through its letters. */
[data-style="geocities"] .comment-section-title,
[data-style="geocities"] .related-section-title,
[data-style="geocities"] .dump-actions a,
[data-style="geocities"] .empty-state {
width: fit-content;
padding-inline: 0.4em;
background: var(--color-bg);
color: var(--color-text-secondary);
opacity: 1;
}
/* A page group's label keeps its row, which its trailing rule fills. */
[data-style="geocities"] .page-group-label {
background: var(--color-bg);
color: var(--color-text-secondary);
}
/* ── Inputs / textareas — sunken inset field ─────────────────────── */ /* ── Inputs / textareas — sunken inset field ─────────────────────── */
[data-style="geocities"] input:not([type="range"]):not([type="file"]):not([type="checkbox"]):not([type="radio"]), [data-style="geocities"] input:not([type="range"]):not([type="file"]):not([type="checkbox"]):not([type="radio"]),
[data-style="geocities"] textarea { [data-style="geocities"] textarea {
@@ -333,19 +713,80 @@
background-color: var(--color-bg); background-color: var(--color-bg);
} }
/* ── Modals — heavy ridged frame ─────────────────────────────────── */ /* ── Dialogs — a window: grey frame, blue title bar ──────────────── */
[data-style="geocities"] .modal-card, [data-style="geocities"] .modal-card,
[data-style="geocities"] .confirm-modal, [data-style="geocities"] .confirm-modal {
[data-style="geocities"] .user-menu-dropdown, border: 3px outset var(--geo-face);
[data-style="geocities"] .user-list-popover,
[data-style="geocities"] .mention-dropdown,
[data-style="geocities"] .emoji-picker-float {
border: 3px ridge var(--color-border);
box-shadow: 0 0 18px color-mix(in srgb, var(--color-border) 45%, transparent); box-shadow: 0 0 18px color-mix(in srgb, var(--color-border) 45%, transparent);
} }
[data-style="geocities"] .modal-header { [data-style="geocities"] .modal-header {
border-bottom: 2px solid var(--color-border-subtle); padding: 0.3rem 0.35rem 0.3rem 0.6rem;
background: var(--geo-titlebar);
border-bottom: none;
}
[data-style="geocities"] .modal-title {
color: #ffffff;
}
[data-style="geocities"] .modal-close-btn {
width: 1.5rem;
height: 1.35rem;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: var(--text-xs);
}
/* ── Menus — a grey sheet, the pointed-at row in the selection's navy ── */
[data-style="geocities"] .user-menu-dropdown,
[data-style="geocities"] .category-switcher-menu,
[data-style="geocities"] .user-list-popover,
[data-style="geocities"] .mention-dropdown {
--color-text: var(--geo-face-ink);
--color-text-secondary: #202020;
--color-text-muted: #404040;
background: var(--geo-face);
color: var(--geo-face-ink);
border: 2px solid;
border-color: var(--geo-face-light) var(--geo-face-dark) var(--geo-face-dark)
var(--geo-face-light);
box-shadow: var(--geo-raised), 3px 3px 0 rgb(0 0 0 / 0.35);
padding: 2px;
gap: 0;
}
[data-style="geocities"] :is(.user-menu-item, .user-list-popover-item, .category-switcher-option) {
background: transparent;
color: var(--geo-face-ink);
border-radius: 0;
transition: none;
}
[data-style="geocities"] .user-menu .user-menu-item:hover,
[data-style="geocities"] .user-list-popover-item:hover,
[data-style="geocities"] .category-switcher-option:hover:not(:disabled),
[data-style="geocities"] .category-switcher-option--active {
background: var(--geo-select);
color: #ffffff;
}
[data-style="geocities"] .user-menu-separator {
border-top: 1px solid var(--geo-face-shade);
border-bottom: 1px solid var(--geo-face-light);
}
[data-style="geocities"] .category-switcher-option {
border: none !important;
box-shadow: none !important;
}
/* The emoji picker keeps the page's colours, in the cards' ridged frame. */
[data-style="geocities"] .emoji-picker-float {
border: 3px ridge var(--color-border);
box-shadow: 0 0 18px color-mix(in srgb, var(--color-border) 45%, transparent);
} }
/* ── Avatars — framed snapshots ──────────────────────────────────── */ /* ── Avatars — framed snapshots ──────────────────────────────────── */
@@ -373,8 +814,6 @@
box-shadow: 0 0 16px color-mix(in srgb, var(--color-accent) 55%, transparent); box-shadow: 0 0 16px color-mix(in srgb, var(--color-accent) 55%, transparent);
} }
/* Queue UI: square off the artwork and the stream progress bar. */
[data-style="geocities"] .global-player .audio-player-btn { [data-style="geocities"] .global-player .audio-player-btn {
color: var(--color-on-accent); color: var(--color-on-accent);
} }
@@ -389,8 +828,11 @@
border-style: dashed; border-style: dashed;
} }
/* ── Pills / badges — squared, and the unread bits blink ─────────── */ [data-style="geocities"] .fdz__upload-icon {
opacity: 0.7;
}
/* ── Categories ──────────────────────────────────────────────────── */
/* The editor is one squared box; flatten its fields (geocities otherwise /* The editor is one squared box; flatten its fields (geocities otherwise
forces a sunken inset border on every input) and drop the soft glow. */ forces a sunken inset border on every input) and drop the soft glow. */
[data-style="geocities"] .category-editor { [data-style="geocities"] .category-editor {
@@ -403,25 +845,31 @@
background: transparent !important; background: transparent !important;
} }
/* Chips are tags, not raised Win95 buttons — keep a clean flat border /* ── What is unread blinks ───────────────────────────────────────── */
instead of the generic 3px outset bevel. */
[data-style="geocities"] .category-chip {
border: 2px solid var(--color-border);
box-shadow: none;
}
[data-style="geocities"] .notification-badge { [data-style="geocities"] .notification-badge {
animation: geo-blink 1.1s step-start infinite; animation: geo-blink 1.1s step-start infinite;
} }
[data-style="geocities"] .notif-dot, [data-style="geocities"] .notif-dot {
[data-style="geocities"] .unread-dot {
animation: geo-blink 1.3s step-start infinite; animation: geo-blink 1.3s step-start infinite;
} }
/* ── Floating "new" affordance / FAB-style accents ───────────────── */ /* Nothing blinks or crawls for someone who asked for less motion: the
[data-style="geocities"] .fdz__upload-icon { marquee then shows its line once and still, from its start, and scrolls by
opacity: 0.7; hand to the rest. */
@media (prefers-reduced-motion: reduce) {
[data-style="geocities"] :is(.notification-badge, .notif-dot, .unread-dot) {
animation: none;
}
[data-style="geocities"] .site-marquee {
overflow-x: auto;
scrollbar-width: none;
}
[data-style="geocities"] .site-marquee-track {
animation: none;
}
} }
/* ── Casual web-1.0 voice: lowercase everything (code excepted) ──── */ /* ── Casual web-1.0 voice: lowercase everything (code excepted) ──── */
@@ -437,19 +885,6 @@
text-transform: none; text-transform: none;
} }
/* ── Category switcher dropdown ──────────────────────────────────── */
/* Same ridged frame as the other popovers; option buttons flattened so the
chunky Win95 button bevel doesn't apply to menu rows. (The trigger reuses
.feed-sort-btn and keeps the bevel, matching the sort tabs.) */
[data-style="geocities"] .category-switcher-menu {
border: 3px ridge var(--color-border);
box-shadow: 0 0 18px color-mix(in srgb, var(--color-border) 45%, transparent);
}
[data-style="geocities"] .category-switcher-option {
border: none !important;
box-shadow: none !important;
}
/* Circles App.css draws with a literal 50% (not the radius scale). */ /* Circles App.css draws with a literal 50% (not the radius scale). */
[data-style="geocities"] .notif-icon, [data-style="geocities"] .notif-icon,
[data-style="geocities"] .notif-dot { [data-style="geocities"] .notif-dot {

View File

@@ -8,6 +8,8 @@
accent) — the yellow paper is the point, keep it warm. accent) — the yellow paper is the point, keep it warm.
Syntax hues carry the character: fujiGray italic "comments" for dates, Syntax hues carry the character: fujiGray italic "comments" for dates,
springGreen "strings" for quotes, oniViolet "keywords" for the brand. springGreen "strings" for quotes, oniViolet "keywords" for the brand.
A section opens on a status line, as a buffer ends on one. The page is
flat, the editor's own ground: the cards and the type carry the scheme.
══════════════════════════════════════════════════════════════════════ */ ══════════════════════════════════════════════════════════════════════ */
/* ── Palette (light-dark: see tokens.css) ─────────────────────────── */ /* ── Palette (light-dark: see tokens.css) ─────────────────────────── */
@@ -32,10 +34,13 @@
/* Surfaces — sunken < bg < surface < raised. /* Surfaces — sunken < bg < surface < raised.
Wave: sumiInk0 · sumiInk3 · sumiInk4 · sumiInk5. Wave: sumiInk0 · sumiInk3 · sumiInk4 · sumiInk5.
Lotus: lotusWhite1 · lotusWhite3 · lotusWhite2 · lotusWhite3 — paper Lotus: lotusWhite1 · lotusWhite4 · lotusWhite3 · lotusWhite3. What is
darkens where the editor draws gutters and floats. */ read sits on lotusWhite3, the editor's own paper and the
--color-bg: light-dark(#f2ecbc, #1f1f28); brightest the scheme has, and the page around the cards is the
--color-surface: light-dark(#e7dfb2, #2a2a37); shade darker the editor gives its window chrome: the ink loses a
fifth of its contrast on the darker sheet. */
--color-bg: light-dark(#e7dfb2, #1f1f28);
--color-surface: light-dark(#f2ecbc, #2a2a37);
--color-surface-raised: light-dark(#f2ecbc, #363646); --color-surface-raised: light-dark(#f2ecbc, #363646);
--color-surface-sunken: light-dark(#dcd5ac, #16161d); --color-surface-sunken: light-dark(#dcd5ac, #16161d);
@@ -91,15 +96,17 @@
); );
/* Theme-only hues, straight from the syntax groups: /* Theme-only hues, straight from the syntax groups:
comment fujiGray / lotusGray3 · keyword oniViolet / lotusViolet4 comment fujiGray / lotusGray2 · keyword oniViolet / lotusViolet4
string springGreen / lotusGreen · number sakuraPink / lotusPink string springGreen / lotusGreen · number sakuraPink / lotusPink
chrome sumiInk1 / lotusWhite1 (sidebars, tabline) chrome sumiInk1 / lotusWhite0 (sidebars, tabline, status line)
visual waveBlue1 / lotusViolet3 (selection) */ visual waveBlue1 / lotusViolet3 (selection)
--kngw-comment: light-dark(#8a8980, #727169); Lotus's own comment grey, lotusGray3, is too faint for a date set this
small, so the light scheme takes the next grey down. */
--kngw-comment: light-dark(#716e61, #727169);
--kngw-keyword: light-dark(#624c83, #957fb8); --kngw-keyword: light-dark(#624c83, #957fb8);
--kngw-string: light-dark(#6f894e, #98bb6c); --kngw-string: light-dark(#6f894e, #98bb6c);
--kngw-number: light-dark(#b35b79, #d27e99); --kngw-number: light-dark(#b35b79, #d27e99);
--kngw-chrome: light-dark(#e5ddb0, #181820); --kngw-chrome: light-dark(#d5cea3, #181820);
--kngw-selection: light-dark(#c9cbd1, #223249); --kngw-selection: light-dark(#c9cbd1, #223249);
/* Over a photo the ground is the black scrim in either scheme, so the /* Over a photo the ground is the black scrim in either scheme, so the
accent there stays Wave's crystalBlue — Lotus's lotusBlue4 is picked to accent there stays Wave's crystalBlue — Lotus's lotusBlue4 is picked to
@@ -133,12 +140,17 @@
background: var(--kngw-selection); background: var(--kngw-selection);
} }
/* Brand as a keyword — oniViolet, italic like kanagawa's keywordStyle */ /* Brand as a keyword — oniViolet, italic like kanagawa's keywordStyle.
Pointed at, it takes the accent, as a card's title does. */
[data-style="kanagawa"] .app-header-brand { [data-style="kanagawa"] .app-header-brand {
color: var(--kngw-keyword); color: var(--kngw-keyword);
font-style: italic; font-style: italic;
} }
[data-style="kanagawa"] .app-header-brand:hover {
color: var(--color-accent);
}
/* ── Header — the tabline: a shade off the page, split by a plain rule ── */ /* ── Header — the tabline: a shade off the page, split by a plain rule ── */
[data-style="kanagawa"] .app-header { [data-style="kanagawa"] .app-header {
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
@@ -190,8 +202,8 @@
color: var(--kngw-string); color: var(--kngw-string);
} }
/* Sub-blocks part on a comment divider — the dashed rule and the ellipsis a /* Sub-blocks part on a comment divider: the dashed rule a source file marks
source file uses to mark a section, set in the mono face the theme reads in. */ a section with, and its three dots. */
[data-style="kanagawa"] .journal-break { [data-style="kanagawa"] .journal-break {
--journal-break-space: 1.9rem; --journal-break-space: 1.9rem;
--journal-break-rule: 1px dashed var(--color-border); --journal-break-rule: 1px dashed var(--color-border);
@@ -202,3 +214,33 @@
[data-style="kanagawa"] .vote-btn:not(.vote-btn--active) { [data-style="kanagawa"] .vote-btn:not(.vote-btn--active) {
color: var(--kngw-number); color: var(--kngw-number);
} }
/* ── Status lines — the comments and the related dumps open on one ── */
/* The bar is the section's own background, a strip of the chrome as tall as
the label, and the label is the mode block at its left end: the accent,
bold, as an editor shows NORMAL. */
[data-style="kanagawa"] .comment-section,
[data-style="kanagawa"] .related-section {
--kngw-status-height: 1.7rem;
padding-top: 0;
border-top: none;
background: linear-gradient(var(--kngw-chrome), var(--kngw-chrome)) top /
100% var(--kngw-status-height) no-repeat;
}
[data-style="kanagawa"] .comment-section-title,
[data-style="kanagawa"] .related-section .related-section-title {
display: inline-block;
height: var(--kngw-status-height);
margin-bottom: 1.1rem;
padding: 0 0.8rem;
background: var(--color-accent);
color: var(--color-on-accent);
line-height: var(--kngw-status-height);
letter-spacing: 0.06em;
}
/* A dump's note is read at length: full ink, where the base dims it. */
[data-style="kanagawa"] .dump-comment {
opacity: 1;
}

View File

@@ -1,32 +1,32 @@
/* ══════════════════════════════════════════════════════════════════════ /* ══════════════════════════════════════════════════════════════════════
Melange theme — after savq/melange-nvim, whose palette is pure 1970s Melange theme — a blend, which is all the name means now. It began as a
desert: toasted amber, terracotta, olive, dusty rose, laid on roasted port of savq/melange-nvim and kept that scheme's idea rather than its
coffee (dark) or warm plaster (light). browns: a handful of hues that only make sense mixed. Here they are a
sorbet's (pink, coral, lilac, sky, mint), run together as one gradient
along the header, through the site's name and between a journal's blocks,
on grounds of berry at night and of blush paper by day.
So this is not an editor skin — it's a warm print. The scheme's own The rest is a print of it: film grain over the whole page, pillowy
colours are kept exactly; what's built on them is analog: a soft borderless cards that sit in a tinted shadow rather than inside a line,
Windsor-ish serif, film grain over the whole page, pillowy borderless a glow under whatever is active, and one quirky grotesque for all of it.
cards that sit in warm shadow rather than inside a line, and a spice
gradient — the mélange itself, every hue blended — along the header.
Dark scheme → melange dark (#292522 grounds, cream ink). The two schemes are one print and its negative. They trade ink and paper:
Light scheme → melange light (plaster, and every ink is a brown). the night's lilac-white type is the day's page, the night's plum ground
the day's type. What doesn't trade is the sorbet: the accent is the same
pink in both, steeped a little deeper by day so that it holds on paper,
and what is written on it is the same plum. A pill, a button, the stripe
under the header are the same objects in either.
Dark scheme → berry (plum grounds, lilac-white ink).
Light scheme → the same on paper (lilac-white grounds, plum ink).
══════════════════════════════════════════════════════════════════════ */ ══════════════════════════════════════════════════════════════════════ */
/* ── Palette (light-dark: see tokens.css) ─────────────────────────── */ /* ── Palette (light-dark: see tokens.css) ─────────────────────────── */
:root[data-style="melange"] { :root[data-style="melange"] {
/* One family, soft-serif: Fraunces sits at the Souvenir/Cooper end of /* One family. fonts.css loads its weight axis alone, so it is the same
the 70s, which is exactly where this palette lives. cut at every size. */
--font-body: "Bricolage Grotesque Variable", system-ui, sans-serif;
Its optical-size axis is the catch — left on `auto`, a headline is --font-display: var(--font-body);
cut as a high-contrast Didone while the caption under it is a sturdy
slab, and one family starts reading as three. So opsz is pinned low
and everywhere: sturdy, low-contrast, warm at every size. Any rule
that touches SOFT or WONK repeats opsz, since font-variation-settings
replaces the whole list rather than merging into it. */
--font-body: "Fraunces Variable", Georgia, serif;
--font-display: "Fraunces Variable", Georgia, serif;
--mel-opsz: 14;
/* Nothing here has a sharp corner. */ /* Nothing here has a sharp corner. */
--radius-xs: 5px; --radius-xs: 5px;
@@ -35,121 +35,119 @@
--radius-lg: 16px; --radius-lg: 16px;
--radius-xl: 22px; --radius-xl: 22px;
/* Text — a.fg / a.com / a.ui */ /* Text — by day, the night's card colour. On the accent it is plum in
--color-text: light-dark(#54433a, #ece1d7); both schemes. */
--color-text-secondary: light-dark(#7d6658, #c1a78e); --color-text: light-dark(#2b2338, #f1e8f3);
--color-text-muted: light-dark(#a98a78, #867462); --color-text-secondary: light-dark(#564769, #c8b8d2);
--color-on-accent: light-dark(#f7f4f1, #292522); --color-text-muted: light-dark(#746489, #a898b6);
--color-on-accent: #2a1830;
/* Surfaces — sunken < bg < surface < raised. /* Surfaces — sunken < bg < surface < raised: the page is the tinted
Dark: below a.bg · a.bg · a.float · a.sel. ground in both schemes and a card the lighter sheet on it. By day the
Light: a.sel is the well, a.float is the *page* — melange's light page is the night's type colour. */
side keeps its warmth in the floats, so on a page made of --color-bg: light-dark(#f1e8f3, #211a2b);
cards the plaster belongs underneath and the cards are the --color-surface: light-dark(#fbf7fc, #2b2338);
bleached tints above it. Reversing those two tiers is what --color-surface-raised: light-dark(#fffcff, #372d47);
keeps the light scheme from reading as grey. */ --color-surface-sunken: light-dark(#e3d6e7, #19131f);
--color-bg: light-dark(#e9e1db, #292522);
--color-surface: light-dark(#f6f2ef, #34302c);
--color-surface-raised: light-dark(#fdfbf9, #403a36);
--color-surface-sunken: light-dark(#d9d3ce, #201d1b);
/* Borders — a.ui, but mostly unused: this theme separates by warmth /* Borders — mostly unused: this theme separates by tint and shadow, not
and shadow, not by line. */ by line. */
--color-border: light-dark(rgb(169 138 120 / 0.38), rgb(134 116 98 / 0.45)); --color-border: light-dark(rgb(117 98 131 / 0.34), rgb(168 152 182 / 0.36));
--color-border-subtle: light-dark( --color-border-subtle: light-dark(
rgb(169 138 120 / 0.18), rgb(117 98 131 / 0.16),
rgb(134 116 98 / 0.22) rgb(168 152 182 / 0.18)
); );
/* Accent — b.yellow, the spice. Hover walks it toward c.yellow's burn. */ /* Accent — one pink, deeper on paper than at night. Hovering walks it one
--color-accent: light-dark(#a06d00, #ebc06d); step along the blend, to the coral. */
--color-accent-hover: light-dark(#bc5c00, #e49b5d); --color-accent: light-dark(#e2568f, #f48fb1);
--color-accent-hover: light-dark(#ea6a45, #ff9e80);
--color-accent-soft: light-dark( --color-accent-soft: light-dark(
rgb(160 109 0 / 0.13), rgb(226 86 143 / 0.12),
rgb(235 192 109 / 0.15) rgb(244 143 177 / 0.14)
); );
--color-focus-ring: light-dark( --color-focus-ring: light-dark(
rgb(160 109 0 / 0.35), rgb(226 86 143 / 0.4),
rgb(235 192 109 / 0.4) rgb(244 143 177 / 0.42)
); );
/* Links */ /* Links — a line of text needs more ink than a pill: the pink again,
--color-link: light-dark(#a06d00, #ebc06d); steeped until it reads at any size on paper. */
--color-link-hover: light-dark(#bc5c00, #e49b5d); --color-link: light-dark(#b62d6b, #f48fb1);
--color-link-hover: light-dark(#c2452b, #ff9e80);
/* Danger — b.red on d.red */ --color-danger: light-dark(#c0303a, #ff8a80);
--color-danger: light-dark(#bf0021, #d47766); --color-danger-bg: light-dark(#f8dde0, #4d2030);
--color-danger-bg: light-dark(#f1dedf, #7d2a2f); --color-danger-hover: light-dark(#c0303a, #c9404f);
--color-danger-hover: light-dark(#c77b8b, #bd8183);
/* Success — b.green on d.green */ --color-success: light-dark(#1b7f66, #86d3b4);
--color-success: light-dark(#3a684a, #85b695); --color-success-bg: light-dark(#d5efe6, #1d3a33);
--color-success-bg: light-dark(#d0e9d1, #233524); --color-success-hover: light-dark(#17705a, #a3e0c8);
--color-success-hover: light-dark(#6e9b72, #78997a);
/* Warning — c.yellow */ --color-warning: light-dark(#b5541f, #ffb38a);
--color-warning: light-dark(#bc5c00, #e49b5d);
/* Overlays */ /* Overlays */
--color-overlay: light-dark(rgb(84 67 58 / 0.3), rgb(32 29 27 / 0.72)); --color-overlay: light-dark(rgb(54 38 63 / 0.3), rgb(15 10 20 / 0.72));
--color-header-user-bg: transparent; --color-header-user-bg: transparent;
--color-header-user-bg-hover: light-dark( --color-header-user-bg-hover: light-dark(
rgb(84 67 58 / 0.07), rgb(54 38 63 / 0.07),
rgb(236 225 215 / 0.07) rgb(241 232 243 / 0.08)
); );
/* Misc */ /* Misc */
--color-option-bg: light-dark(#e9e1db, #34302c); --color-option-bg: light-dark(#eee3ef, #2b2338);
--color-option-border: light-dark( --color-option-border: light-dark(
rgb(169 138 120 / 0.18), rgb(117 98 131 / 0.16),
rgb(134 116 98 / 0.22) rgb(168 152 182 / 0.18)
); );
/* The rest of the desert — melange's c.* row, dusty rather than bright. /* The rest of the blend, beside the accent's pink: the theme's own
These are the theme's own furniture colours. */ furniture colours. By day each is steeped as far as the accent is, a
--mel-terracotta: light-dark(#bc5c00, #e49b5d); sorbet still and not a flag's primary. */
--mel-olive: light-dark(#6e9b72, #78997a); --mel-coral: light-dark(#ea6a45, #ff9e80);
--mel-rose: light-dark(#be79bb, #b380b0); --mel-lilac: light-dark(#8262d8, #b9a2f2);
--mel-clay: light-dark(#c77b8b, #bd8183); --mel-sky: light-dark(#3a8fd4, #86bdea);
--mel-dust: light-dark(#a98a78, #867462); --mel-mint: light-dark(#27a583, #86d3b4);
/* The mélange: every hue of the desert, blended once, left to right. */ /* The mélange: every hue, blended once, left to right in the order of
the wheel so no two neighbours mix to mud. */
--mel-spice: linear-gradient( --mel-spice: linear-gradient(
90deg, 90deg,
var(--color-accent), var(--mel-coral),
var(--mel-terracotta) 32%, var(--color-accent) 26%,
var(--mel-clay) 58%, var(--mel-lilac) 54%,
var(--mel-rose) 78%, var(--mel-sky) 78%,
var(--mel-olive) var(--mel-mint)
); );
/* Shadows are warm and wide — sunlight, not a drop shadow. Light needs /* Shadows are tinted with the ground and wide. Light needs more of them
more of them than dark: on plaster, shadow is the only thing holding than dark: on paper, shadow is the only thing holding a card off the
a card off the page. */ page. */
--shadow-color: light-dark(rgb(122 84 46 / 0.26), rgb(12 9 8 / 0.5)); --shadow-color: light-dark(rgb(96 52 118 / 0.2), rgb(8 4 12 / 0.5));
--shadow-color-soft: light-dark(rgb(122 84 46 / 0.16), rgb(12 9 8 / 0.32)); --shadow-color-soft: light-dark(rgb(96 52 118 / 0.13), rgb(8 4 12 / 0.32));
--shadow-color-strong: light-dark(rgb(122 84 46 / 0.36), rgb(12 9 8 / 0.7)); --shadow-color-strong: light-dark(rgb(96 52 118 / 0.32), rgb(8 4 12 / 0.7));
--shadow-1: 0 2px 10px -2px var(--shadow-color-soft); --shadow-1: 0 2px 10px -2px var(--shadow-color-soft);
--shadow-2: 0 14px 34px -10px var(--shadow-color), --shadow-2: 0 14px 34px -10px var(--shadow-color),
0 2px 6px var(--shadow-color-soft); 0 2px 6px var(--shadow-color-soft);
--shadow-3: 0 32px 70px -16px var(--shadow-color-strong), --shadow-3: 0 32px 70px -16px var(--shadow-color-strong),
0 3px 10px var(--shadow-color-soft); 0 3px 10px var(--shadow-color-soft);
/* Grain — one tile, two treatments (see body::after). Defaults are the /* Grain — one fine tile for both schemes, laid differently (see
dark ones; the light scheme overrides them by selector. */ body::after). Defaults are the night's; the day overrides them by
selector. */
--mel-grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23g)' opacity='0.22'/%3E%3C/svg%3E"); --mel-grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23g)' opacity='0.22'/%3E%3C/svg%3E");
--mel-grain-blend: soft-light; --mel-grain-blend: soft-light;
--mel-grain-opacity: 0.55; --mel-grain-opacity: 0.55;
/* Glow: the spice catches light where it's interactive. */ /* Glow: the accent catches light where it's interactive. */
--mel-glow: 0 3px 16px -4px --mel-glow: 0 3px 16px -4px
light-dark(rgb(160 109 0 / 0.45), rgb(235 192 109 / 0.35)); light-dark(rgb(226 86 143 / 0.5), rgb(244 143 177 / 0.38));
/* Component skins — no lines anywhere; warmth and shadow do the work. */ /* Component skins — no lines anywhere; tint and shadow do the work. */
--border-width: 1px; --border-width: 1px;
/* The header is a board laid over the page, lighter than it in both /* The header is a board laid over the page, lighter than it in both
schemes: a.float on coffee, the bleached tint on plaster. */ schemes. */
--header-bg: light-dark(#fdfbf9, #34302c); --header-bg: light-dark(#fffcff, #2b2338);
--input-focus-bg: var(--color-surface-raised); --input-focus-bg: var(--color-surface-raised);
--overlay-filter: blur(6px); --overlay-filter: blur(6px);
--modal-border: transparent; --modal-border: transparent;
@@ -161,22 +159,15 @@
--tab-border: transparent; --tab-border: transparent;
--btn-primary-shadow: var(--mel-glow); --btn-primary-shadow: var(--mel-glow);
--btn-primary-shadow-hover: 0 5px 22px -4px --btn-primary-shadow-hover: 0 5px 22px -4px
light-dark(rgb(160 109 0 / 0.55), rgb(235 192 109 / 0.45)); light-dark(rgb(226 86 143 / 0.6), rgb(244 143 177 / 0.48));
} }
/* ══════════════════════════════════════════════════════════════════════ /* ══════════════════════════════════════════════════════════════════════
Structural overrides Structural overrides
══════════════════════════════════════════════════════════════════════ */ ══════════════════════════════════════════════════════════════════════ */
/* ── One cut of Fraunces, at one optical size, for every size of text ── */ /* ── The page is lit from above: the accent pools faintly at the top,
:root[data-style="melange"], the ground below is flat. ────────────────────────────────────── */
[data-style="melange"] body {
font-optical-sizing: none;
font-variation-settings: "opsz" var(--mel-opsz), "SOFT" 40, "WONK" 0;
}
/* ── The page is sunlit: warm light pools at the top, the ground below
is flat desert. ───────────────────────────────────────────────── */
[data-style="melange"] body { [data-style="melange"] body {
background-image: radial-gradient( background-image: radial-gradient(
120% 55% at 50% -12%, 120% 55% at 50% -12%,
@@ -190,13 +181,14 @@
/* ── Film grain over everything, so no surface reads as flat pixels. /* ── Film grain over everything, so no surface reads as flat pixels.
Sits above the page, below modals and toasts. Sits above the page, below modals and toasts.
The two schemes need opposite grain. On coffee, `soft-light` grits It is the same fine tile in both schemes, and the blend is what
the surface without lifting the blacks. On plaster that same blend differs. On berry, `soft-light` grits the surface without lifting the
is invisible — pale grey noise on a pale ground has nothing to blacks. On paper that blend does nothing, pale grey noise on a pale
work against — so light multiplies instead, which is what paper ground having nothing to work against, so the day multiplies it:
tooth actually is: tiny shadows. Neither blend nor opacity takes paper tooth, which is tiny shadows, kept faint enough that the page
light-dark(), so the two parameters are custom properties and the stays the colour it is given. Neither blend nor opacity takes
scheme is picked by selector below. ───────────────────────────── */ light-dark(), so the two are custom properties and the scheme is
picked by selector below. ───────────────────────────────────────── */
[data-style="melange"] body::after { [data-style="melange"] body::after {
content: ""; content: "";
position: fixed; position: fixed;
@@ -212,22 +204,19 @@
it because they come later and are more specific. */ it because they come later and are more specific. */
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
:root[data-style="melange"] { :root[data-style="melange"] {
--mel-grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.55' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23g)' opacity='0.42'/%3E%3C/svg%3E");
--mel-grain-blend: multiply; --mel-grain-blend: multiply;
--mel-grain-opacity: 0.85; --mel-grain-opacity: 0.4;
} }
} }
:root[data-style="melange"][data-color-scheme="light"] { :root[data-style="melange"][data-color-scheme="light"] {
--mel-grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.55' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23g)' opacity='0.42'/%3E%3C/svg%3E");
--mel-grain-blend: multiply; --mel-grain-blend: multiply;
--mel-grain-opacity: 0.85; --mel-grain-opacity: 0.4;
} }
/* Repeats the fine tile too: forcing dark under a light OS would /* Forcing dark under a light OS would otherwise inherit the day's two from
otherwise inherit the coarse one from the media query above. */ the media query above. */
:root[data-style="melange"][data-color-scheme="dark"] { :root[data-style="melange"][data-color-scheme="dark"] {
--mel-grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23g)' opacity='0.22'/%3E%3C/svg%3E");
--mel-grain-blend: soft-light; --mel-grain-blend: soft-light;
--mel-grain-opacity: 0.55; --mel-grain-opacity: 0.55;
} }
@@ -239,8 +228,8 @@
} }
/* ── Header — a board over the page: no rule under it, just its own /* ── Header — a board over the page: no rule under it, just its own
shade, a warm shadow, and the spice laid along its bottom edge as shade, a tinted shadow, and the blend laid along its bottom edge
a single blended stripe. ──────────────────────────────────────── */ as a single stripe. ──────────────────────────────────────────── */
[data-style="melange"] .app-header { [data-style="melange"] .app-header {
border-bottom: none; border-bottom: none;
box-shadow: var(--shadow-1); box-shadow: var(--shadow-1);
@@ -251,25 +240,55 @@
content: ""; content: "";
position: absolute; position: absolute;
inset: auto 0 0 0; inset: auto 0 0 0;
height: 2px; height: 3px;
background: var(--mel-spice); background: var(--mel-spice);
opacity: 0.7;
} }
/* Brand — soft and a little wonky, the way 70s display type was cut. */ /* Brand — the site's name is cut out of the blend itself. The fill takes
the place of the text's colour, so the emoji beside it, which sits
outside the name's span, keeps its own. The blend is laid twice as wide
as the name and drifts under it, from its warm end to its cool one and
back, so the name is never the same two colours for long. Pointed at, the
name fills with the accent over the blend, as a card's title turns to it. */
[data-style="melange"] .app-header-brand { [data-style="melange"] .app-header-brand {
color: var(--mel-terracotta); font-weight: 800;
font-weight: 600; letter-spacing: -0.02em;
font-variation-settings: "opsz" var(--mel-opsz), "SOFT" 100, "WONK" 1;
letter-spacing: -0.01em;
} }
[data-style="melange"] .form-page-title, [data-style="melange"] .app-header-brand-name {
background: var(--mel-spice) 0 0 / 200% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
transition: color var(--dur-base);
animation: mel-drift 7s ease-in-out infinite alternate;
}
[data-style="melange"] .app-header-brand:hover .app-header-brand-name {
color: var(--color-accent);
}
@keyframes mel-drift {
to {
background-position: 100% 0;
}
}
/* Held still, the name shows the whole blend. */
@media (prefers-reduced-motion: reduce) {
[data-style="melange"] .app-header-brand-name {
background-size: 100% 100%;
animation: none;
}
}
/* Headlines are heavy and set a little tight, as the face likes. */
[data-style="melange"] h1,
[data-style="melange"] .dump-title,
[data-style="melange"] .page-title, [data-style="melange"] .page-title,
[data-style="melange"] .profile-section-title, [data-style="melange"] .form-page-title {
[data-style="melange"] .comment-section-title, font-weight: 800;
[data-style="melange"] .related-section-title { letter-spacing: -0.02em;
font-variation-settings: "opsz" var(--mel-opsz), "SOFT" 80, "WONK" 1;
} }
/* Tabs keep the shared uppercase cut, but the active one glows. */ /* Tabs keep the shared uppercase cut, but the active one glows. */
@@ -306,55 +325,50 @@
} }
} }
/* ── Metadata is dust: quiet, warm, never black ──────────────────── */ /* A pointed-at headline over a photo sits on a pillow of the night ground
[data-style="melange"] .journal-card:not(.journal-card--image) .journal-card-meta, — the dark scheme's in either scheme, since the scrim under it is dark in
[data-style="melange"] .dump-card-date, both — lit in the dark scheme's pink. */
[data-style="melange"] .comment-time,
[data-style="melange"] .notification-time {
color: var(--mel-dust);
}
/* A pointed-at headline over a photo sits on a pillow of roasted coffee —
the dark scheme's ground in either scheme, since the scrim under it is
dark in both — lit in the dark scheme's amber, in warm shadow. */
[data-style="melange"] .journal-card--image .journal-card-title { [data-style="melange"] .journal-card--image .journal-card-title {
--journal-title-hover-bg: rgb(41 37 34 / 0.84); --journal-title-hover-bg: rgb(33 26 43 / 0.86);
--journal-title-hover-color: #ebc06d; --journal-title-hover-color: #f48fb1;
--journal-title-hover-radius: var(--radius-sm); --journal-title-hover-radius: var(--radius-sm);
--journal-title-hover-shadow: 0 4px 14px -4px rgb(0 0 0 / 0.5); --journal-title-hover-shadow: 0 4px 14px -4px rgb(0 0 0 / 0.5);
} }
/* ── A quote is a printed pull-quote: big soft mark, terracotta ──── */ /* ── A quote is a printed pull-quote: big mark, in the coral ─────── */
[data-style="melange"] .journal-card--quote { [data-style="melange"] .journal-card--quote {
background: var(--color-surface); background: var(--color-surface);
} }
[data-style="melange"] .journal-card-quotemark { [data-style="melange"] .journal-card-quotemark {
color: var(--mel-terracotta); color: var(--mel-coral);
opacity: 0.28; opacity: 0.3;
font-variation-settings: "opsz" var(--mel-opsz), "SOFT" 100, "WONK" 1;
} }
[data-style="melange"] .journal-card-quote-body { [data-style="melange"] .journal-card-quote-body {
font-size: var(--text-lg); font-size: var(--text-lg);
line-height: 1.35; line-height: 1.35;
font-variation-settings: "opsz" var(--mel-opsz), "SOFT" 70, "WONK" 0;
} }
/* The mélange runs along the header's bottom edge; between sub-blocks it /* The blend runs along the header's bottom edge; between sub-blocks it
returns as a thinner, softer band of the same blend — the page divided by returns as a softer band of the same — the page divided by its own
its own palette rather than by a line. A rule can't carry a gradient, so palette rather than by a line. A rule can't carry a gradient, so this one
this one is a filled bar and the base break's border stands down. */ is a filled bar and the base break's border stands down. */
[data-style="melange"] .journal-break { [data-style="melange"] .journal-break {
--journal-break-space: 2rem; --journal-break-space: 2rem;
--journal-break-rule: none; --journal-break-rule: none;
height: 3px; height: 3px;
border-radius: 999px; border-radius: 999px;
background: var(--mel-spice); background: var(--mel-spice);
opacity: 0.55; opacity: 0.6;
} }
/* Vote tallies wear the dusty rose of the palette's far end */ /* Vote tallies are the blend's lilac, apart from the accent's pink. */
[data-style="melange"] .vote-btn:not(.vote-btn--active) { [data-style="melange"] .vote-btn:not(.vote-btn--active) {
color: var(--mel-rose); color: var(--mel-lilac);
}
/* A dump's note is read at length: full ink, where the base dims it. */
[data-style="melange"] .dump-comment {
opacity: 1;
} }

View File

@@ -1,7 +1,9 @@
/* ══════════════════════════════════════════════════════════════════════ /* ══════════════════════════════════════════════════════════════════════
"Old Grey Lady" theme — New York Times editorial styling. "Old Grey Lady" theme — New York Times editorial styling.
Serif typography, paper-and-ink palette, hairline rules, sharp corners, Four faces, each with one job: a blackletter nameplate, a condensed display
restrained NYT-blue links, inverted ink-on-paper primary buttons. serif for headlines, a news serif for prose, and a Franklin gothic for
everything the interface says.
Paper-and-ink palette, ink rules over hairlines, square pictures.
Light scheme → newsprint (white page, black ink). Light scheme → newsprint (white page, black ink).
Dark scheme → NYT night mode (near-black page, warm-grey ink). Dark scheme → NYT night mode (near-black page, warm-grey ink).
@@ -9,33 +11,31 @@
/* ── Palette (light-dark: see tokens.css) ─────────────────────────── */ /* ── Palette (light-dark: see tokens.css) ─────────────────────────── */
:root[data-style="nyt"] { :root[data-style="nyt"] {
/* Square corners everywhere App.css uses the radius scale. */ /* Pictures, cards and sheets are square; a control keeps the 3px of a
newspaper site's buttons (see "Controls" below). */
--radius-xs: 0; --radius-xs: 0;
--radius-sm: 0; --radius-sm: 0;
--radius-md: 0; --radius-md: 0;
--radius-lg: 0; --radius-lg: 0;
--radius-xl: 0; --radius-xl: 0;
--radius-full: 0; --radius-full: 0;
--nyt-control-radius: 3px;
/* Hairlines, not strokes — controls match the rules between stories. */ /* Hairlines, not strokes — controls match the rules between stories. */
--border-width: 1px; --border-width: 1px;
/* Instrument Serif is a condensed display face: at the shared scale its
small sizes read a step smaller than other themes', so lift the low end. */
--text-2xs: 0.78rem;
--text-xs: 0.86rem;
--text-sm: 0.94rem;
--text-md: 1rem;
--text-base: 1.06rem;
--text-lg: 1.2rem;
--color-text: light-dark(#121212, #e6e6e6); --color-text: light-dark(#121212, #e6e6e6);
--color-text-secondary: light-dark(#5a5a5a, #ababab); --color-text-secondary: light-dark(#5a5a5a, #ababab);
--color-text-muted: light-dark(#767676, #8a8a8a); --color-text-muted: light-dark(#727272, #8a8a8a);
--color-on-accent: light-dark(#ffffff, #121212); --color-on-accent: light-dark(#ffffff, #121212);
--color-bg: light-dark(#ffffff, #121212); --color-bg: light-dark(#ffffff, #121212);
--color-surface: light-dark(#ffffff, #1e1e1e); --color-surface: light-dark(#ffffff, #1e1e1e);
--color-border: light-dark(#e2e2e2, #3a3a3a); --color-border: light-dark(#dfdfdf, #323232);
/* The heavy rules: under the masthead, over a section's label. On paper
they are ink. On the night page ink is near white, and a white line
across black is far louder than a black one across white, so there they
are a mid grey, still twice the hairlines' weight to the eye. */
--nyt-rule: light-dark(#121212, #5e5e5e);
--color-border-subtle: light-dark(rgba(0, 0, 0, 0.08), rgba(255, 255, 255, 0.12)); --color-border-subtle: light-dark(rgba(0, 0, 0, 0.08), rgba(255, 255, 255, 0.12));
--color-accent: light-dark(#121212, #ececec); --color-accent: light-dark(#121212, #ececec);
--color-accent-hover: light-dark(#000000, #ffffff); --color-accent-hover: light-dark(#000000, #ffffff);
@@ -48,41 +48,121 @@
--color-success-bg: light-dark(#e6f4ea, #122a14); --color-success-bg: light-dark(#e6f4ea, #122a14);
--color-success-hover: light-dark(#136a2c, #95d699); --color-success-hover: light-dark(#136a2c, #95d699);
--color-overlay: light-dark(rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.6)); --color-overlay: light-dark(rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.6));
--color-header-user-bg: light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.06)); /* The header's controls sit on the page; only hovering one tints it. */
--color-header-user-bg-hover: light-dark(rgba(0, 0, 0, 0.09), rgba(255, 255, 255, 0.12)); --color-header-user-bg: transparent;
--color-option-bg: light-dark(#f2f2f2, #181818); --color-header-user-bg-hover: light-dark(rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.1));
--color-option-border: light-dark(#e2e2e2, #3a3a3a); --color-option-bg: light-dark(#f4f4f4, #181818);
--color-option-border: light-dark(#dfdfdf, #323232);
/* The masthead is printed on the page, in both schemes. */
--header-bg: var(--color-bg);
/* Section tabs read as a paper's section list: sentence case, no pill. */
--tab-text-transform: none;
--tab-tracking: 0;
} }
/* ══════════════════════════════════════════════════════════════════════ /* ══════════════════════════════════════════════════════════════════════
Structural overrides Typography
══════════════════════════════════════════════════════════════════════ */ ══════════════════════════════════════════════════════════════════════ */
/* ── Typography — one serif everywhere ───────────────────────────── */ /* What is read is serif, and it is the default; what is operated is sans
(--font-ui, the list below). */
[data-style="nyt"] { [data-style="nyt"] {
--font-body: "Instrument Serif", Georgia, "Times New Roman", serif; --font-body: "Newsreader Variable", Georgia, "Times New Roman", serif;
--font-display: "Instrument Serif", Georgia, "Times New Roman", serif; --font-display: "Instrument Serif", Georgia, "Times New Roman", serif;
--font-ui: "Libre Franklin Variable", "Franklin Gothic Medium", "Helvetica Neue", Arial,
sans-serif;
--font-nameplate: "Manufacturing Consent", "Old English Text MT", var(--font-display);
font-family: var(--font-body); font-family: var(--font-body);
font-weight: 400; font-weight: 400;
line-height: 1.5; line-height: 1.5;
/* Single weight, no small caps: never let the browser fake either — a
smeared faux bold or spindly faux small caps give the typeface away.
Hierarchy comes from size, italics and capitals instead. */
font-synthesis: none;
/* Hairline icons, drawn in ink like the rules. */ /* Hairline icons, drawn in ink like the rules. */
--nav-icon-stroke: 1.25; --nav-icon-stroke: 1.25;
} }
/* This theme squares everything off — opt the FAB into a square too (it keeps /* ── The interface's sans — controls, navigation, labels, datelines ── */
its animations; only the corners change). */ /* The button classes are listed beside `button` because a link is drawn as
[data-style="nyt"] .dump-fab, one too (the 404 page's "Go home"). */
[data-style="nyt"] .chat-fab { [data-style="nyt"] :is(
--fab-radius: 0; button,
.btn-primary,
.btn-secondary,
.btn-danger,
.btn-border,
input,
select,
label,
.app-header-nav,
.app-header-status,
.feed-sort,
.profile-tabs,
.search-tabs,
.playlist-sort-tabs,
.category-switcher-menu,
.user-menu-dropdown,
.user-list-popover,
.dump-card-meta,
.dump-card-vote,
.playlist-card-meta,
.journal-card-meta,
.category-link,
.dump-op,
.dump-actions,
.dump-edited-label,
.rich-content-badge,
.rich-content-url,
.comment-section-title,
.related-section-title,
.comment-meta,
.comment-actions,
.comment-form-actions,
.notification-time,
.notifications-unread-pill,
.notification-badge,
.page-group-label,
.form-page-eyebrow,
.form-hint,
.form-field,
.profile-role-chip,
.profile-invited-by,
.profile-email,
.profile-section-header,
.profile-appearance-row,
.playlist-badge,
.dump-card-private-badge,
.avatar-initials,
.thumb-placeholder-initials,
.feed-end,
.empty-state,
.tooltip,
.dump-steps,
.auth-card-footer,
.fdz__hint,
.fdz__limit,
.fdz__file-meta,
.chat-message-meta,
.chat-day-sep,
.chat-status,
.chat-typing,
.chat-reply-bar,
.chat-reply-ref,
.audio-player-time,
.global-player-position,
.global-player-status,
.global-player-subtitle
) {
font-family: var(--font-ui);
} }
/* ── Headlines — display serif ───────────────────────────────────── */ /* A note is written in the face it will be read in. */
[data-style="nyt"] textarea {
font-family: var(--font-body);
font-size: var(--text-base);
}
/* ── Headlines ───────────────────────────────────────────────────── */
[data-style="nyt"] h1, [data-style="nyt"] h1,
[data-style="nyt"] .app-header-brand,
[data-style="nyt"] .dump-title, [data-style="nyt"] .dump-title,
[data-style="nyt"] .dump-card-title, [data-style="nyt"] .dump-card-title,
[data-style="nyt"] .playlist-card-title, [data-style="nyt"] .playlist-card-title,
@@ -91,35 +171,64 @@
[data-style="nyt"] .form-page-title, [data-style="nyt"] .form-page-title,
[data-style="nyt"] .auth-card-title, [data-style="nyt"] .auth-card-title,
[data-style="nyt"] .page-title, [data-style="nyt"] .page-title,
[data-style="nyt"] .dump-create-title { [data-style="nyt"] .profile-username,
[data-style="nyt"] .dump-create-title,
[data-style="nyt"] .rich-content-title,
[data-style="nyt"] .modal-title {
font-family: var(--font-display); font-family: var(--font-display);
font-weight: 400; /* Instrument Serif is single-weight; size carries it */ /* Instrument Serif has one weight: size carries the hierarchy, and the
browser is kept from smearing a bold out of it. */
font-weight: 400;
font-synthesis: none;
letter-spacing: 0; letter-spacing: 0;
line-height: 1.14; line-height: 1.12;
} }
/* A confident broadsheet type scale */ /* A broadsheet's scale. The face is condensed and runs small, so each step
[data-style="nyt"] .app-header-brand { is set larger than its rem reads. */
font-size: 1.45rem;
letter-spacing: 0;
}
[data-style="nyt"] .dump-title { [data-style="nyt"] .dump-title {
font-size: 2.1rem; font-size: clamp(2.2rem, 1.3rem + 3.4vw, 3.2rem);
line-height: 1.04;
} }
[data-style="nyt"] .playlist-detail-title { [data-style="nyt"] .playlist-detail-title {
font-size: 1.9rem; font-size: clamp(2rem, 1.3rem + 2.6vw, 2.75rem);
line-height: 1.06;
}
[data-style="nyt"] .page-title,
[data-style="nyt"] .profile-username,
[data-style="nyt"] .form-page-title,
[data-style="nyt"] .auth-card-title {
font-size: 2.5rem;
line-height: 1.05;
} }
[data-style="nyt"] .dump-card-title, [data-style="nyt"] .dump-card-title,
[data-style="nyt"] .playlist-card-title { [data-style="nyt"] .playlist-card-title {
font-size: 1.2rem; font-size: 1.5rem;
line-height: 1.2; line-height: 1.12;
}
[data-style="nyt"] .rich-content-title {
font-size: 1.3rem;
line-height: 1.15;
}
[data-style="nyt"] .modal-title {
font-size: 1.5rem;
} }
[data-style="nyt"] .journal-card-title { [data-style="nyt"] .journal-card-title {
font-size: 1.15rem; font-size: 1.35rem;
line-height: 1.18; line-height: 1.1;
} }
[data-style="nyt"] .journal-card--feature .journal-card-title { [data-style="nyt"] .journal-card--feature .journal-card-title {
font-size: 1.6rem; font-size: 2.2rem;
line-height: 1.04;
}
/* The base scale sets these three smaller than a card's title; in this face
they need the same lift as the rest. */
[data-style="nyt"] .journal-card--square.journal-card--image .journal-card-title,
[data-style="nyt"] .journal-card--text .journal-card-title {
font-size: 1.25rem;
}
[data-style="nyt"] .journal-card--quote .journal-card-title {
font-size: 1.1rem;
} }
/* Headline links underline on hover (ink stays ink). */ /* Headline links underline on hover (ink stays ink). */
@@ -128,7 +237,8 @@
[data-style="nyt"] .journal-card-title:hover { [data-style="nyt"] .journal-card-title:hover {
color: var(--color-text); color: var(--color-text);
text-decoration: underline; text-decoration: underline;
text-underline-offset: 2px; text-decoration-thickness: 1px;
text-underline-offset: 3px;
} }
/* …except over a photo, where the headline is white on a dark scrim: ink /* …except over a photo, where the headline is white on a dark scrim: ink
there goes black-on-black in the light scheme. */ there goes black-on-black in the light scheme. */
@@ -150,28 +260,276 @@
text-decoration: none; text-decoration: none;
} }
/* ── Kickers — section labels & badges in spaced capitals ───────── */ /* ── Summaries — the grey paragraph under a headline ─────────────── */
[data-style="nyt"] .comment-section-title, [data-style="nyt"] .dump-card-comment {
[data-style="nyt"] .related-section-title, color: var(--color-text-secondary);
[data-style="nyt"] .form-page-eyebrow, font-size: var(--text-base);
[data-style="nyt"] .playlist-badge, line-height: 1.4;
[data-style="nyt"] .dump-card-private-badge, margin-top: 0.2rem;
[data-style="nyt"] .page-group-label,
[data-style="nyt"] .follow-btn {
text-transform: uppercase;
letter-spacing: 0.08em;
} }
/* ── Datelines — the serif's real italic, in muted ink ────────────── */ [data-style="nyt"] .rich-content-description {
font-size: var(--text-md);
line-height: 1.4;
}
/* ── Section labels — heavy sans capitals under an ink rule ──────── */
[data-style="nyt"] .comment-section-title,
[data-style="nyt"] .related-section-title,
[data-style="nyt"] .page-group-label,
[data-style="nyt"] .form-page-eyebrow {
color: var(--color-text);
font-size: var(--text-2xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
[data-style="nyt"] .page-group-label {
border-top: 1px solid var(--nyt-rule);
padding-top: 0.5rem;
}
/* These two sections already open on a rule: it turns to ink and the label
moves up under it. */
[data-style="nyt"] .comment-section,
[data-style="nyt"] .related-section {
border-top-color: var(--nyt-rule);
padding-top: 0.5rem;
}
/* The label's own trailing hairline would double the rule above it. */
[data-style="nyt"] .page-group-label::after {
content: none;
}
[data-style="nyt"] .profile-section-header {
border-bottom: none;
border-top: 1px solid var(--nyt-rule);
padding: 0.5rem 0 0;
}
[data-style="nyt"] .profile-section-title {
font-family: var(--font-ui);
font-size: var(--text-2xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
/* ── Kickers — a story's categories, and the small badges ────────── */
[data-style="nyt"] .category-link,
[data-style="nyt"] .playlist-badge,
[data-style="nyt"] .dump-card-private-badge,
[data-style="nyt"] .profile-role-chip,
[data-style="nyt"] .rich-content-badge {
font-size: 0.66rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
[data-style="nyt"] .category-link {
padding: 0;
background: none;
color: var(--color-text);
}
[data-style="nyt"] .category-link:hover {
background: none;
text-decoration: underline;
text-underline-offset: 2px;
}
[data-style="nyt"] .dump-card-categories {
gap: 0.15rem 0.75rem;
}
/* ── Datelines and bylines — small sans, the name in ink ─────────── */
[data-style="nyt"] .dump-card-date, [data-style="nyt"] .dump-card-date,
[data-style="nyt"] .dump-card-comment-count,
[data-style="nyt"] .playlist-card-meta,
[data-style="nyt"] .journal-card-meta, [data-style="nyt"] .journal-card-meta,
[data-style="nyt"] .comment-time, [data-style="nyt"] .comment-time,
[data-style="nyt"] .notification-time, [data-style="nyt"] .notification-time,
[data-style="nyt"] .dump-edited-label { [data-style="nyt"] .dump-edited-label {
text-transform: none; font-size: 0.72rem;
letter-spacing: 0; font-style: normal;
font-style: italic; letter-spacing: 0.01em;
} }
[data-style="nyt"] .dump-op {
opacity: 1;
color: var(--color-text-muted);
font-size: var(--text-xs);
margin-top: 0.6rem;
}
[data-style="nyt"] .dump-op-link,
[data-style="nyt"] .comment-author {
color: var(--color-text);
font-size: var(--text-xs);
font-weight: 700;
}
[data-style="nyt"] .dump-actions a {
font-size: var(--text-xs);
font-weight: 500;
}
[data-style="nyt"] .vote-btn {
font-size: var(--text-xs);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
/* ══════════════════════════════════════════════════════════════════════
Masthead
══════════════════════════════════════════════════════════════════════ */
/* The nameplate: the site's name in blackletter, its emoji beside it at the
size of a printer's mark, the two on one baseline. Centring them put the
emoji against the middle of the name's line, which is above the middle of
its letters. The face has one weight; the base rule's bold would be faked. */
[data-style="nyt"] .app-header-brand {
display: inline-flex;
align-items: baseline;
gap: 0.15em;
font-size: 1.25rem;
font-weight: 400;
letter-spacing: 0;
line-height: 1;
padding-block: 0.6rem;
white-space: nowrap;
}
[data-style="nyt"] .app-header-brand-name {
font-family: var(--font-nameplate);
font-size: 1.8rem;
/* An emoji hangs a quarter of its height under the baseline, so on a shared
baseline its middle is a little under the middle of the lowercase: the
name comes down to meet it. */
translate: 0 0.04em;
/* The blackletter sets its letters low in its line: an ascender stops a
fifth of an em under the line's top and a descender ends on its bottom.
This much under the line leaves as much paper below the letters as above,
which is what puts the name level with the buttons beside it. */
padding-bottom: 0.22em;
}
/* Pointed at, the name is underlined, as a headline is. */
[data-style="nyt"] .app-header-brand:hover .app-header-brand-name {
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 3px;
}
[data-style="nyt"] .app-header {
border-bottom: 1px solid var(--nyt-rule);
}
/* Where there is room the nameplate takes the middle of a row of its own,
the buttons beside it, and what the page puts in the header (a feed's
sections, the open search field) runs underneath between a hairline and a
double rule, as a front page's section list does. A page that puts nothing
there keeps the one row and a single rule. */
@media (min-width: 860px) {
[data-style="nyt"] .app-header--has-center {
grid-template-columns: 1fr auto 1fr;
column-gap: 1rem;
row-gap: 0;
}
[data-style="nyt"] .app-header-brand {
grid-area: 1 / 2;
justify-self: center;
font-size: 1.5rem;
padding-block: 0.7rem;
}
[data-style="nyt"] .app-header-brand-name {
font-size: 2.9rem;
}
[data-style="nyt"] .app-header--has-center .app-header-nav {
grid-area: 1 / 3;
}
[data-style="nyt"] .app-header-center {
grid-area: 2 / 1 / auto / -1;
border-top: 1px solid var(--color-border);
padding-block: 0.3rem;
}
[data-style="nyt"] .app-header-center:not(:has(.header-center-slot, .search-bar--expanded)) {
display: none;
}
[data-style="nyt"] .app-header:has(.header-center-slot, .search-bar--expanded) {
border-bottom: 3px double var(--nyt-rule);
}
}
[data-style="nyt"] .header-search-below {
border-bottom: 1px solid var(--color-border);
}
/* The header's links and buttons: small sans, the weight of a section list. */
[data-style="nyt"] .app-header-nav button,
[data-style="nyt"] .app-header-user {
font-size: var(--text-xs);
font-weight: 600;
}
[data-style="nyt"] .app-header-nav button:not(.btn-primary) {
border-color: transparent;
}
[data-style="nyt"] .app-header--guest .app-header-nav > button:last-child {
border-color: var(--color-text);
}
/* ── Section tabs — a word, underlined in ink when it is the open one ── */
[data-style="nyt"] .feed-sort {
gap: 0.25rem;
}
[data-style="nyt"] .feed-sort-btn {
border: none;
border-bottom: 2px solid transparent;
border-radius: 0;
background: none;
color: var(--color-text-secondary);
padding: 0.3rem 0.1rem 0.2rem;
margin: 0 0.5rem;
font-size: var(--text-xs);
font-weight: 500;
}
[data-style="nyt"] .feed-sort-btn:hover {
background: none;
color: var(--color-text);
}
[data-style="nyt"] .feed-sort-btn.active {
background: none;
color: var(--color-text);
font-weight: 700;
border-bottom-color: var(--color-text);
}
/* ══════════════════════════════════════════════════════════════════════
Controls
══════════════════════════════════════════════════════════════════════ */
[data-style="nyt"] :is(
.btn,
.btn-primary,
.btn-sm,
.vote-btn,
.follow-btn,
.invite-btn,
.btn-add-playlist,
.new-item-toggle,
.app-header-nav button,
.app-header-user,
input,
select,
textarea
) {
border-radius: var(--nyt-control-radius);
}
[data-style="nyt"] :is(.btn, .btn-primary, .btn-sm, .follow-btn, .invite-btn) {
font-weight: 600;
letter-spacing: 0.01em;
}
/* This theme squares everything off — opt the FAB into a square too (it keeps
its animations; only the corners change). */
[data-style="nyt"] .dump-fab,
[data-style="nyt"] .chat-fab {
--fab-radius: 0;
}
/* ══════════════════════════════════════════════════════════════════════
Structural overrides
══════════════════════════════════════════════════════════════════════ */
/* ── Lead note — a standfirst with an editorial drop cap ─────────── */ /* ── Lead note — a standfirst with an editorial drop cap ─────────── */
[data-style="nyt"] .dump-comment { [data-style="nyt"] .dump-comment {
@@ -179,24 +537,26 @@
padding: 0; padding: 0;
margin: 0.35rem 0 0; margin: 0.35rem 0 0;
opacity: 1; opacity: 1;
color: var(--color-text-secondary); color: var(--color-text);
font-size: 1.2rem; font-size: 1.2rem;
}
[data-style="nyt"] .dump-comment,
[data-style="nyt"] .dump-comment p {
line-height: 1.6; line-height: 1.6;
} }
[data-style="nyt"] .dump-comment p:first-of-type::first-letter { [data-style="nyt"] .dump-comment p:first-of-type::first-letter {
float: left; float: left;
font-family: var(--font-display); font-family: var(--font-display);
font-weight: 400; font-weight: 400;
font-size: 3.4rem; font-size: 4.4rem;
line-height: 0.78; line-height: 0.78;
padding: 0.12em 0.14em 0 0; padding: 0.08em 0.1em 0 0;
color: var(--color-text); color: var(--color-text);
} }
/* ── Generous editorial leading for the lead note; comments stay tight ── */ /* Comments are read too: serif, a step over the labels around them. */
[data-style="nyt"] .dump-comment, [data-style="nyt"] .comment-body {
[data-style="nyt"] .dump-comment p { font-size: var(--text-base);
line-height: 1.7;
} }
/* Tight, ruled comment thread — small padding, snug leading, no extra gaps. */ /* Tight, ruled comment thread — small padding, snug leading, no extra gaps. */
@@ -266,27 +626,29 @@
} }
/* Journal view: a section-front grid of stories divided by hairline rules, /* Journal view: a section-front grid of stories divided by hairline rules,
not boxed cards. Close the gaps and rule each cell on its top & left edge so not boxed cards. Every cell draws a rule on all four of its sides, as a
the rules read as continuous row + column delimiters (à la nytimes.com). 1px shadow lying in a 1px gap: two neighbours' shadows fall on the same
The outermost rules are masked off (below) so there's no frame around the pixels and read as one rule, and a cell beside an empty track (the last
block — CSS grid can't target a first column or a last row, hence the mask. */ row, when the stories don't fill it) still closes on its own. A rule on the
top and left edges only left such a cell open on the right, and the row
above it unruled over the empty tracks. The rules around the outside of the
block are masked off (below), so there is no frame. */
/* How far a photo holds off the rules. It sits on the feed rather than on the /* How far a photo holds off the rules. It sits on the feed rather than on the
grid because the break between two grids is cut to the same measure, and a grid because the break between two grids is cut to the same measure, and a
property declared on the grid wouldn't reach it. */ property declared on the grid wouldn't reach it. */
[data-style="nyt"] .journal-feed { [data-style="nyt"] .journal-feed {
--journal-art-inset: 0.55rem; --journal-art-inset: 0.55rem;
--journal-rule: 0 0 0 1px var(--color-border);
} }
[data-style="nyt"] .journal-grid { [data-style="nyt"] .journal-grid {
gap: 0; gap: 1px;
position: relative; position: relative;
} }
[data-style="nyt"] .journal-card { [data-style="nyt"] .journal-card {
background: transparent; background: transparent;
border: none; border: none;
border-top: 1px solid var(--color-border); box-shadow: var(--journal-rule);
border-left: 1px solid var(--color-border);
box-shadow: none;
} }
/* Rules run to the edges of their cell, but a photo holds off them by /* Rules run to the edges of their cell, but a photo holds off them by
@@ -298,14 +660,14 @@
One page-coloured frame, laid over the grid's own edge and as thick as that One page-coloured frame, laid over the grid's own edge and as thick as that
inset, cuts all of them back to the art at once. It takes the outer rules inset, cuts all of them back to the art at once. It takes the outer rules
(the first row's top, the first column's left) with it, which is the same with it, which sit one pixel outside the grid, hence the negative inset.
frameless block the two strips it replaces were after. One frame per grid One frame per grid means one per sub-block, so every block reads frameless,
means one per sub-block, so every block reads frameless, not just the feed. */ not just the feed. */
[data-style="nyt"] .journal-grid::before { [data-style="nyt"] .journal-grid::before {
content: ""; content: "";
position: absolute; position: absolute;
inset: 0; inset: -1px;
border: var(--journal-art-inset) solid var(--color-bg); border: calc(var(--journal-art-inset) + 1px) solid var(--color-bg);
z-index: 1; z-index: 1;
pointer-events: none; pointer-events: none;
} }
@@ -327,9 +689,8 @@
margin: var(--journal-break-space) var(--journal-art-inset); margin: var(--journal-break-space) var(--journal-art-inset);
} }
[data-style="nyt"] .journal-card:hover { [data-style="nyt"] .journal-card:hover {
border-color: var(--color-border);
transform: none; transform: none;
box-shadow: none; box-shadow: var(--journal-rule);
} }
/* Inset content from the rules so the grid breathes — photos get a margin /* Inset content from the rules so the grid breathes — photos get a margin
@@ -346,13 +707,58 @@
padding: var(--journal-quote-pad); padding: var(--journal-quote-pad);
} }
/* ── App header — crisp masthead rule ────────────────────────────── */ /* A story with no picture is set as text. The shared mosaic gives it a
[data-style="nyt"] .app-header { generated placeholder under the same dark scrim a photo gets, which on
border-bottom: 2px solid var(--color-text); paper is a grey smudge with a white headline in it: here the placeholder
and the scrim go, the headline is ink at the top of its cell, and the
dateline and the vote close the cell at its foot. A playable one keeps its
badge in the corner. */
[data-style="nyt"] .journal-card--image:has(.journal-card-image .thumb-placeholder) {
padding: 1.05rem;
} }
[data-style="nyt"] .journal-card--image:has(.thumb-placeholder) .journal-card-image {
[data-style="nyt"] .header-search-below { background: none;
border-bottom: 1px solid var(--color-border); }
[data-style="nyt"] .journal-card--image .journal-card-image .thumb-placeholder {
display: none;
}
[data-style="nyt"] .journal-card--image:has(.thumb-placeholder) .journal-card-overlay {
flex: 1;
margin-top: 0;
padding: 0;
background: none;
}
[data-style="nyt"] .journal-card--image:has(.thumb-placeholder) .journal-card-footer {
margin-top: auto;
}
[data-style="nyt"] .journal-card--image:has(.thumb-placeholder) .journal-card-title,
[data-style="nyt"] .journal-card--image:has(.thumb-placeholder) .journal-card-title:hover {
color: var(--color-text);
background: none;
}
/* The headline stops short of the play badge in the cell's corner. */
[data-style="nyt"] .journal-card--image:has(.thumb-placeholder):has(.rich-content-play-overlay) .journal-card-title {
max-width: calc(100% - 2.2rem);
}
[data-style="nyt"] .journal-card--image:has(.thumb-placeholder) .journal-card-meta {
color: var(--color-text-secondary);
}
[data-style="nyt"] .journal-card--image:has(.thumb-placeholder) .journal-card-comment {
color: var(--color-text-secondary);
}
[data-style="nyt"] .journal-card--image:has(.thumb-placeholder) .journal-card-overlay .vote-btn:not(.vote-btn--active) {
background: transparent;
color: var(--color-text);
}
[data-style="nyt"] .journal-card--image:has(.thumb-placeholder) .unread-dot {
background: var(--color-text);
}
/* On a phone a tile takes a photo's proportions; text takes its own height. */
@media (max-width: 460px) {
[data-style="nyt"] .journal-card--image:has(.thumb-placeholder) {
aspect-ratio: auto;
min-height: 7rem;
}
} }
/* ── Primary button — inverted ink-on-paper (auto-flips per scheme) ─ */ /* ── Primary button — inverted ink-on-paper (auto-flips per scheme) ─ */
@@ -420,27 +826,6 @@
notification badge, brand-provider cues). Everything else is ink & paper. notification badge, brand-provider cues). Everything else is ink & paper.
══════════════════════════════════════════════════════════════════════ */ ══════════════════════════════════════════════════════════════════════ */
/* Feed sort — section-front tabs: an underline, not a filled chip. */
[data-style="nyt"] .feed-sort-btn {
border: none;
border-bottom: 2px solid transparent;
background: none;
color: var(--color-text-muted);
padding: 0.2rem 0.1rem;
margin: 0 0.3rem;
font-weight: 400;
letter-spacing: 0.1em; /* condensed caps need more air than the default */
}
[data-style="nyt"] .feed-sort-btn:hover {
background: none;
color: var(--color-text);
}
[data-style="nyt"] .feed-sort-btn.active {
background: none;
color: var(--color-text);
border-bottom-color: var(--color-text);
}
/* Square off the remaining pills & chips. */ /* Square off the remaining pills & chips. */
[data-style="nyt"] .playlist-badge, [data-style="nyt"] .playlist-badge,
[data-style="nyt"] .dump-card-private-badge, [data-style="nyt"] .dump-card-private-badge,

View File

@@ -11,7 +11,10 @@ import type { Wire } from "../../../shared/api.ts";
import type { Category } from "../../../shared/category.ts"; import type { Category } from "../../../shared/category.ts";
import type { ChatMessage } from "../../../shared/chat.ts"; import type { ChatMessage } from "../../../shared/chat.ts";
import type { Comment } from "../../../shared/comment.ts"; import type { Comment } from "../../../shared/comment.ts";
import type { CommunityStats } from "../../../shared/community.ts"; import type {
CommunityFeed,
CommunityStats,
} from "../../../shared/community.ts";
import type { Dump, DumpUrlMatch } from "../../../shared/dump.ts"; import type { Dump, DumpUrlMatch } from "../../../shared/dump.ts";
import type { import type {
FederationGrant, FederationGrant,
@@ -153,6 +156,16 @@ export function deserializeCommunityStats(
}; };
} }
/** Returns `raw` as a {@link CommunityFeed}. */
export function deserializeCommunityFeed(
raw: Wire<CommunityFeed>,
): CommunityFeed {
return {
...raw,
events: raw.events.map((e) => ({ ...e, at: new Date(e.at) })),
};
}
/** Returns `raw` as a {@link FederationPeer}. */ /** Returns `raw` as a {@link FederationPeer}. */
export function deserializeFederationPeer( export function deserializeFederationPeer(
raw: Wire<FederationPeer>, raw: Wire<FederationPeer>,

View File

@@ -10,7 +10,8 @@ export default mergeConfig(
test: { test: {
environment: "jsdom", environment: "jsdom",
include: ["src/**/*.test.{ts,tsx}"], include: ["src/**/*.test.{ts,tsx}"],
setupFiles: ["src/test/setup.ts"], // before-react.ts first: setup.ts imports React.
setupFiles: ["src/test/before-react.ts", "src/test/setup.ts"],
restoreMocks: true, restoreMocks: true,
unstubGlobals: true, unstubGlobals: true,
css: false, css: false,