Compare commits
10 Commits
d2b7d21824
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eec73e9749 | ||
|
|
3e03626cc1 | ||
|
|
b92c674726 | ||
|
|
7d0a0e7534 | ||
|
|
51439f475d | ||
|
|
62e51f1622 | ||
|
|
eb4e64683c | ||
|
|
d54ae554a4 | ||
|
|
3e15e9a4f2 | ||
|
|
cbb770bff8 |
@@ -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
6
API.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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" };
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
94
deno.lock
generated
@@ -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
236
locales/en.po
236
locales/en.po
@@ -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
236
locales/fr.po
236
locales/fr.po
@@ -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…"
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
473
src/App.css
473
src/App.css
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
32
src/App.tsx
32
src/App.tsx
@@ -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,14 +306,16 @@ export function App(): React.JSX.Element {
|
|||||||
<PlayerProvider>
|
<PlayerProvider>
|
||||||
<WSProvider>
|
<WSProvider>
|
||||||
<FollowProvider>
|
<FollowProvider>
|
||||||
<CategoriesProvider>
|
<CommunityFeedProvider>
|
||||||
<BrowserRouter>
|
<CategoriesProvider>
|
||||||
<ChatProvider>
|
<BrowserRouter>
|
||||||
<AppRoutes />
|
<ChatProvider>
|
||||||
<GlobalPlayer />
|
<AppRoutes />
|
||||||
</ChatProvider>
|
<GlobalPlayer />
|
||||||
</BrowserRouter>
|
</ChatProvider>
|
||||||
</CategoriesProvider>
|
</BrowserRouter>
|
||||||
|
</CategoriesProvider>
|
||||||
|
</CommunityFeedProvider>
|
||||||
</FollowProvider>
|
</FollowProvider>
|
||||||
</WSProvider>
|
</WSProvider>
|
||||||
</PlayerProvider>
|
</PlayerProvider>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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: "" });
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
340
src/components/form/InlineEdit.tsx
Normal file
340
src/components/form/InlineEdit.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
158
src/components/layout/Marquee.tsx
Normal file
158
src/components/layout/Marquee.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
<h1 className="playlist-detail-title">{playlist.title}</h1>
|
<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>
|
||||||
|
</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) && (
|
||||||
<Markdown className="playlist-detail-description">
|
<InlineEdit
|
||||||
{playlist.description}
|
value={playlist.description ?? ""}
|
||||||
</Markdown>
|
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">
|
||||||
|
{playlist.description}
|
||||||
|
</Markdown>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<p className="inline-edit-empty">
|
||||||
|
<Trans>Add a description…</Trans>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</InlineEdit>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="playlist-detail-meta">
|
<div className="playlist-detail-meta">
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,70 +26,53 @@ 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(
|
||||||
<div
|
{ description: saved } satisfies UpdateUserRequest,
|
||||||
className={`profile-description-view${
|
),
|
||||||
isOwnProfile ? " profile-description-view--editable" : ""
|
}),
|
||||||
}`}
|
);
|
||||||
onClick={isOwnProfile ? () => setEditing(true) : undefined}
|
onSaved(saved || undefined);
|
||||||
// Focusable rather than a button, so the Markdown inside keeps its
|
}}
|
||||||
// own structure and links.
|
kind="markdown"
|
||||||
tabIndex={isOwnProfile ? 0 : undefined}
|
maxLength={VALIDATION.USER_DESCRIPTION_MAX}
|
||||||
title={isOwnProfile ? t`Edit description` : undefined}
|
placeholder={t`Who am I?`}
|
||||||
onKeyDown={isOwnProfile
|
>
|
||||||
? (e) => {
|
{description
|
||||||
if (
|
? (
|
||||||
(e.key === "Enter" || e.key === " ") &&
|
<Markdown className="profile-description-text">
|
||||||
e.target === e.currentTarget
|
{description}
|
||||||
) {
|
</Markdown>
|
||||||
e.preventDefault();
|
)
|
||||||
setEditing(true);
|
: (
|
||||||
}
|
<p className="inline-edit-empty">
|
||||||
}
|
<Trans>Who am I?</Trans>
|
||||||
: undefined}
|
</p>
|
||||||
>
|
)}
|
||||||
{description
|
</InlineEdit>
|
||||||
? (
|
|
||||||
<Markdown className="profile-description-text">
|
|
||||||
{description}
|
|
||||||
</Markdown>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
<div className="profile-description-empty">
|
|
||||||
<Trans>Who am I?</Trans>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isOwnProfile && (
|
|
||||||
<span className="profile-description-edit-btn" aria-hidden>
|
|
||||||
✎
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
<span className="profile-email-text">
|
||||||
className="profile-email-display"
|
{me?.email ?? t`Add email…`}
|
||||||
onClick={() => setEmailEditing(true)}
|
</span>
|
||||||
onKeyDown={(e) => {
|
</InlineEdit>
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
setEmailEditing(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
tabIndex={0}
|
|
||||||
role="button"
|
|
||||||
title={t`Edit email`}
|
|
||||||
>
|
|
||||||
{me?.email ?? t`Add email…`}
|
|
||||||
<span className="profile-description-edit-btn" aria-hidden>
|
|
||||||
✎
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
{avatarError && (
|
{avatarError && (
|
||||||
<ErrorCard
|
<ErrorCard
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
196
src/components/social/CommunityFeed.tsx
Normal file
196
src/components/social/CommunityFeed.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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. */
|
||||||
|
|||||||
36
src/contexts/social/CommunityFeedContext.ts
Normal file
36
src/contexts/social/CommunityFeedContext.ts
Normal 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);
|
||||||
118
src/contexts/social/CommunityFeedProvider.tsx
Normal file
118
src/contexts/social/CommunityFeedProvider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
src/fonts.css
166
src/fonts.css
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
21
src/hooks/social/useCommunityFeed.ts
Normal file
21
src/hooks/social/useCommunityFeed.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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,45 +408,23 @@ 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 () => {
|
method: "PUT",
|
||||||
const trimmed = titleDraft.trim();
|
body: JSON.stringify(changes),
|
||||||
if (!trimmed || trimmed.length > VALIDATION.DUMP_TITLE_MAX) return;
|
}),
|
||||||
|
),
|
||||||
setTitleSaving(true);
|
);
|
||||||
setTitleError(null);
|
setDumpState({ status: "loaded", dump: updatedDump });
|
||||||
try {
|
if (updatedDump.slug !== dump.slug) {
|
||||||
const res = await authFetch(`${API_URL}/api/dumps/${dump.id}`, {
|
navigate(dumpUrl(updatedDump), {
|
||||||
method: "PUT",
|
replace: true,
|
||||||
body: JSON.stringify(
|
state: { dump: updatedDump },
|
||||||
{ 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 });
|
|
||||||
if (updatedDump.slug !== dump.slug) {
|
|
||||||
navigate(dumpUrl(updatedDump), {
|
|
||||||
replace: true,
|
|
||||||
state: { dump: updatedDump },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setTitleError(friendlyFetchError(err));
|
|
||||||
} finally {
|
|
||||||
setTitleSaving(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -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}
|
maxLength={VALIDATION.DUMP_TITLE_MAX}
|
||||||
onChange={(e) => setTitleDraft(e.target.value)}
|
fieldClassName="dump-title"
|
||||||
onKeyDown={(e) => {
|
>
|
||||||
if (e.key === "Enter") {
|
<h1 className="dump-title">{dump.title}</h1>
|
||||||
e.preventDefault();
|
</InlineEdit>
|
||||||
handleTitleSave();
|
|
||||||
} else if (e.key === "Escape") {
|
|
||||||
setTitleEditing(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
maxLength={VALIDATION.DUMP_TITLE_MAX}
|
|
||||||
disabled={titleSaving}
|
|
||||||
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>
|
|
||||||
{canEdit && (
|
|
||||||
<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>
|
||||||
|
|
||||||
|
|||||||
@@ -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") && (
|
||||||
|
|||||||
@@ -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
74
src/test/App.test.tsx
Normal 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
8
src/test/before-react.ts
Normal 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 {};
|
||||||
@@ -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" }),
|
||||||
|
|||||||
88
src/test/components/form/InlineEdit.test.tsx
Normal file
88
src/test/components/form/InlineEdit.test.tsx
Normal 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!");
|
||||||
|
});
|
||||||
|
});
|
||||||
105
src/test/components/layout/Marquee.test.tsx
Normal file
105
src/test/components/layout/Marquee.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
118
src/test/components/social/CommunityFeed.test.tsx
Normal file
118
src/test/components/social/CommunityFeed.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
138
src/test/contexts/social/CommunityFeedProvider.test.tsx
Normal file
138
src/test/contexts/social/CommunityFeedProvider.test.tsx
Normal 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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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}`)}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
session,
|
||||||
<DumpEdit />
|
categories: [music],
|
||||||
</RestrictedLoggedIn>,
|
});
|
||||||
{
|
|
||||||
route: `/@owner/dumps/${dump.slug}/edit`,
|
|
||||||
path: "/:handle/dumps/:slug/edit",
|
|
||||||
session,
|
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" }],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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("/"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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}",
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
expect(screen.getByPlaceholderText("Username")).toHaveAttribute(
|
await waitFor(() =>
|
||||||
"aria-invalid",
|
expect(screen.getByPlaceholderText("Username")).toHaveAttribute(
|
||||||
"true",
|
"aria-invalid",
|
||||||
|
"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(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,20 +265,22 @@ export function renderApp(ui: ReactElement, opts: AppOptions = {}) {
|
|||||||
<PlayerProvider>
|
<PlayerProvider>
|
||||||
<WSProvider>
|
<WSProvider>
|
||||||
<FollowProvider>
|
<FollowProvider>
|
||||||
<CategoriesProvider>
|
<CommunityFeedProvider>
|
||||||
<MemoryRouter
|
<CategoriesProvider>
|
||||||
initialEntries={entries}
|
<MemoryRouter
|
||||||
initialIndex={entries.length - 1}
|
initialEntries={entries}
|
||||||
>
|
initialIndex={entries.length - 1}
|
||||||
<ChatProvider>
|
>
|
||||||
<Routes>
|
<ChatProvider>
|
||||||
<Route path={path} element={children} />
|
<Routes>
|
||||||
{extraRoutes}
|
<Route path={path} element={children} />
|
||||||
</Routes>
|
{extraRoutes}
|
||||||
<LocationProbe />
|
</Routes>
|
||||||
</ChatProvider>
|
<LocationProbe />
|
||||||
</MemoryRouter>
|
</ChatProvider>
|
||||||
</CategoriesProvider>
|
</MemoryRouter>
|
||||||
|
</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 });
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user