Compare commits
22 Commits
a2ec163ee5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eec73e9749 | ||
|
|
3e03626cc1 | ||
|
|
b92c674726 | ||
|
|
7d0a0e7534 | ||
|
|
51439f475d | ||
|
|
62e51f1622 | ||
|
|
eb4e64683c | ||
|
|
d54ae554a4 | ||
|
|
3e15e9a4f2 | ||
|
|
cbb770bff8 | ||
|
|
d2b7d21824 | ||
|
|
90fc281e90 | ||
|
|
6ab398e687 | ||
|
|
1bd5c924b0 | ||
|
|
de223baaa4 | ||
|
|
561cdf98b0 | ||
|
|
350e945eb1 | ||
|
|
dde0232acb | ||
|
|
7dd00068df | ||
|
|
28f910b00e | ||
|
|
ffc071af4c | ||
|
|
1d56c323a4 |
@@ -1,11 +1,20 @@
|
||||
FROM denoland/deno:bin-2.9.0 AS deno
|
||||
FROM mcr.microsoft.com/devcontainers/typescript-node:20
|
||||
FROM denoland/deno:bin-2.9.7 AS deno
|
||||
FROM mcr.microsoft.com/devcontainers/typescript-node:26
|
||||
COPY --from=deno /deno /usr/local/bin/deno
|
||||
# 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.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg \
|
||||
&& 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
|
||||
# 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
|
||||
|
||||
35
API.md
35
API.md
@@ -15,9 +15,11 @@ default view is the bare path, with no redirect. A category is a segment where
|
||||
the page is the category's own, a dump category's feed at the root (`/music`)
|
||||
and a playlist category's under `/playlists` (`/playlists/mixes`); where it
|
||||
narrows a list on another page, a profile's or a search's, it is `?category=`,
|
||||
of the kind that list holds. Switching tabs, or a tab's views, takes the place
|
||||
of the current history entry; picking a category, or opening another page or an
|
||||
editor, adds one.
|
||||
of the kind that list holds. The order of a list of playlists is `?sort=`, the
|
||||
default order being the bare path, and the playlists page's list of the viewer's
|
||||
own is `?mine=1`. Switching tabs, a tab's views or a list's order takes the
|
||||
place of the current history entry; picking a category, or opening another page
|
||||
or an editor, adds one.
|
||||
|
||||
**API.** A resource is `/api/<plural>/<key>[/<noun>]`. Its key is its id, except
|
||||
a user's, which may also be `@name`. The `@` keeps the two apart, so no regex
|
||||
@@ -33,14 +35,14 @@ has one word, the same as in pages.
|
||||
| `/` | `/hot`, `/new`, `/journal`, `/follow`, `/follow/playlists`; the bare `/` is the viewer's default tab, the bare `/follow` the users followed | anyone, `/follow` signed in | `Index` |
|
||||
| `/<category>` | `/<category>/hot`, `/new`, `/journal`, `/follow`, `/follow/playlists` | anyone, `/follow` signed in | `Index` |
|
||||
| `/@<name>` | the overview: dumps beside upvoted | anyone | `UserPublicProfile` |
|
||||
| `/@<name>/dumps`, `/upvoted`, `/playlists`, `/invitees` | `?category=` narrows the dumps, the upvoted or the playlists | anyone | `UserPublicProfile` |
|
||||
| `/@<name>/dumps`, `/upvoted`, `/playlists`, `/invitees` | `?category=` narrows the dumps, the upvoted or the playlists; `?sort=` orders the playlists | anyone | `UserPublicProfile` |
|
||||
| `/@<name>/follow` | `/@<name>/follow/playlists`; the bare tab lists users | anyone | `UserPublicProfile` |
|
||||
| `/@<name>/manage` | | `user:manage`, on others' profiles | `UserPublicProfile` |
|
||||
| `/@<name>/dumps/<slug>` | | anyone; a private dump, its poster | `Dump` |
|
||||
| `/@<name>/dumps/<slug>/edit` | | its poster, `dump:moderate` | `DumpEdit` |
|
||||
| `/@<name>/playlists/<slug>` | | anyone; a private playlist, its owner | `PlaylistDetail` |
|
||||
| `/@<name>/playlists/<slug>/edit` | | its owner, `playlist:moderate` | `PlaylistDetail` |
|
||||
| `/playlists` | `/playlists/<category>`, a playlist category's | anyone | `Playlists` |
|
||||
| `/playlists` | `/playlists/<category>`, a playlist category's; `?sort=new`, `?sort=dumps` order either; `?mine=1` lists the viewer's own, signed in only | anyone | `Playlists` |
|
||||
| `/search?q=` | `/search/users?q=`, `/search/playlists?q=`; `?category=` narrows the dumps or the playlists | anyone | `Search` |
|
||||
| `/notifications` | | signed in | `Notifications` |
|
||||
| `/community` | `/community/monthly`; the bare path is the week | signed in | `Community` |
|
||||
@@ -103,6 +105,15 @@ status. A list answers `{ "items", "hasMore" }` and takes `?page=` (from 1) and
|
||||
thumbnail, waveform, cover or attachment takes the owner's media token as
|
||||
`?token=`.
|
||||
|
||||
An image the API serves, an avatar, a thumbnail, a cover, an attachment or a
|
||||
remote image, takes `?w=` and answers a WebP copy scaled down to that width,
|
||||
snapped up to one of `shared/image.ts`'s (128, 320, 640, 960), and never wider
|
||||
than the image; without it, the image as it was uploaded, a dump's thumbnail
|
||||
alone being scaled to the widest by default. `?f=compat` asks for the copy as a
|
||||
JPEG or PNG, for a mail client that reads no WebP. An image no copy can be made
|
||||
of goes out as it is. JSON answers of a kilobyte or more are compressed, in
|
||||
brotli or gzip, for a client that accepts it.
|
||||
|
||||
### me
|
||||
|
||||
- `GET /api/me` — signed in. The caller's own user.
|
||||
@@ -135,7 +146,7 @@ there: those endpoints answer it 400.
|
||||
- `GET /api/users/:key/avatar` — anyone. The avatar.
|
||||
- `GET /api/users/:key/dumps`, `/upvoted`, `/playlists` — anyone; private items
|
||||
to their owner only. Their lists; `?category=` narrows one to a category of
|
||||
its kind.
|
||||
its kind, and `?sort=` orders the playlists as `GET /api/playlists` does.
|
||||
- `GET /api/users/:key/dumps/:slug`, `/playlists/:slug` — anyone; private items
|
||||
to their owner only. An item by its page path, by an alias too.
|
||||
- `GET /api/users/:key/follow/users`, `/follow/playlists` — anyone. What they
|
||||
@@ -174,8 +185,10 @@ there: those endpoints answer it 400.
|
||||
|
||||
- `POST /api/playlists` — signed in. Creates a playlist, filed under the
|
||||
playlist categories it names.
|
||||
- `GET /api/playlists` — anyone. Every public playlist, newest first;
|
||||
`?category=<slug>` narrows it to a playlist category.
|
||||
- `GET /api/playlists` — anyone. Every public playlist; `?category=<slug>`
|
||||
narrows it to a playlist category, and `?sort=updated|new|dumps` orders it:
|
||||
the last to take a dump first (the default), the newest, or the fullest. The
|
||||
caller's own private dumps count, and date their playlists, for them alone.
|
||||
- `GET /api/playlists/:id` — anyone who may see it. A playlist and its dumps.
|
||||
- `PUT`, `DELETE /api/playlists/:id` — its owner, `playlist:moderate`. Title,
|
||||
slug, description, visibility, categories; deletion.
|
||||
@@ -203,6 +216,12 @@ there: those endpoints answer it 400.
|
||||
- `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)
|
||||
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
|
||||
|
||||
|
||||
@@ -199,7 +199,7 @@ api/
|
||||
test/ # Backend tests (Deno): test/<layer>/<module>.test.ts
|
||||
sql/
|
||||
gerbeur.db # SQLite database (not committed); <name>.db per dev instance
|
||||
uploads/ # User-uploaded files — avatars, dumps, thumbnails, … (not committed); <name>/ per dev instance
|
||||
uploads/ # User-uploaded files — avatars, dumps, thumbnails, … and scaled/, their copies made on request (not committed); <name>/ per dev instance
|
||||
src/ # React frontend (Vite)
|
||||
config/ # API base URL, default feed tab, player mode, instance settings
|
||||
pages/ # One component per route, nothing else
|
||||
|
||||
@@ -120,6 +120,12 @@ export const ATTACHMENTS_DIR = `${UPLOADS_DIR}/attachments`;
|
||||
* thumbnails route generates from file dumps.
|
||||
*/
|
||||
export const THUMBNAILS_DIR = `${UPLOADS_DIR}/thumbnails`;
|
||||
/**
|
||||
* Holds the scaled-down copies of the images above, which lib/ffmpeg.ts makes
|
||||
* as they are first asked for, each at its upload's path with its width. It
|
||||
* can be emptied at any time: the copies are made again.
|
||||
*/
|
||||
export const SCALED_DIR = `${UPLOADS_DIR}/scaled`;
|
||||
|
||||
/**
|
||||
* Caps the size of an image upload, in bytes, for avatars, covers, thumbnails
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ORPHANED_ATTACHMENTS_RETENTION_HOURS,
|
||||
UNUSED_INVITES_RETENTION_DAYS,
|
||||
} from "../config.ts";
|
||||
import { removeScaledImages } from "../lib/ffmpeg.ts";
|
||||
import { hashPassword } from "../lib/password.ts";
|
||||
|
||||
/**
|
||||
@@ -60,6 +61,7 @@ export async function runStartupTasks(db: DatabaseSync): Promise<void> {
|
||||
if (orphanedAttachments.length > 0) {
|
||||
for (const { id } of orphanedAttachments) {
|
||||
await Deno.remove(`${ATTACHMENTS_DIR}/${id}`).catch(() => {});
|
||||
await removeScaledImages(`${ATTACHMENTS_DIR}/${id}`);
|
||||
}
|
||||
db.prepare(`DELETE FROM attachments WHERE ${ORPHANED};`)
|
||||
.run(attachmentCutoff);
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
/**
|
||||
* Makes what the server derives from a file dump's upload with ffmpeg: a
|
||||
* video's still and an image's scaled-down copy, which the thumbnails route
|
||||
* serves, and an audio file's waveform, which the waveform route serves. Each
|
||||
* is made on its first request and kept in `THUMBNAILS_DIR` under a name this
|
||||
* module gives, until dump-service replaces or deletes the upload and
|
||||
* {@link removeGeneratedMedia} drops them.
|
||||
* Makes what the server derives from an upload with ffmpeg: a video's still,
|
||||
* which the thumbnails route serves, an audio file's waveform, which the
|
||||
* waveform route serves, and the scaled-down copies of any image, which
|
||||
* `serveImage` (lib/upload.ts) serves for every route that answers one. Each
|
||||
* is made on its first request and kept under a name this module gives, a
|
||||
* dump's in `THUMBNAILS_DIR` and the scaled copies in `SCALED_DIR`, until
|
||||
* dump-service replaces or deletes the upload and
|
||||
* {@link removeGeneratedMedia} drops them. {@link generatedMediaSweepJob}
|
||||
* takes what that leaves behind.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { THUMBNAILS_DIR } from "../config.ts";
|
||||
import { dirname, relative } from "node:path";
|
||||
import {
|
||||
DUMPS_DIR,
|
||||
SCALED_DIR,
|
||||
THUMBNAILS_DIR,
|
||||
UPLOADS_DIR,
|
||||
} from "../config.ts";
|
||||
import { IMAGE_WIDTHS, type ImageWidth } from "../../shared/image.ts";
|
||||
import type { Job } from "./scheduler.ts";
|
||||
import { type DumpWaveform, peaksOf } from "../../shared/waveform.ts";
|
||||
import type { APIResponse } from "../../shared/api.ts";
|
||||
|
||||
@@ -48,7 +59,7 @@ const VIDEO_FORMATS =
|
||||
* Lists the formats ffmpeg may read an image as, for the reason given at
|
||||
* {@link VIDEO_FORMATS}.
|
||||
*/
|
||||
const IMAGE_FORMATS = "jpeg_pipe,png_pipe,webp_pipe,bmp_pipe";
|
||||
const IMAGE_FORMATS = "jpeg_pipe,png_pipe,webp_pipe,bmp_pipe,gif";
|
||||
/**
|
||||
* Lists the formats ffmpeg may read an audio file as, for the reason given at
|
||||
* {@link VIDEO_FORMATS}; an `.m4a` or a `.webm` is read by its container's.
|
||||
@@ -57,35 +68,50 @@ const AUDIO_FORMATS =
|
||||
"mp3,ogg,flac,wav,aiff,aac,asf,caf,w64,wv,ape,mov,mp4,m4a,matroska,webm";
|
||||
|
||||
/**
|
||||
* Maps the image types that get a scaled-down preview to the preview's format.
|
||||
* A GIF is served as it is, to keep its animation.
|
||||
* Returns where the still and the waveform generated from dump `dumpId`'s
|
||||
* upload are kept.
|
||||
*/
|
||||
export const PREVIEWABLE_IMAGES: Record<string, "jpg" | "png"> = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/bmp": "jpg",
|
||||
// May carry transparency, which JPEG would fill with black.
|
||||
"image/png": "png",
|
||||
"image/webp": "png",
|
||||
};
|
||||
|
||||
/**
|
||||
* Caps an image preview's width. It covers the widest feed card on a
|
||||
* high-density screen, at a fraction of a phone photo's bytes.
|
||||
*/
|
||||
const PREVIEW_WIDTH = 960;
|
||||
|
||||
/** Returns where the media generated from dump `dumpId`'s upload are kept. */
|
||||
export function generatedPaths(dumpId: string): {
|
||||
still: string;
|
||||
preview: Record<"jpg" | "png", string>;
|
||||
waveform: string;
|
||||
} {
|
||||
const base = `${THUMBNAILS_DIR}/${dumpId}`;
|
||||
return {
|
||||
still: `${base}.jpg`,
|
||||
preview: { jpg: `${base}.preview.jpg`, png: `${base}.preview.png` },
|
||||
waveform: `${base}.waveform.json`,
|
||||
};
|
||||
return { still: `${base}.jpg`, waveform: `${base}.waveform.json` };
|
||||
}
|
||||
|
||||
/**
|
||||
* The formats a scaled copy comes in: WebP for a browser, and a JPEG or a PNG
|
||||
* for what doesn't read it, as some mail clients don't.
|
||||
*/
|
||||
export type ScaledFormat = "webp" | "jpg" | "png";
|
||||
const SCALED_FORMATS: readonly ScaledFormat[] = ["webp", "jpg", "png"];
|
||||
|
||||
/**
|
||||
* Returns where the copy of the upload at `srcPath` scaled to `width` is
|
||||
* kept, in `format`: under `SCALED_DIR`, at the upload's own path below
|
||||
* `UPLOADS_DIR`.
|
||||
*/
|
||||
export function scaledImagePath(
|
||||
srcPath: string,
|
||||
width: ImageWidth,
|
||||
format: ScaledFormat = "webp",
|
||||
): string {
|
||||
return `${SCALED_DIR}/${relative(UPLOADS_DIR, srcPath)}.w${width}.${format}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the scaled copies of the upload at `srcPath`, once it is deleted.
|
||||
* One that is replaced needs no call: its copies are made again, being older
|
||||
* than it ({@link ensureGenerated}).
|
||||
*/
|
||||
export async function removeScaledImages(srcPath: string): Promise<void> {
|
||||
await Promise.all(
|
||||
IMAGE_WIDTHS.flatMap((width) =>
|
||||
SCALED_FORMATS.map((format) =>
|
||||
Deno.remove(scaledImagePath(srcPath, width, format)).catch(() => {})
|
||||
)
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,12 +119,11 @@ export function generatedPaths(dumpId: string): {
|
||||
* has replaced or deleted it. The routes make them again from a new one.
|
||||
*/
|
||||
export async function removeGeneratedMedia(dumpId: string): Promise<void> {
|
||||
const { still, preview, waveform } = generatedPaths(dumpId);
|
||||
await Promise.all(
|
||||
[still, preview.jpg, preview.png, waveform].map((path) =>
|
||||
Deno.remove(path).catch(() => {})
|
||||
),
|
||||
);
|
||||
const { still, waveform } = generatedPaths(dumpId);
|
||||
await Promise.all([
|
||||
...[still, waveform].map((path) => Deno.remove(path).catch(() => {})),
|
||||
removeScaledImages(`${DUMPS_DIR}/${dumpId}`),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,7 +134,7 @@ async function writeAtomically(
|
||||
outPath: string,
|
||||
write: (tmpPath: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
await Deno.mkdir(THUMBNAILS_DIR, { recursive: true });
|
||||
await Deno.mkdir(dirname(outPath), { recursive: true });
|
||||
const tmpPath = `${outPath}.${crypto.randomUUID()}.tmp`;
|
||||
try {
|
||||
await write(tmpPath);
|
||||
@@ -156,25 +181,104 @@ export function videoStill(srcPath: string, outPath: string): Promise<void> {
|
||||
], outPath);
|
||||
}
|
||||
|
||||
/** Writes a copy of an image at most {@link PREVIEW_WIDTH} wide. */
|
||||
export function imagePreview(srcPath: string, outPath: string): Promise<void> {
|
||||
/**
|
||||
* Maps a JPEG's EXIF orientation to the filters that turn its pixels upright.
|
||||
* A browser applies the orientation to the upload itself; the copy carries no
|
||||
* EXIF, so it is turned here, and not by ffmpeg's own autorotation, which
|
||||
* only its recent versions apply to images.
|
||||
*/
|
||||
const ORIENTATION_FILTERS: Record<number, string> = {
|
||||
2: "hflip",
|
||||
3: "hflip,vflip",
|
||||
4: "vflip",
|
||||
5: "transpose=0",
|
||||
6: "transpose=1",
|
||||
7: "transpose=3",
|
||||
8: "transpose=2",
|
||||
};
|
||||
|
||||
/** What ffmpeg is told to write each {@link ScaledFormat} with. */
|
||||
const ENCODERS: Record<ScaledFormat, string[]> = {
|
||||
webp: ["-c:v", "libwebp", "-q:v", "80", "-loop", "0", "-f", "webp"],
|
||||
jpg: ["-c:v", "mjpeg", "-q:v", "4", "-f", "image2"],
|
||||
png: ["-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
|
||||
* 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.
|
||||
*/
|
||||
export function scaleImage(
|
||||
srcPath: string,
|
||||
outPath: string,
|
||||
width: ImageWidth,
|
||||
{ orientation, animated }: ImageTraits,
|
||||
format: ScaledFormat = "webp",
|
||||
): Promise<void> {
|
||||
const upright = ORIENTATION_FILTERS[orientation];
|
||||
// A JPEG's colour planes halve its height, which they need even.
|
||||
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([
|
||||
"-noautorotate",
|
||||
"-format_whitelist",
|
||||
IMAGE_FORMATS,
|
||||
"-i",
|
||||
srcPath,
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-vf",
|
||||
`scale='min(${PREVIEW_WIDTH},iw)':-2`,
|
||||
"-f",
|
||||
"image2",
|
||||
...(outPath.endsWith(".png")
|
||||
? ["-c:v", "png"]
|
||||
: ["-c:v", "mjpeg", "-q:v", "4"]),
|
||||
`${upright ? `${upright},` : ""}scale='min(${width},iw)':${height}`,
|
||||
"-an",
|
||||
...frames,
|
||||
...ENCODERS[format],
|
||||
], outPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `bytes`, an image held in memory, scaled as {@link scaleImage}
|
||||
* does, or null when it can't be, because ffmpeg is missing or failed. The
|
||||
* image proxy scales what it fetched with it, and keeps the result itself.
|
||||
*/
|
||||
export async function scaleImageBytes(
|
||||
bytes: Uint8Array,
|
||||
width: ImageWidth,
|
||||
traits: ImageTraits,
|
||||
): Promise<Uint8Array | null> {
|
||||
if (!await ffmpegAvailable()) return null;
|
||||
const srcPath = `${SCALED_DIR}/tmp/${crypto.randomUUID()}`;
|
||||
const outPath = `${srcPath}.webp`;
|
||||
try {
|
||||
await Deno.mkdir(dirname(srcPath), { recursive: true });
|
||||
await Deno.writeFile(srcPath, bytes);
|
||||
await scaleImage(srcPath, outPath, width, traits);
|
||||
return await Deno.readFile(outPath);
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
await Promise.all(
|
||||
[srcPath, outPath].map((path) => Deno.remove(path).catch(() => {})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The sample rate the audio is decoded at for its waveform. A bar stands for
|
||||
* a second or more of a song, so 8 kHz keeps every peak worth drawing at a
|
||||
@@ -269,20 +373,140 @@ export function audioWaveform(srcPath: string, outPath: string): Promise<void> {
|
||||
*/
|
||||
const inFlight = new Map<string, Promise<void>>();
|
||||
|
||||
/**
|
||||
* Spares an ffmpeg run per request for an image it can't read: the
|
||||
* generations that failed, by output path, with the date of the source they
|
||||
* failed on. A new upload gets a new try.
|
||||
*/
|
||||
const failed = new Map<string, number>();
|
||||
|
||||
/** Returns the date `path` was last written, or null when it doesn't exist. */
|
||||
async function writtenAt(path: string): Promise<number | null> {
|
||||
try {
|
||||
return (await Deno.stat(path)).mtime?.getTime() ?? 0;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes `outPath` with `generate` unless it exists. Resolves false when it
|
||||
* can't be made, because ffmpeg is missing or failed.
|
||||
*
|
||||
* With `srcPath`, the file it is made from, `outPath` takes that file's date,
|
||||
* and is made again when the two differ: an upload replaced in place, as an
|
||||
* avatar or a cover is, gets new copies without anyone deleting the old.
|
||||
* Without it, the caller deletes `outPath` when its source changes.
|
||||
*/
|
||||
export async function ensureGenerated(
|
||||
outPath: string,
|
||||
generate: () => Promise<void>,
|
||||
srcPath?: string,
|
||||
): Promise<boolean> {
|
||||
if (await Deno.stat(outPath).then(() => true, () => false)) return true;
|
||||
const made = await writtenAt(outPath);
|
||||
let version: number | null = null;
|
||||
if (srcPath) {
|
||||
version = await writtenAt(srcPath);
|
||||
if (version === null) return false;
|
||||
if (made === version) return true;
|
||||
if (failed.get(outPath) === version) return false;
|
||||
} else if (made !== null) {
|
||||
return true;
|
||||
}
|
||||
if (!await ffmpegAvailable()) return false;
|
||||
let pending = inFlight.get(outPath);
|
||||
if (!pending) {
|
||||
pending = generate().finally(() => inFlight.delete(outPath));
|
||||
pending = generate()
|
||||
.then(() =>
|
||||
version === null
|
||||
? undefined
|
||||
: Deno.utime(outPath, new Date(), new Date(version))
|
||||
)
|
||||
.catch((err) => {
|
||||
if (version !== null) failed.set(outPath, version);
|
||||
throw err;
|
||||
})
|
||||
.finally(() => inFlight.delete(outPath));
|
||||
inFlight.set(outPath, pending);
|
||||
}
|
||||
return await pending.then(() => true, () => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* How old a temporary file must be for the sweep to take it: one being
|
||||
* written, by a generation under way, is seconds old, and one a crash left
|
||||
* is never finished.
|
||||
*/
|
||||
const ABANDONED_AFTER_MS = 60 * 60_000;
|
||||
|
||||
/** Yields the path of every file under `dir`, and nothing when it is missing. */
|
||||
async function* filesUnder(dir: string): AsyncGenerator<string> {
|
||||
let entries: Deno.DirEntry[];
|
||||
try {
|
||||
entries = await Array.fromAsync(Deno.readDir(dir));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const path = `${dir}/${entry.name}`;
|
||||
if (entry.isDirectory) yield* filesUnder(path);
|
||||
else if (entry.isFile) yield path;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the generated files nothing serves any more, and returns how many:
|
||||
* the scaled copies of an upload that is gone, which the services' own
|
||||
* removals missed or never made, as for a user's avatar; the previews of
|
||||
* image dumps under their former name, `<id>.preview.jpg` or `.png`, which
|
||||
* the scaled copies replaced; and the temporary files a generation left
|
||||
* unfinished. Nothing else of `THUMBNAILS_DIR` is touched, the custom
|
||||
* thumbnails kept there being uploads.
|
||||
*/
|
||||
export async function sweepGeneratedMedia(): Promise<number> {
|
||||
let removed = 0;
|
||||
const remove = (path: string) =>
|
||||
Deno.remove(path).then(() => {
|
||||
removed++;
|
||||
}, () => {});
|
||||
const abandoned = async (path: string) => {
|
||||
const at = await writtenAt(path);
|
||||
return at !== null && Date.now() - at > ABANDONED_AFTER_MS;
|
||||
};
|
||||
|
||||
for await (const path of filesUnder(THUMBNAILS_DIR)) {
|
||||
if (
|
||||
/\.preview\.(jpg|png)$/.test(path) ||
|
||||
(path.endsWith(".tmp") && await abandoned(path))
|
||||
) await remove(path);
|
||||
}
|
||||
|
||||
for await (const path of filesUnder(SCALED_DIR)) {
|
||||
// A copy is named after its upload's path: see scaledImagePath.
|
||||
const copy = relative(SCALED_DIR, path).match(
|
||||
/^(.+)\.w\d+\.(?:webp|jpg|png)$/,
|
||||
);
|
||||
const unused = copy
|
||||
? await writtenAt(`${UPLOADS_DIR}/${copy[1]}`) === null
|
||||
: await abandoned(path);
|
||||
if (unused) await remove(path);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs {@link sweepGeneratedMedia} a minute after the server starts, which
|
||||
* clears what a new version no longer uses as soon as it is deployed, then
|
||||
* once a day. `main.ts` starts it.
|
||||
*/
|
||||
export const generatedMediaSweepJob: Job = {
|
||||
name: "media-sweep",
|
||||
everyMs: 24 * 60 * 60_000,
|
||||
firstAfterMs: 60_000,
|
||||
async run() {
|
||||
const removed = await sweepGeneratedMedia();
|
||||
if (removed > 0) {
|
||||
console.log(`[media-sweep] removed ${removed} generated files unused`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Decides which of a playlist's dumps a viewer may see. The playlist service
|
||||
* filters its listings with it, and the WebSocket service filters each
|
||||
* client's `playlist_dumps_updated` message the same way.
|
||||
* filters its listings with it, the WebSocket service filters each client's
|
||||
* `playlist_dumps_updated` message the same way, and the queries that list
|
||||
* playlists count their dumps by the same rule.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -34,3 +35,53 @@ export function isMemberVisibleTo(
|
||||
return !member.isPrivate ||
|
||||
(member.userId === viewerId && viewerId === playlist.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The fields of a playlist's dump that {@link dumpFiguresFor} needs: those
|
||||
* that tell who may see it, and when it was added. The playlist service reads
|
||||
* them for its broadcasts.
|
||||
*/
|
||||
export interface DatedPlaylistMember extends PlaylistMember {
|
||||
addedAt: Date;
|
||||
}
|
||||
|
||||
/** A figure of the dumps of `playlists p` that the viewer `?1` may see. */
|
||||
const visibleDumps = (figure: string) =>
|
||||
`(SELECT ${figure} FROM playlist_dumps pd
|
||||
INNER JOIN dumps d ON d.id = pd.dump_id
|
||||
WHERE pd.playlist_id = p.id
|
||||
AND (d.is_private = 0 OR (d.user_id = p.user_id AND p.user_id = ?1))
|
||||
)`;
|
||||
|
||||
/**
|
||||
* The `dump_count` and `last_dump_at` columns of a query on `playlists p`:
|
||||
* how many of the playlist's dumps the viewer may see, by
|
||||
* {@link isMemberVisibleTo}'s rule, and when the latest of them was added,
|
||||
* null without any. Both read the viewer's id, or `""` for a signed-out one,
|
||||
* as `?1`: it is the query's first parameter, and each `?` after it takes the
|
||||
* next.
|
||||
*/
|
||||
export const VISIBLE_DUMP_FIGURES = `${visibleDumps("COUNT(*)")} as dump_count,
|
||||
${visibleDumps("MAX(pd.added_at)")} as last_dump_at`;
|
||||
|
||||
/**
|
||||
* Returns what `playlist` says of its dumps to the viewer, `members` being
|
||||
* all of them: the `dumpCount` and `lastDumpAt` that
|
||||
* {@link VISIBLE_DUMP_FIGURES} reads in a query. The WebSocket service puts
|
||||
* them on the playlist each socket is sent.
|
||||
*/
|
||||
export function dumpFiguresFor(
|
||||
members: DatedPlaylistMember[],
|
||||
playlist: Pick<Playlist, "userId">,
|
||||
viewerId: string | null | undefined,
|
||||
): Required<Pick<Playlist, "dumpCount">> & Pick<Playlist, "lastDumpAt"> {
|
||||
const visible = members.filter((m) =>
|
||||
isMemberVisibleTo(m, playlist, viewerId)
|
||||
);
|
||||
return {
|
||||
dumpCount: visible.length,
|
||||
lastDumpAt: visible.length
|
||||
? new Date(Math.max(...visible.map((m) => m.addedAt.getTime())))
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Checks the images users upload and serves them back. The avatar, playlist
|
||||
* cover, dump thumbnail and attachment routes call it.
|
||||
* Checks the images users upload and serves them back, scaled down to the
|
||||
* width a request names. The avatar, playlist cover, dump thumbnail and
|
||||
* attachment routes call it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -10,6 +11,14 @@ import { APIException } from "./api-exception.ts";
|
||||
import { serveFile } from "./serve-file.ts";
|
||||
import { dirname } from "node:path";
|
||||
import { MAX_IMAGE_SIZE_BYTES } from "../config.ts";
|
||||
import { type ImageWidth, parseImageWidth } from "../../shared/image.ts";
|
||||
import {
|
||||
ensureGenerated,
|
||||
type ImageTraits,
|
||||
type ScaledFormat,
|
||||
scaledImagePath,
|
||||
scaleImage,
|
||||
} from "./ffmpeg.ts";
|
||||
|
||||
/**
|
||||
* Returns the MIME type of a JPEG, PNG, GIF or WebP image from its magic
|
||||
@@ -62,10 +71,176 @@ export function validateImageUpload(data: Uint8Array): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves an uploaded image inline as `mime`, cached for `cacheMaxAge` seconds,
|
||||
* and only by the browser when `isPrivate`. It answers 404 and resolves false
|
||||
* when the file is missing. The caller checks first that the viewer may see
|
||||
* it.
|
||||
* Returns the EXIF orientation of a JPEG, 1 to 8, from the start of its
|
||||
* bytes, and 1, upright, when it states none. A phone stores a photo as its
|
||||
* sensor saw it and says here how to turn it.
|
||||
*/
|
||||
function jpegOrientation(data: Uint8Array): number {
|
||||
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
||||
// The segments follow the two-byte SOI, each a marker and its length.
|
||||
let at = 2;
|
||||
while (at + 4 <= data.length && data[at] === 0xFF) {
|
||||
const marker = data[at + 1];
|
||||
const length = view.getUint16(at + 2);
|
||||
// APP1 holding "Exif\0\0", then a TIFF header and its first directory.
|
||||
if (marker === 0xE1 && view.getUint32(at + 4) === 0x45786966) {
|
||||
const tiff = at + 10;
|
||||
if (tiff + 8 > data.length) return 1;
|
||||
const little = view.getUint16(tiff) === 0x4949;
|
||||
const ifd = tiff + view.getUint32(tiff + 4, little);
|
||||
if (ifd + 2 > data.length) return 1;
|
||||
const entries = view.getUint16(ifd, little);
|
||||
for (let i = 0; i < entries; i++) {
|
||||
const entry = ifd + 2 + i * 12;
|
||||
if (entry + 12 > data.length) return 1;
|
||||
if (view.getUint16(entry, little) === 0x0112) {
|
||||
const value = view.getUint16(entry + 8, little);
|
||||
return value >= 1 && value <= 8 ? value : 1;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
// The image data starts: no EXIF came before it.
|
||||
if (marker === 0xDA) return 1;
|
||||
at += 2 + length;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of a file {@link scalableImage} needs: the EXIF segment, which
|
||||
* comes first in a JPEG, is 64 KB at most.
|
||||
*/
|
||||
const IMAGE_HEAD_BYTES = 64 * 1024 + 32;
|
||||
|
||||
/** Returned by {@link scalableImage}, for `scaleImage`. */
|
||||
export interface ScalableImage extends ImageTraits {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
compat: "jpg" | "png";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns how to scale the image whose first {@link IMAGE_HEAD_BYTES} are
|
||||
* `head`, or null when it is to be served as it is: an animated WebP, which
|
||||
* ffmpeg can't read, or anything but a JPEG, PNG, GIF, WebP or BMP.
|
||||
*/
|
||||
export function scalableImage(head: Uint8Array): ScalableImage | null {
|
||||
const mime = detectImageMime(head);
|
||||
// A file dump may be a BMP ("BM"), which no upload route here accepts.
|
||||
if (!mime) {
|
||||
return head[0] === 0x42 && head[1] === 0x4D
|
||||
? { orientation: 1, animated: false, compat: "jpg" }
|
||||
: null;
|
||||
}
|
||||
// "RIFF....WEBPVP8X", whose flags then say whether frames follow.
|
||||
const animatedWebp = mime === "image/webp" && head[15] === 0x58 &&
|
||||
(head[20] & 0x02) !== 0;
|
||||
if (animatedWebp) return null;
|
||||
return mime === "image/jpeg"
|
||||
? { orientation: jpegOrientation(head), animated: false, compat: "jpg" }
|
||||
: { orientation: 1, animated: mime === "image/gif", compat: "png" };
|
||||
}
|
||||
|
||||
/** Reads the first {@link IMAGE_HEAD_BYTES} of the file at `path`. */
|
||||
async function readHead(path: string): Promise<Uint8Array | null> {
|
||||
let file: Deno.FsFile;
|
||||
try {
|
||||
file = await Deno.open(path, { read: true });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const head = new Uint8Array(IMAGE_HEAD_BYTES);
|
||||
let filled = 0;
|
||||
while (filled < head.length) {
|
||||
const n = await file.read(head.subarray(filled));
|
||||
if (n === null) break;
|
||||
filled += n;
|
||||
}
|
||||
return head.subarray(0, filled);
|
||||
} finally {
|
||||
file.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** The type each format of a scaled copy is served as. */
|
||||
const SCALED_MIMES: Record<ScaledFormat, string> = {
|
||||
webp: "image/webp",
|
||||
jpg: "image/jpeg",
|
||||
png: "image/png",
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the copy of the image at `path` scaled to `width`, made now unless
|
||||
* it was already, as its path and type, or null when there is none to serve:
|
||||
* the image can't be scaled ({@link scalableImage}), or ffmpeg is missing or
|
||||
* failed on it. The copy is a WebP, or with `compat` the JPEG or PNG a reader
|
||||
* without WebP takes.
|
||||
*/
|
||||
async function scaledCopy(
|
||||
path: string,
|
||||
width: ImageWidth,
|
||||
compat: boolean,
|
||||
): Promise<{ path: string; mime: string } | null> {
|
||||
// The other format depends on the image, so it is read before the copy is
|
||||
// looked for; a WebP's path is known without.
|
||||
let how = compat ? await describe(path) : undefined;
|
||||
if (how === null) return null;
|
||||
const format: ScaledFormat = how ? how.compat : "webp";
|
||||
const scaledPath = scaledImagePath(path, width, format);
|
||||
const made = await ensureGenerated(scaledPath, async () => {
|
||||
how ??= await describe(path);
|
||||
if (!how) throw new Error("Not an image that can be scaled");
|
||||
await scaleImage(path, scaledPath, width, how, format);
|
||||
}, path);
|
||||
return made ? { path: scaledPath, mime: SCALED_MIMES[format] } : null;
|
||||
}
|
||||
|
||||
/** Returns how to scale the image at `path`: see {@link scalableImage}. */
|
||||
async function describe(path: string): Promise<ScalableImage | null> {
|
||||
const head = await readHead(path);
|
||||
return head && scalableImage(head);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the image at `path`, of type `mime`, with `cacheControl`: a WebP copy
|
||||
* at most as wide as the request's `?w=` names (`shared/image.ts`), or as
|
||||
* `defaultWidth` without one, and the image as it is when the request names
|
||||
* no width or the copy can't be made. With `?f=compat`, the copy is a JPEG,
|
||||
* or a PNG for an image that may be transparent, which the digest's emails
|
||||
* ask for, since not every mail client reads WebP. Every route that answers
|
||||
* one of the site's own images ends here, so each takes both. It resolves
|
||||
* false, having sent nothing, when the file is missing.
|
||||
*/
|
||||
export async function serveImage(
|
||||
ctx: Context,
|
||||
path: string,
|
||||
mime: string,
|
||||
cacheControl: string,
|
||||
defaultWidth?: ImageWidth,
|
||||
): Promise<boolean> {
|
||||
// The browser must take the checked type, never guess another from the
|
||||
// bytes.
|
||||
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
|
||||
const width = parseImageWidth(ctx.request.url.searchParams.get("w")) ??
|
||||
defaultWidth;
|
||||
const compat = ctx.request.url.searchParams.get("f") === "compat";
|
||||
const scaled = width ? await scaledCopy(path, width, compat) : null;
|
||||
if (
|
||||
scaled &&
|
||||
await serveFile(ctx, scaled.path, { mime: scaled.mime, cacheControl })
|
||||
) return true;
|
||||
return await serveFile(ctx, path, { mime, cacheControl });
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves an uploaded image inline as `mime`, or scaled down as
|
||||
* {@link serveImage} does, cached for `cacheMaxAge` seconds, and only by the
|
||||
* browser when `isPrivate`. It answers 404 and resolves false when the file
|
||||
* is missing. The caller checks first that the viewer may see it.
|
||||
*/
|
||||
export async function serveUploadedFile(
|
||||
ctx: Context,
|
||||
@@ -73,16 +248,18 @@ export async function serveUploadedFile(
|
||||
mime: string,
|
||||
cacheMaxAge = 3600,
|
||||
isPrivate = false,
|
||||
defaultWidth?: ImageWidth,
|
||||
): Promise<boolean> {
|
||||
ctx.response.headers.set("Content-Disposition", "inline");
|
||||
// The browser must take the checked type, never guess another from the
|
||||
// bytes, and a frontend on another origin may still show the image.
|
||||
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
|
||||
// A frontend on another origin may still show the image.
|
||||
ctx.response.headers.set("Cross-Origin-Resource-Policy", "cross-origin");
|
||||
const served = await serveFile(ctx, filePath, {
|
||||
const served = await serveImage(
|
||||
ctx,
|
||||
filePath,
|
||||
mime,
|
||||
cacheControl: `${isPrivate ? "private" : "public"}, max-age=${cacheMaxAge}`,
|
||||
});
|
||||
`${isPrivate ? "private" : "public"}, max-age=${cacheMaxAge}`,
|
||||
defaultWidth,
|
||||
);
|
||||
if (!served) ctx.response.status = 404;
|
||||
return served;
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ const RECIPES: Record<StyleName, Recipe> = {
|
||||
},
|
||||
},
|
||||
melange: {
|
||||
webFontAxes: "opsz,wght@9..144,400;9..144,600;9..144,700",
|
||||
webFontAxes: "wght@400;600",
|
||||
scale: 1,
|
||||
avatarRadius: 16,
|
||||
title: { size: 32, weight: 600 },
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* jobs and listens on {@link LISTEN_HOST}:{@link PORT}, having first signed
|
||||
* the users out of other instances if they may no longer sign in there
|
||||
* (`FEDERATION.outgoing`). A request goes
|
||||
* through the error middleware, the OG tags and CORS, then the routers, one
|
||||
* through the error middleware, the compression of what is answered, the OG
|
||||
* tags and CORS, then the routers, one
|
||||
* per resource of the API (`API.md` lists them), and last the built frontend,
|
||||
* which answers every path no router did.
|
||||
*
|
||||
@@ -46,11 +47,13 @@ import {
|
||||
PORT,
|
||||
PUBLIC_URL,
|
||||
} from "./config.ts";
|
||||
import { compressMiddleware } from "./middleware/compress.ts";
|
||||
import { errorMiddleware } from "./middleware/error.ts";
|
||||
import { ogMiddleware } from "./middleware/og.ts";
|
||||
import { routeStaticFilesFrom } from "./lib/static.ts";
|
||||
import { startJobs } from "./lib/scheduler.ts";
|
||||
import { rateLimitSweepJob } from "./lib/rate-limit.ts";
|
||||
import { generatedMediaSweepJob } from "./lib/ffmpeg.ts";
|
||||
import { digestJob } from "./services/digest-service.ts";
|
||||
import { communityJob } from "./services/community-service.ts";
|
||||
import { pingJob, typingExpiryJob } from "./services/ws-service.ts";
|
||||
@@ -68,6 +71,7 @@ const app: Application = new Application();
|
||||
const cors = oakCors({ origin: ALLOWED_ORIGINS });
|
||||
|
||||
app.use(errorMiddleware);
|
||||
app.use(compressMiddleware);
|
||||
app.use(ogMiddleware);
|
||||
// oakCors calls next() without awaiting it when there is no origin, which
|
||||
// corrupts the response in Oak's async chain. So it only sees requests from an
|
||||
@@ -138,6 +142,7 @@ if (import.meta.main) {
|
||||
pingJob,
|
||||
typingExpiryJob,
|
||||
rateLimitSweepJob,
|
||||
generatedMediaSweepJob,
|
||||
outboxJob,
|
||||
]);
|
||||
await app.listen({ hostname: LISTEN_HOST, port: PORT });
|
||||
|
||||
108
api/middleware/compress.ts
Normal file
108
api/middleware/compress.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Compresses what the routes answer in JSON, and the HTML page, for the
|
||||
* browsers that accept it: a page of the feed is some twenty kilobytes as it
|
||||
* is, and a fifth of that compressed. main.ts installs it after the error
|
||||
* middleware, around everything that sets a body. The built frontend's files
|
||||
* aren't its concern: lib/static.ts sends the copies compressed at build time.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { Context, Next } from "@oak/oak";
|
||||
import { promisify } from "node:util";
|
||||
import { brotliCompress, constants as zlib, gzip } from "node:zlib";
|
||||
|
||||
const brotli = promisify(brotliCompress);
|
||||
const gz = promisify(gzip);
|
||||
|
||||
/**
|
||||
* Below this, the bytes saved aren't worth the work; it is the threshold the
|
||||
* build compresses the frontend's files from (vite.config.ts).
|
||||
*/
|
||||
const MIN_BYTES = 1024;
|
||||
|
||||
/**
|
||||
* Brotli's quality for a response made on the spot. Past 5 it takes several
|
||||
* times longer for a few percent; the frontend's files, compressed once, are
|
||||
* at 11.
|
||||
*/
|
||||
const BROTLI_QUALITY = 5;
|
||||
|
||||
/**
|
||||
* Returns the coding to answer with for an `Accept-Encoding` header, brotli
|
||||
* before gzip, or null when the client takes neither. A coding refused with
|
||||
* `q=0` isn't taken.
|
||||
*/
|
||||
function pickCoding(header: string | null): "br" | "gzip" | null {
|
||||
if (!header) return null;
|
||||
const accepted = new Set<string>();
|
||||
for (const part of header.split(",")) {
|
||||
const [name, ...params] = part.trim().toLowerCase().split(";");
|
||||
const q = params.map((p) => p.trim().match(/^q=([\d.]+)$/)?.[1])
|
||||
.find((v) => v !== undefined);
|
||||
if (q === undefined || Number(q) > 0) accepted.add(name.trim());
|
||||
}
|
||||
return accepted.has("br") ? "br" : accepted.has("gzip") ? "gzip" : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the text of a body this middleware compresses, or null for any
|
||||
* other: an object or array, which Oak would send as JSON, or a string whose
|
||||
* type its route has set. Files, streams and bytes are left alone, as they
|
||||
* are images, media and files compressed already.
|
||||
*/
|
||||
function textOf(ctx: Context): string | null {
|
||||
const body = ctx.response.body;
|
||||
if (typeof body === "string") {
|
||||
// Oak guesses an untyped string's type from the string itself, which it
|
||||
// couldn't from its compressed bytes.
|
||||
return ctx.response.type || ctx.response.headers.has("Content-Type")
|
||||
? body
|
||||
: null;
|
||||
}
|
||||
if (body === null || typeof body !== "object") return null;
|
||||
const proto = Object.getPrototypeOf(body);
|
||||
if (proto !== Object.prototype && !Array.isArray(body)) return null;
|
||||
ctx.response.type = "json";
|
||||
return JSON.stringify(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the response's JSON or text compressed, in brotli or gzip as the
|
||||
* request accepts, when it is at least {@link MIN_BYTES} long. A response
|
||||
* already encoded, a `HEAD`, and one marked `no-transform` go out as they are.
|
||||
*/
|
||||
export async function compressMiddleware(
|
||||
ctx: Context,
|
||||
next: Next,
|
||||
): Promise<void> {
|
||||
await next();
|
||||
|
||||
const headers = ctx.response.headers;
|
||||
if (
|
||||
ctx.request.method === "HEAD" || headers.has("Content-Encoding") ||
|
||||
headers.get("Cache-Control")?.includes("no-transform")
|
||||
) return;
|
||||
|
||||
const text = textOf(ctx);
|
||||
if (text === null) return;
|
||||
const raw = new TextEncoder().encode(text);
|
||||
if (raw.length < MIN_BYTES) return;
|
||||
|
||||
// A cache must not hand the compressed copy to a client that can't read it.
|
||||
headers.append("Vary", "Accept-Encoding");
|
||||
const coding = pickCoding(ctx.request.headers.get("Accept-Encoding"));
|
||||
if (!coding) return;
|
||||
|
||||
const compressed = coding === "br"
|
||||
? await brotli(raw, {
|
||||
params: {
|
||||
[zlib.BROTLI_PARAM_MODE]: zlib.BROTLI_MODE_TEXT,
|
||||
[zlib.BROTLI_PARAM_QUALITY]: BROTLI_QUALITY,
|
||||
[zlib.BROTLI_PARAM_SIZE_HINT]: raw.length,
|
||||
},
|
||||
})
|
||||
: await gz(raw);
|
||||
headers.set("Content-Encoding", coding);
|
||||
ctx.response.body = new Uint8Array(compressed);
|
||||
}
|
||||
@@ -10,9 +10,9 @@ import type { Playlist } from "../../shared/playlist.ts";
|
||||
|
||||
/**
|
||||
* Returned by the playlist and follow services' `SELECT`s on `playlists`,
|
||||
* which join the owner's name as `owner_username`. A query may add a
|
||||
* `dump_count` column, which {@link playlistRowToApi} carries over when
|
||||
* present.
|
||||
* which join the owner's name as `owner_username`. A query may add the
|
||||
* `dump_count` and `last_dump_at` columns (`VISIBLE_DUMP_FIGURES`), which
|
||||
* {@link playlistRowToApi} carries over when present.
|
||||
*/
|
||||
export interface PlaylistRow {
|
||||
id: string;
|
||||
@@ -62,6 +62,9 @@ export function playlistRowToApi(row: PlaylistRow): Playlist {
|
||||
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
|
||||
imageMime: row.image_mime ?? undefined,
|
||||
dumpCount: typeof row.dump_count === "number" ? row.dump_count : undefined,
|
||||
lastDumpAt: typeof row.last_dump_at === "string"
|
||||
? new Date(row.last_dump_at)
|
||||
: undefined,
|
||||
ownerUsername: row.owner_username,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
* http, which an https page would block, and those behind hotlink protection.
|
||||
* It fetches and caches the images `GET /api/remote/image` serves, which the
|
||||
* frontend routes a thumbnail through when it is on http or when loading it
|
||||
* directly failed.
|
||||
* directly failed, and scales them down as the site's own are.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { fetchWithTimeout, readBytesCapped } from "./fetch.ts";
|
||||
import type { ImageWidth } from "../../shared/image.ts";
|
||||
import { scaleImageBytes } from "../lib/ffmpeg.ts";
|
||||
import { scalableImage } from "../lib/upload.ts";
|
||||
|
||||
/**
|
||||
* Returned to `GET /api/remote/image` by {@link fetchProxiedImage}, which sends
|
||||
@@ -34,9 +37,9 @@ const PROXYABLE_IMAGE_TYPES = new Set([
|
||||
const IMAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const IMAGE_CACHE_MAX_BYTES = 32 * 1024 * 1024;
|
||||
/**
|
||||
* Proxied images, by URL, since every viewer of a feed asks for the same
|
||||
* thumbnails. The least recently used go first, and the Map's order is that
|
||||
* of use.
|
||||
* Proxied images, by URL and by the width they were scaled to, since every
|
||||
* viewer of a feed asks for the same thumbnails. The least recently used go
|
||||
* first, and the Map's order is that of use.
|
||||
*/
|
||||
const imageCache = new Map<
|
||||
string,
|
||||
@@ -89,15 +92,33 @@ export function clearImageCache(): void {
|
||||
/**
|
||||
* Returns the image at `url`, from the cache or else from its host; null when
|
||||
* the host answers with an error, or with anything but a raster image of 5 MB
|
||||
* at most. The route
|
||||
* checks `url` with `isValidHttpUrl` first.
|
||||
* at most. With `width`, it comes as a WebP copy scaled down to it, or as it
|
||||
* is when no copy can be made (`scalableImage`, and ffmpeg). The route checks
|
||||
* `url` with `isValidHttpUrl` first.
|
||||
*
|
||||
* @throws {Error} When the host can't be reached, as
|
||||
* {@link fetchWithTimeout} reports it.
|
||||
*/
|
||||
export async function fetchProxiedImage(
|
||||
url: string,
|
||||
width?: ImageWidth | null,
|
||||
): Promise<ProxiedImage | null> {
|
||||
if (!width) return await fetchOriginal(url);
|
||||
|
||||
const key = `${url}#w${width}`;
|
||||
const cached = readImageCache(key);
|
||||
if (cached) return cached;
|
||||
const original = await fetchOriginal(url);
|
||||
if (!original) return null;
|
||||
const how = scalableImage(original.bytes);
|
||||
const bytes = how && await scaleImageBytes(original.bytes, width, how);
|
||||
if (!bytes) return original;
|
||||
writeImageCache(key, bytes, "image/webp");
|
||||
return { bytes, contentType: "image/webp" };
|
||||
}
|
||||
|
||||
/** Returns the image at `url` as its host sends it: see above. */
|
||||
async function fetchOriginal(url: string): Promise<ProxiedImage | null> {
|
||||
const cached = readImageCache(url);
|
||||
if (cached) return cached;
|
||||
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
*
|
||||
* - `POST /api/attachments` — signed in. Stores an image (multipart `file`, at
|
||||
* most 5 MB) and answers its id.
|
||||
* - `GET /api/attachments/:attachmentId?token=` — public, but an image in a
|
||||
* private dump or playlist, or in a comment on a private dump, needs its
|
||||
* owner's media `token`. Serves the image.
|
||||
* - `GET /api/attachments/:attachmentId?w=&token=` — public, but an image in
|
||||
* a private dump or playlist, or in a comment on a private dump, needs its
|
||||
* owner's media `token`. Serves the image, scaled down to `w`
|
||||
* (`shared/image.ts`) when it names one.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/**
|
||||
* Stores and serves the users' avatars. The profile page uploads the avatar,
|
||||
* and the client's `Avatar` component loads it, with the user's `updatedAt` as
|
||||
* a cache-busting version.
|
||||
* and the client's `Avatar` component loads it at the size it shows, with the
|
||||
* user's `updatedAt` as a cache-busting version.
|
||||
*
|
||||
* - `PUT /api/me/avatar` — signed in. Replaces the caller's avatar (multipart
|
||||
* `file`, at most 5 MB), sends `user_updated` to everyone, and answers the
|
||||
* caller's user. 400 for an account from another instance, whose avatar
|
||||
* comes from there. The instances the caller signed in to are told.
|
||||
* - `GET /api/users/:key/avatar` — public. Serves a user's avatar; 404 without
|
||||
* one.
|
||||
* - `GET /api/users/:key/avatar?w=` — public. Serves a user's avatar, scaled
|
||||
* down to `w` (`shared/image.ts`) when it names one; 404 without one.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
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 { APIException } from "../lib/api-exception.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.
|
||||
@@ -34,4 +39,12 @@ router.get("/", authMiddleware, (ctx: AuthContext) => {
|
||||
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;
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
* playlist's id; its page reads it by its path, through
|
||||
* `GET /api/users/:key/playlists/:slug`.
|
||||
*
|
||||
* - `GET /api/playlists?category=&page=&limit=` — public. Answers a page of
|
||||
* every public playlist, newest first, or only those in the playlist
|
||||
* category at `category`, which 404s when there is none.
|
||||
* - `GET /api/playlists?category=&sort=&page=&limit=` — public. Answers a
|
||||
* page of every public playlist, in the order `sort` names
|
||||
* (`PLAYLIST_SORTS`, the last to take a dump first by default), or only
|
||||
* those in the playlist category at `category`, which 404s when there is
|
||||
* none.
|
||||
* - `POST /api/playlists` — signed in. Creates a playlist, filed under the
|
||||
* playlist categories it names.
|
||||
* - `GET /api/playlists/:playlistId` — optional sign-in. Answers a playlist
|
||||
@@ -24,9 +26,9 @@
|
||||
* or with `playlist:moderate`. Removes a dump.
|
||||
* - `PUT /api/playlists/:playlistId/cover` — signed in, as its owner or with
|
||||
* `playlist:moderate`. Replaces its cover (multipart `file`, at most 5 MB).
|
||||
* - `GET /api/playlists/:playlistId/cover?token=` — public, but a private
|
||||
* playlist's cover needs its owner's media `token`. Serves its cover, or 404s
|
||||
* without one.
|
||||
* - `GET /api/playlists/:playlistId/cover?w=&token=` — public, but a private
|
||||
* playlist's cover needs its owner's media `token`. Serves its cover, scaled
|
||||
* down to `w` (`shared/image.ts`) when it names one, or 404s without one.
|
||||
* - `PUT /api/playlists/:playlistId/order` — signed in, as its owner or with
|
||||
* `playlist:moderate`. Reorders the dumps the caller can see in it.
|
||||
*
|
||||
@@ -44,7 +46,11 @@ import {
|
||||
isReorderPlaylistRequest,
|
||||
isUpdatePlaylistRequest,
|
||||
} from "../validators/playlist.ts";
|
||||
import type { Playlist, PlaylistWithDumps } from "../../shared/playlist.ts";
|
||||
import {
|
||||
parsePlaylistSort,
|
||||
type Playlist,
|
||||
type PlaylistWithDumps,
|
||||
} from "../../shared/playlist.ts";
|
||||
import { authMiddleware, type AuthState } from "../middleware/auth.ts";
|
||||
import { can } from "../lib/permissions.ts";
|
||||
import {
|
||||
@@ -77,12 +83,19 @@ const router: Router<AuthState> = new Router<AuthState>({
|
||||
prefix: "/api/playlists",
|
||||
});
|
||||
|
||||
router.get("/", (ctx) => {
|
||||
router.get("/", async (ctx) => {
|
||||
const requestingUserId = await parseOptionalAuth(ctx);
|
||||
const params = ctx.request.url.searchParams;
|
||||
const { page, limit } = parsePagination(params);
|
||||
const slug = params.get("category");
|
||||
const categoryId = slug ? getCategoryBySlug("playlist", slug).id : undefined;
|
||||
const { items, hasMore } = listPublicPlaylists(page, limit, categoryId);
|
||||
const { items, hasMore } = listPublicPlaylists(
|
||||
requestingUserId,
|
||||
page,
|
||||
limit,
|
||||
parsePlaylistSort(params.get("sort")),
|
||||
categoryId,
|
||||
);
|
||||
const responseBody: APIResponse<PaginatedData<Playlist>> = {
|
||||
success: true,
|
||||
data: { items, hasMore },
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
*
|
||||
* - `GET /api/remote/preview?url=` — signed in. Answers the preview of a link,
|
||||
* and whether its page could be reached.
|
||||
* - `GET /api/remote/image?url=` — public. Serves a remote raster image
|
||||
* through this site; 400 when it isn't one, 502 when its host can't be
|
||||
* reached.
|
||||
* - `GET /api/remote/image?url=&w=` — public. Serves a remote raster image
|
||||
* through this site, scaled down to `w` (`shared/image.ts`) when it names
|
||||
* one; 400 when it isn't an image, 502 when its host can't be reached.
|
||||
* - `GET /api/remote/bandcamp?url=&force=` — public. Answers a Bandcamp
|
||||
* page's tracks and their stream URLs, for the native player; `force=1`
|
||||
* skips the server's cache.
|
||||
@@ -19,6 +19,7 @@
|
||||
*/
|
||||
|
||||
import { Router } from "@oak/oak";
|
||||
import { parseImageWidth } from "../../shared/image.ts";
|
||||
import { isValidHttpUrl } from "../remote/fetch.ts";
|
||||
import { fetchProxiedImage } from "../remote/image-proxy.ts";
|
||||
import { resolveBandcamp } from "../remote/bandcamp-streams.ts";
|
||||
@@ -88,7 +89,10 @@ router.get("/image", async (ctx) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const image = await fetchProxiedImage(url);
|
||||
const image = await fetchProxiedImage(
|
||||
url,
|
||||
parseImageWidth(ctx.request.url.searchParams.get("w")),
|
||||
);
|
||||
if (!image) {
|
||||
ctx.response.status = 400;
|
||||
return;
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
* from an upload are generated on first request by lib/ffmpeg.ts, which keeps
|
||||
* them.
|
||||
*
|
||||
* - `GET /api/dumps/:dumpId/thumbnail?v=&token=` — public; a private dump's
|
||||
* thumbnail needs its poster's media `token`. Serves the dump's custom
|
||||
* thumbnail, else one made from its upload: a still of a video, or a
|
||||
* scaled-down copy of an image (the image itself when none can be made); 404
|
||||
* when there is neither.
|
||||
* - `GET /api/dumps/:dumpId/thumbnail?v=&w=&token=` — public; a private
|
||||
* dump's thumbnail needs its poster's media `token`. Serves the dump's
|
||||
* custom thumbnail, else one made from its upload: a still of a video, or
|
||||
* its image. An image comes scaled down to `w` (`shared/image.ts`), or to
|
||||
* the widest of those widths without one, and as it is when no copy can be
|
||||
* made; 404 when there is neither.
|
||||
* - `PUT /api/dumps/:dumpId/thumbnail` — signed in, as its poster or with
|
||||
* `dump:moderate`. Replaces its custom thumbnail (multipart `file`, at most 5
|
||||
* MB).
|
||||
@@ -32,18 +33,14 @@ import {
|
||||
import { serveFile } from "../lib/serve-file.ts";
|
||||
import {
|
||||
replaceUploadedFile,
|
||||
serveImage,
|
||||
serveUploadedFile,
|
||||
validateImageUpload,
|
||||
} from "../lib/upload.ts";
|
||||
import { DUMPS_DIR } from "../config.ts";
|
||||
import { MAX_IMAGE_WIDTH } from "../../shared/image.ts";
|
||||
import { resolveMediaDump } from "../lib/dump-media.ts";
|
||||
import {
|
||||
ensureGenerated,
|
||||
generatedPaths,
|
||||
imagePreview,
|
||||
PREVIEWABLE_IMAGES,
|
||||
videoStill,
|
||||
} from "../lib/ffmpeg.ts";
|
||||
import { ensureGenerated, generatedPaths, videoStill } from "../lib/ffmpeg.ts";
|
||||
import { can } from "../lib/permissions.ts";
|
||||
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
|
||||
|
||||
@@ -69,6 +66,7 @@ router.get("/:dumpId/thumbnail", async (ctx) => {
|
||||
dump.thumbnailMime,
|
||||
0,
|
||||
dump.isPrivate,
|
||||
MAX_IMAGE_WIDTH,
|
||||
);
|
||||
if (served) return;
|
||||
}
|
||||
@@ -92,29 +90,12 @@ router.get("/:dumpId/thumbnail", async (ctx) => {
|
||||
) return;
|
||||
}
|
||||
|
||||
if (mime.startsWith("image/")) {
|
||||
// A scaled-down copy for the feed; the original when there's none to make
|
||||
// (a GIF, no ffmpeg, an image ffmpeg can't read).
|
||||
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
|
||||
const ext = PREVIEWABLE_IMAGES[mime];
|
||||
if (ext) {
|
||||
const previewPath = generated.preview[ext];
|
||||
if (
|
||||
await ensureGenerated(
|
||||
previewPath,
|
||||
() => imagePreview(srcPath, previewPath),
|
||||
) &&
|
||||
await serveFile(ctx, previewPath, {
|
||||
mime: ext === "png" ? "image/png" : "image/jpeg",
|
||||
cacheControl: generatedCache,
|
||||
})
|
||||
) return;
|
||||
}
|
||||
if (
|
||||
/^image\/(png|jpeg|gif|webp|avif|bmp)$/.test(mime) &&
|
||||
await serveFile(ctx, srcPath, { mime, cacheControl: generatedCache })
|
||||
) return;
|
||||
}
|
||||
// A scaled-down copy for the feed; the original when there's none to make
|
||||
// (no ffmpeg, an image it can't read).
|
||||
if (
|
||||
/^image\/(png|jpeg|gif|webp|avif|bmp)$/.test(mime) &&
|
||||
await serveImage(ctx, srcPath, mime, generatedCache, MAX_IMAGE_WIDTH)
|
||||
) return;
|
||||
|
||||
throw new APIException(404, "No thumbnail for this dump");
|
||||
});
|
||||
|
||||
@@ -19,9 +19,10 @@
|
||||
* - `GET /api/users/:key/upvoted?page=&limit=&category=` — optional sign-in.
|
||||
* Answers a page of the dumps they upvoted, private ones only to
|
||||
* themselves, optionally in one category.
|
||||
* - `GET /api/users/:key/playlists?page=&limit=&category=` — optional
|
||||
* - `GET /api/users/:key/playlists?page=&limit=&category=&sort=` — optional
|
||||
* sign-in. Answers a page of the playlists they made, the private ones only
|
||||
* to themselves, optionally in one playlist category.
|
||||
* to themselves, optionally in one playlist category, in the order `sort`
|
||||
* names, as `GET /api/playlists` does.
|
||||
* - `GET /api/users/:key/playlists/:slug` — optional sign-in. Answers their
|
||||
* playlist whose slug is or was `slug`, with its current slug and the dumps
|
||||
* the caller may see; a private one 404s but for its owner.
|
||||
@@ -56,7 +57,11 @@ import {
|
||||
type UserSuggestion,
|
||||
} from "../../shared/user.ts";
|
||||
import type { Dump } from "../../shared/dump.ts";
|
||||
import type { Playlist, PlaylistWithDumps } from "../../shared/playlist.ts";
|
||||
import {
|
||||
parsePlaylistSort,
|
||||
type Playlist,
|
||||
type PlaylistWithDumps,
|
||||
} from "../../shared/playlist.ts";
|
||||
import {
|
||||
fieldOf,
|
||||
isRegisterUserRequest,
|
||||
@@ -210,7 +215,14 @@ router.get("/:key/playlists", async (ctx) => {
|
||||
const categoryId = resolveCategoryFilter("playlist", params);
|
||||
const { items, hasMore } = categoryId === "missing"
|
||||
? { items: [], hasMore: false }
|
||||
: listPlaylistsByUser(user.id, requestingUserId, page, limit, categoryId);
|
||||
: listPlaylistsByUser(
|
||||
user.id,
|
||||
requestingUserId,
|
||||
page,
|
||||
limit,
|
||||
parsePlaylistSort(params.get("sort")),
|
||||
categoryId,
|
||||
);
|
||||
const responseBody: APIResponse<PaginatedData<Playlist>> = {
|
||||
success: true,
|
||||
data: { items, hasMore },
|
||||
@@ -224,7 +236,7 @@ router.get("/:key/playlists/:slug", async (ctx) => {
|
||||
const responseBody: APIResponse<PlaylistWithDumps> = {
|
||||
success: true,
|
||||
data: getPlaylist(
|
||||
getPlaylistByPath(user.id, ctx.params.slug),
|
||||
getPlaylistByPath(user.id, ctx.params.slug, requestingUserId),
|
||||
requestingUserId,
|
||||
),
|
||||
};
|
||||
@@ -245,10 +257,16 @@ router.get("/:key/follow/users", (ctx) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.get("/:key/follow/playlists", (ctx) => {
|
||||
router.get("/:key/follow/playlists", async (ctx) => {
|
||||
const user = resolveUserKey(ctx.params.key);
|
||||
const requestingUserId = await parseOptionalAuth(ctx);
|
||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||
const { items, hasMore } = getFollowedPlaylistsByUser(user.id, page, limit);
|
||||
const { items, hasMore } = getFollowedPlaylistsByUser(
|
||||
user.id,
|
||||
requestingUserId,
|
||||
page,
|
||||
limit,
|
||||
);
|
||||
const responseBody: APIResponse<PaginatedData<Playlist>> = {
|
||||
success: true,
|
||||
data: { items, hasMore },
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Records which dumps a dump's note and comments link to, and finds the dumps
|
||||
* related to one through those links. The dump and comment services keep the
|
||||
* links up to date, and the dump page lists the related dumps.
|
||||
* links up to date, and the dump page lists the related dumps, which it
|
||||
* fetches again when `dump_related_changed` says they changed.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
import { parsePagePath } from "../../shared/paths.ts";
|
||||
import { canonicalizeUrl } from "../lib/canonical-url.ts";
|
||||
import { parseRows } from "../model/rows.ts";
|
||||
import { broadcastDumpRelatedChanged } from "./ws-service.ts";
|
||||
|
||||
/**
|
||||
* Matches an http(s) URL in Markdown. It stops at the delimiters of
|
||||
@@ -46,6 +48,42 @@ function resolveDumpPathId(pathname: string): string | null {
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells clients that the links between `dumpId` and each of `otherIds`
|
||||
* changed, as `broadcastDumpRelatedChanged` says, after reading who may see
|
||||
* each of them. It sends nothing without `otherIds`.
|
||||
*/
|
||||
function announceRelatedChange(dumpId: string, otherIds: string[]): void {
|
||||
if (otherIds.length === 0) return;
|
||||
const ids = [dumpId, ...otherIds];
|
||||
const rows = db.prepare(
|
||||
`SELECT id, user_id, is_private FROM dumps
|
||||
WHERE id IN (${ids.map(() => "?").join(", ")});`,
|
||||
).all(...ids) as { id: string; user_id: string; is_private: number }[];
|
||||
const dumps = rows.map((row) => ({
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
isPrivate: Boolean(row.is_private),
|
||||
}));
|
||||
const dump = dumps.find((d) => d.id === dumpId);
|
||||
if (!dump) return;
|
||||
broadcastDumpRelatedChanged(dump, dumps.filter((d) => d.id !== dumpId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells clients that dump `dumpId`, which just turned public, is now related
|
||||
* to the dumps linked to or from it, for the dump service. Its poster saw
|
||||
* those links already; to everyone else they are new.
|
||||
*/
|
||||
export function announceLinksNowPublic(dumpId: string): void {
|
||||
const rows = db.prepare(
|
||||
`SELECT to_dump_id AS id FROM dump_backlinks WHERE from_dump_id = ?
|
||||
UNION
|
||||
SELECT from_dump_id FROM dump_backlinks WHERE to_dump_id = ?;`,
|
||||
).all(dumpId, dumpId) as { id: string }[];
|
||||
announceRelatedChange(dumpId, rows.map((row) => row.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the backlinks from one text, a dump's note (`dump_comment`) or one
|
||||
* of its comments (`comment`), with the links its current `text` holds. A link
|
||||
@@ -53,7 +91,8 @@ function resolveDumpPathId(pathname: string): string | null {
|
||||
* when it is the URL a dump was posted with, compared by `canonicalizeUrl` as
|
||||
* reposts are. The dump and
|
||||
* comment services call it on every save, and with an empty `text` when a
|
||||
* comment is deleted.
|
||||
* comment is deleted. Clients are told of the dumps it links or no longer
|
||||
* links, as `dump_related_changed`.
|
||||
*/
|
||||
export function relinkBacklinks(
|
||||
sourceType: "dump_comment" | "comment",
|
||||
@@ -90,7 +129,11 @@ export function relinkBacklinks(
|
||||
|
||||
targetIds.delete(fromDumpId);
|
||||
|
||||
transaction(() => {
|
||||
const formerIds = transaction(() => {
|
||||
const former = db.prepare(
|
||||
`SELECT to_dump_id FROM dump_backlinks
|
||||
WHERE source_type = ? AND source_id = ?;`,
|
||||
).all(sourceType, sourceId) as { to_dump_id: string }[];
|
||||
db.prepare(
|
||||
`DELETE FROM dump_backlinks WHERE source_type = ? AND source_id = ?;`,
|
||||
).run(sourceType, sourceId);
|
||||
@@ -102,7 +145,13 @@ export function relinkBacklinks(
|
||||
for (const toDumpId of targetIds) {
|
||||
insert.run(sourceType, sourceId, fromDumpId, toDumpId, createdAt);
|
||||
}
|
||||
return new Set(former.map((row) => row.to_dump_id));
|
||||
});
|
||||
|
||||
// Most saves change no link, and send nothing.
|
||||
announceRelatedChange(fromDumpId, [
|
||||
...targetIds.symmetricDifference(formerIds),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,18 +5,28 @@
|
||||
* the month ending at the last refresh, which {@link communityJob} redoes
|
||||
* 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
|
||||
*/
|
||||
|
||||
import { db } from "../db/connection.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 {
|
||||
DIGEST_CADENCES,
|
||||
type DigestCadence,
|
||||
PERIOD_MS,
|
||||
} from "../../shared/settings.ts";
|
||||
import type { Job } from "../lib/scheduler.ts";
|
||||
import { parseRows } from "../model/rows.ts";
|
||||
import { listTopDumps } from "./dump-service.ts";
|
||||
import { listNewPublicPlaylists } from "./playlist-service.ts";
|
||||
import { listNewUsers } from "./user-service.ts";
|
||||
@@ -91,3 +101,148 @@ export const communityJob: Job = {
|
||||
firstAfterMs: 0,
|
||||
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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from "../../shared/settings.ts";
|
||||
import type { CommunityStats } from "../../shared/community.ts";
|
||||
import { type Dump, dumpFileVersion } from "../../shared/dump.ts";
|
||||
import type { ImageWidth } from "../../shared/image.ts";
|
||||
import { siteHost } from "../../shared/url.ts";
|
||||
import { markdownToPlainText } from "../../shared/markdown.ts";
|
||||
import {
|
||||
@@ -101,11 +102,19 @@ function quoteOf(markdown: string | undefined | null): string | undefined {
|
||||
: flat;
|
||||
}
|
||||
|
||||
/**
|
||||
* The width the emails' thumbnails are asked at: the widest an email shows
|
||||
* one, on a dense screen.
|
||||
*/
|
||||
const EMAIL_IMAGE_WIDTH: ImageWidth = 640;
|
||||
|
||||
/**
|
||||
* Returns the image a mail client can load for `dump`, which must be public:
|
||||
* the thumbnails route for a custom thumbnail or an image or video upload,
|
||||
* else the link preview's image, through the image proxy when it is plain
|
||||
* http, which a mail client may refuse.
|
||||
* scaled to the email's width as a JPEG or PNG (`f=compat`), since not every
|
||||
* mail client reads the WebP a browser gets; else the link preview's image,
|
||||
* through the image proxy when it is plain http, which a mail client may
|
||||
* refuse.
|
||||
*/
|
||||
function thumbnailOf(dump: Dump): string | null {
|
||||
const isMedia = dump.kind === "file" &&
|
||||
@@ -113,7 +122,7 @@ function thumbnailOf(dump: Dump): string | null {
|
||||
if (dump.thumbnailMime || isMedia) {
|
||||
return `${PUBLIC_URL}/api/dumps/${dump.id}/thumbnail?v=${
|
||||
dumpFileVersion(dump)
|
||||
}`;
|
||||
}&w=${EMAIL_IMAGE_WIDTH}&f=compat`;
|
||||
}
|
||||
const thumbnailUrl = dump.richContent?.thumbnailUrl;
|
||||
if (typeof thumbnailUrl !== "string") return null;
|
||||
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
} from "./notification-service.ts";
|
||||
import { changeSlug, newSlug, resolveSlug } from "./slug-service.ts";
|
||||
import { canonicalizeUrl } from "../lib/canonical-url.ts";
|
||||
import { removeGeneratedMedia } from "../lib/ffmpeg.ts";
|
||||
import { removeGeneratedMedia, removeScaledImages } from "../lib/ffmpeg.ts";
|
||||
import {
|
||||
DUMP_ALLOWED_MIME_PREFIXES,
|
||||
DUMP_ALLOWED_MIME_TYPES,
|
||||
@@ -57,7 +57,7 @@ import {
|
||||
VALIDATION,
|
||||
} from "../../shared/validation.ts";
|
||||
import { linkAttachments } from "./attachment-service.ts";
|
||||
import { relinkBacklinks } from "./backlink-service.ts";
|
||||
import { announceLinksNowPublic, relinkBacklinks } from "./backlink-service.ts";
|
||||
import {
|
||||
assertCategoriesExist,
|
||||
attachCategoryIds,
|
||||
@@ -96,7 +96,8 @@ function assertFileDumpText(title?: string, comment?: string): void {
|
||||
* `broadcastDumpChange` for the dump itself, and, when it was deleted or
|
||||
* changed visibility, the dumps of each playlist holding it, which moved for
|
||||
* their viewers. A deletion passes the `holders` it read before the cascade
|
||||
* took the dump out of them.
|
||||
* took the dump out of them. A dump that turned public also joins the related
|
||||
* dumps of those it is linked with.
|
||||
*/
|
||||
function announceDumpChange(
|
||||
before: Dump | null,
|
||||
@@ -106,6 +107,7 @@ function announceDumpChange(
|
||||
broadcastDumpChange(before, after);
|
||||
if (!before || (after && after.isPrivate === before.isPrivate)) return;
|
||||
broadcastHoldersDumps(holders ?? playlistsHolding(before.id));
|
||||
if (after && !after.isPrivate) announceLinksNowPublic(after.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -852,6 +854,13 @@ export function setDumpThumbnail(dumpId: string, mime: string): Dump {
|
||||
return updatedDump;
|
||||
}
|
||||
|
||||
/** Deletes the file of dump `dumpId`'s custom thumbnail, and its copies. */
|
||||
async function removeCustomThumbnail(dumpId: string): Promise<void> {
|
||||
const path = customThumbnailPath(dumpId);
|
||||
await Deno.remove(path).catch(() => {});
|
||||
await removeScaledImages(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a dump's custom thumbnail and its file, for
|
||||
* `DELETE /api/dumps/:dumpId/thumbnail`.
|
||||
@@ -862,7 +871,7 @@ export async function removeDumpThumbnail(dumpId: string): Promise<Dump> {
|
||||
const dump = fetchDump(dumpId);
|
||||
db.prepare(`UPDATE dumps SET custom_thumbnail_mime = NULL WHERE id = ?;`)
|
||||
.run(dump.id);
|
||||
await Deno.remove(customThumbnailPath(dump.id)).catch(() => {});
|
||||
await removeCustomThumbnail(dump.id);
|
||||
const updatedDump: Dump = { ...dump, thumbnailMime: undefined };
|
||||
announceDumpChange(dump, updatedDump);
|
||||
return updatedDump;
|
||||
@@ -889,9 +898,7 @@ export async function deleteDump(dumpId: string): Promise<void> {
|
||||
await Deno.remove(`${DUMPS_DIR}/${dump.id}`).catch(() => {});
|
||||
await removeGeneratedMedia(dump.id);
|
||||
}
|
||||
if (dump.thumbnailMime) {
|
||||
await Deno.remove(customThumbnailPath(dump.id)).catch(() => {});
|
||||
}
|
||||
if (dump.thumbnailMime) await removeCustomThumbnail(dump.id);
|
||||
|
||||
announceDumpChange(dump, null, holders);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "../model/dump.ts";
|
||||
import { isFollowRow } from "../model/follow.ts";
|
||||
import { isPlaylistRow, playlistRowToApi } from "../model/playlist.ts";
|
||||
import { VISIBLE_DUMP_FIGURES } from "../lib/playlist-members.ts";
|
||||
import { type Page, pageOf } from "../lib/pagination.ts";
|
||||
import { attachCategoryIds, categoryJoin } from "./category-service.ts";
|
||||
import { sendToUser } from "./ws-service.ts";
|
||||
@@ -232,12 +233,14 @@ export function getFollowedPlaylistsDumpFeed(
|
||||
|
||||
/**
|
||||
* Returns a page of the public playlists `userId` follows, most recently
|
||||
* followed first, for their profile and playlists pages.
|
||||
* followed first, for their profile and playlists pages. Their dumps are
|
||||
* counted for `requestingUserId`, null being a signed-out viewer.
|
||||
*
|
||||
* @throws {APIException} 500 when a row doesn't parse as a playlist.
|
||||
*/
|
||||
export function getFollowedPlaylistsByUser(
|
||||
userId: string,
|
||||
requestingUserId: string | null,
|
||||
page: number,
|
||||
limit: number,
|
||||
): Page<Playlist> {
|
||||
@@ -245,7 +248,7 @@ export function getFollowedPlaylistsByUser(
|
||||
|
||||
const rawRows = db.prepare(
|
||||
`SELECT p.*, u.username as owner_username,
|
||||
(SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count
|
||||
${VISIBLE_DUMP_FIGURES}
|
||||
FROM playlists p
|
||||
LEFT JOIN users u ON u.id = p.user_id
|
||||
INNER JOIN follows f ON f.followed_playlist_id = p.id
|
||||
@@ -253,7 +256,7 @@ export function getFollowedPlaylistsByUser(
|
||||
AND p.is_public = 1
|
||||
ORDER BY f.created_at DESC
|
||||
LIMIT ? OFFSET ?;`,
|
||||
).all(userId, limit + 1, offset);
|
||||
).all(requestingUserId ?? "", userId, limit + 1, offset);
|
||||
|
||||
if (!rawRows.every(isPlaylistRow)) {
|
||||
throw new APIException(500, "Malformed playlist data");
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
CreatePlaylistRequest,
|
||||
Playlist,
|
||||
PlaylistMembership,
|
||||
PlaylistSort,
|
||||
PlaylistWithDumps,
|
||||
UpdatePlaylistRequest,
|
||||
} from "../../shared/playlist.ts";
|
||||
@@ -42,21 +43,41 @@ import {
|
||||
setCategories,
|
||||
} from "./category-service.ts";
|
||||
import {
|
||||
type DatedPlaylistMember,
|
||||
isMemberVisibleTo,
|
||||
type PlaylistMember,
|
||||
VISIBLE_DUMP_FIGURES,
|
||||
} from "../lib/playlist-members.ts";
|
||||
import { LIKE, likePattern } from "../lib/like.ts";
|
||||
import { parseRows } from "../model/rows.ts";
|
||||
|
||||
/**
|
||||
* The select list and `FROM` clause of a playlist query, adding the owner's
|
||||
* name and the dump count. Callers append the `WHERE`. The dump count includes
|
||||
* dumps the viewer can't see.
|
||||
* name, the count of the dumps the viewer may see and when the latest was
|
||||
* added. Callers append the `WHERE`, and pass the viewer's id first, as
|
||||
* {@link viewerParam} gives it.
|
||||
*/
|
||||
const PLAYLIST_SELECT = `p.*, u.username as owner_username,
|
||||
(SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count
|
||||
${VISIBLE_DUMP_FIGURES}
|
||||
FROM playlists p JOIN users u ON u.id = p.user_id`;
|
||||
|
||||
/**
|
||||
* The `ORDER BY` of each of the `PLAYLIST_SORTS`, on a {@link PLAYLIST_SELECT}:
|
||||
* `comparePlaylists` (`shared/playlist.ts`) in SQL, which a test holds to it.
|
||||
*/
|
||||
const PLAYLIST_ORDER: Record<PlaylistSort, string> = {
|
||||
updated: "COALESCE(last_dump_at, p.created_at) DESC, p.created_at DESC",
|
||||
new: "p.created_at DESC",
|
||||
dumps: "dump_count DESC, p.created_at DESC",
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns what {@link VISIBLE_DUMP_FIGURES}'s parameter takes for `viewerId`:
|
||||
* a signed-out viewer matches no user.
|
||||
*/
|
||||
function viewerParam(viewerId: string | null | undefined): string {
|
||||
return viewerId ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the rows of a {@link PLAYLIST_SELECT} to playlists, their
|
||||
* categories filled in.
|
||||
@@ -69,13 +90,18 @@ function playlistsOf(rows: unknown[]): Playlist[] {
|
||||
|
||||
/**
|
||||
* Returns playlist `playlistId`, private or not. A caller that shows it to
|
||||
* someone must check `isPublic` first, as the OG middleware does.
|
||||
* someone must check `isPublic` first, as the OG middleware does. Its
|
||||
* `dumpCount` and `lastDumpAt` are `viewerId`'s, or a signed-out viewer's
|
||||
* without one.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such playlist.
|
||||
*/
|
||||
export function getPlaylistById(playlistId: string): Playlist {
|
||||
export function getPlaylistById(
|
||||
playlistId: string,
|
||||
viewerId: string | null = null,
|
||||
): Playlist {
|
||||
const row = db.prepare(`SELECT ${PLAYLIST_SELECT} WHERE p.id = ?;`)
|
||||
.get(playlistId);
|
||||
.get(viewerParam(viewerId), playlistId);
|
||||
if (!row || !isPlaylistRow(row)) {
|
||||
throw new APIException(404, "Playlist not found");
|
||||
}
|
||||
@@ -86,13 +112,18 @@ export function getPlaylistById(playlistId: string): Playlist {
|
||||
|
||||
/**
|
||||
* Returns the playlist of `userId`'s whose slug is or was `slug`, private or
|
||||
* not, or throws a 404, as {@link getPlaylistById} does. Reached by a former
|
||||
* slug, it comes with its current one.
|
||||
* not, or throws a 404, as {@link getPlaylistById} does, counting its dumps
|
||||
* for `viewerId` likewise. Reached by a former slug, it comes with its current
|
||||
* one.
|
||||
*/
|
||||
export function getPlaylistByPath(userId: string, slug: string): Playlist {
|
||||
export function getPlaylistByPath(
|
||||
userId: string,
|
||||
slug: string,
|
||||
viewerId: string | null = null,
|
||||
): Playlist {
|
||||
const playlistId = resolveSlug("playlist", userId, slug);
|
||||
if (!playlistId) throw new APIException(404, "Playlist not found");
|
||||
return getPlaylistById(playlistId);
|
||||
return getPlaylistById(playlistId, viewerId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,7 +167,7 @@ export function createPlaylist(
|
||||
}
|
||||
linkAttachments(req.description, "playlist", id);
|
||||
}
|
||||
broadcastPlaylistChange(null, playlist);
|
||||
broadcastPlaylistChange(null, playlist, []);
|
||||
return playlist;
|
||||
}
|
||||
|
||||
@@ -144,7 +175,8 @@ export function createPlaylist(
|
||||
* Returns a playlist with the dumps `requestingUserId` may see in it, in
|
||||
* order, for `GET /api/playlists/:playlistId`, and for
|
||||
* `GET /api/users/:key/playlists/:slug` once it has found the playlist by its
|
||||
* path. A null `requestingUserId` is a signed-out viewer.
|
||||
* path, for the same viewer. A null `requestingUserId` is a signed-out viewer.
|
||||
* Its `dumpCount` is the number of those dumps.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such playlist, or it is private
|
||||
* and not theirs.
|
||||
@@ -154,7 +186,7 @@ export function getPlaylist(
|
||||
requestingUserId: string | null,
|
||||
): PlaylistWithDumps {
|
||||
const playlist = typeof playlistId === "string"
|
||||
? getPlaylistById(playlistId)
|
||||
? getPlaylistById(playlistId, requestingUserId)
|
||||
: playlistId;
|
||||
|
||||
if (!playlist.isPublic && requestingUserId !== playlist.userId) {
|
||||
@@ -175,7 +207,7 @@ export function getPlaylist(
|
||||
isMemberVisibleTo(d, playlist, requestingUserId)
|
||||
);
|
||||
|
||||
return { ...playlist, dumps: visibleDumps };
|
||||
return { ...playlist, dumpCount: visibleDumps.length, dumps: visibleDumps };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -198,22 +230,33 @@ export function searchPlaylists(
|
||||
const rows = requestingUserId
|
||||
? db.prepare(
|
||||
`SELECT ${PLAYLIST_SELECT} ${catJoin} WHERE (p.is_public = 1 OR p.user_id = ?) AND ${searchClause} ORDER BY p.created_at DESC LIMIT ?;`,
|
||||
).all(...catParams, requestingUserId, pattern, pattern, limit)
|
||||
).all(
|
||||
requestingUserId,
|
||||
...catParams,
|
||||
requestingUserId,
|
||||
pattern,
|
||||
pattern,
|
||||
limit,
|
||||
)
|
||||
: db.prepare(
|
||||
`SELECT ${PLAYLIST_SELECT} ${catJoin} WHERE p.is_public = 1 AND ${searchClause} ORDER BY p.created_at DESC LIMIT ?;`,
|
||||
).all(...catParams, pattern, pattern, limit);
|
||||
).all(viewerParam(null), ...catParams, pattern, pattern, limit);
|
||||
|
||||
return playlistsOf(rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a page of every public playlist, newest first, for
|
||||
* Returns a page of every public playlist, in the order `sort` names, for
|
||||
* `GET /api/playlists`, optionally only those in the playlist category
|
||||
* `categoryId`. The client's `/playlists` page lists them.
|
||||
* `categoryId`. The client's `/playlists` page lists them. Their dumps are
|
||||
* counted, and so ordered, for `requestingUserId`, null being a signed-out
|
||||
* viewer.
|
||||
*/
|
||||
export function listPublicPlaylists(
|
||||
requestingUserId: string | null,
|
||||
page: number,
|
||||
limit: number,
|
||||
sort: PlaylistSort,
|
||||
categoryId?: string,
|
||||
): Page<Playlist> {
|
||||
const offset = (page - 1) * limit;
|
||||
@@ -221,27 +264,28 @@ export function listPublicPlaylists(
|
||||
const rows = db.prepare(
|
||||
`SELECT ${PLAYLIST_SELECT} ${catJoin}
|
||||
WHERE p.is_public = 1
|
||||
ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`,
|
||||
).all(...catParams, limit + 1, offset);
|
||||
ORDER BY ${PLAYLIST_ORDER[sort]} LIMIT ? OFFSET ?;`,
|
||||
).all(viewerParam(requestingUserId), ...catParams, limit + 1, offset);
|
||||
return pageOf(playlistsOf(rows), limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the public playlists created from `from` to `to`, the fullest first
|
||||
* and the newer on a tie. The community service gathers them for the
|
||||
* `/community` page and the digests.
|
||||
* `/community` page and the digests, which show every reader the same
|
||||
* figures: only their public dumps are counted.
|
||||
*/
|
||||
export function listNewPublicPlaylists(from: Date, to: Date): Playlist[] {
|
||||
const rows = db.prepare(
|
||||
`SELECT ${PLAYLIST_SELECT}
|
||||
WHERE p.is_public = 1 AND p.created_at >= ? AND p.created_at < ?
|
||||
ORDER BY dump_count DESC, p.created_at DESC;`,
|
||||
).all(from.toISOString(), to.toISOString());
|
||||
ORDER BY ${PLAYLIST_ORDER.dumps};`,
|
||||
).all(viewerParam(null), from.toISOString(), to.toISOString());
|
||||
return playlistsOf(rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a page of `userId`'s playlists, newest first, for
|
||||
* Returns a page of `userId`'s playlists, in the order `sort` names, for
|
||||
* `GET /api/users/:key/playlists`, optionally only those in the playlist
|
||||
* category `categoryId`. The private ones are listed only when
|
||||
* `requestingUserId` is `userId`.
|
||||
@@ -251,6 +295,7 @@ export function listPlaylistsByUser(
|
||||
requestingUserId: string | null,
|
||||
page: number,
|
||||
limit: number,
|
||||
sort: PlaylistSort,
|
||||
categoryId?: string,
|
||||
): Page<Playlist> {
|
||||
const isOwner = requestingUserId === userId;
|
||||
@@ -261,8 +306,14 @@ export function listPlaylistsByUser(
|
||||
const rows = db.prepare(
|
||||
`SELECT ${PLAYLIST_SELECT} ${catJoin}
|
||||
WHERE p.user_id = ?${privacyFilter}
|
||||
ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`,
|
||||
).all(...catParams, userId, limit + 1, offset);
|
||||
ORDER BY ${PLAYLIST_ORDER[sort]} LIMIT ? OFFSET ?;`,
|
||||
).all(
|
||||
viewerParam(requestingUserId),
|
||||
...catParams,
|
||||
userId,
|
||||
limit + 1,
|
||||
offset,
|
||||
);
|
||||
return pageOf(playlistsOf(rows), limit);
|
||||
}
|
||||
|
||||
@@ -287,7 +338,7 @@ export function updatePlaylist(
|
||||
requestingUserId: string,
|
||||
canModerate: boolean,
|
||||
): Playlist {
|
||||
const playlist = getPlaylistById(playlistId);
|
||||
const playlist = getPlaylistById(playlistId, requestingUserId);
|
||||
|
||||
if (playlist.userId !== requestingUserId && !canModerate) {
|
||||
throw new APIException(403, "Only its owner or a moderator may do that");
|
||||
@@ -347,7 +398,7 @@ export function updatePlaylist(
|
||||
}
|
||||
linkAttachments(newDescription, "playlist", playlist.id);
|
||||
}
|
||||
broadcastPlaylistChange(playlist, updated);
|
||||
broadcastPlaylistChange(playlist, updated, getCurrentMembers(playlist.id));
|
||||
// Its followers weren't told of the dumps added while it was private.
|
||||
if (!playlist.isPublic && newIsPublic) {
|
||||
notifyPlaylistFollowersNowPublic({ playlistId: playlist.id });
|
||||
@@ -374,7 +425,7 @@ export function deletePlaylist(
|
||||
}
|
||||
|
||||
db.prepare(`DELETE FROM playlists WHERE id = ?;`).run(playlist.id);
|
||||
broadcastPlaylistChange(playlist, null);
|
||||
broadcastPlaylistChange(playlist, null, []);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -390,7 +441,7 @@ export function getEditablePlaylist(
|
||||
requestingUserId: string,
|
||||
canModerate: boolean,
|
||||
): Playlist {
|
||||
const playlist = getPlaylistById(playlistId);
|
||||
const playlist = getPlaylistById(playlistId, requestingUserId);
|
||||
if (playlist.userId !== requestingUserId && !canModerate) {
|
||||
throw new APIException(403, "Only its owner or a moderator may do that");
|
||||
}
|
||||
@@ -421,8 +472,8 @@ export function setPlaylistImage(
|
||||
db.prepare(
|
||||
`UPDATE playlists SET image_mime = ?, updated_at = ? WHERE id = ?;`,
|
||||
).run(imageMime, new Date().toISOString(), playlist.id);
|
||||
const updated = getPlaylistById(playlist.id);
|
||||
broadcastPlaylistChange(playlist, updated);
|
||||
const updated = getPlaylistById(playlist.id, requestingUserId);
|
||||
broadcastPlaylistChange(playlist, updated, getCurrentMembers(playlist.id));
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -590,13 +641,13 @@ export function getPlaylistMembershipsForDump(
|
||||
): PlaylistMembership[] {
|
||||
const rows = db.prepare(
|
||||
`SELECT p.*, u.username as owner_username,
|
||||
(SELECT COUNT(*) FROM playlist_dumps x WHERE x.playlist_id = p.id) as dump_count,
|
||||
${VISIBLE_DUMP_FIGURES},
|
||||
EXISTS (SELECT 1 FROM playlist_dumps pd
|
||||
WHERE pd.playlist_id = p.id AND pd.dump_id = ?) as has_dump
|
||||
FROM playlists p JOIN users u ON u.id = p.user_id
|
||||
WHERE p.user_id = ?
|
||||
ORDER BY p.created_at DESC;`,
|
||||
).all(dumpId, userId);
|
||||
).all(userId, dumpId, userId);
|
||||
|
||||
const memberships = rows.map((row) => {
|
||||
if (!isPlaylistRow(row)) {
|
||||
@@ -626,7 +677,7 @@ export function getPlaylistsForDump(
|
||||
INNER JOIN playlist_dumps pd ON pd.playlist_id = p.id
|
||||
WHERE pd.dump_id = ? AND (p.is_public = 1 OR p.user_id = ?)
|
||||
ORDER BY p.created_at DESC LIMIT 20;`,
|
||||
).all(dumpId, requestingUserId ?? "");
|
||||
).all(viewerParam(requestingUserId), dumpId, requestingUserId ?? "");
|
||||
|
||||
return playlistsOf(rows);
|
||||
}
|
||||
@@ -660,18 +711,19 @@ export function getPlaylistImageInfo(
|
||||
* Returns a playlist's dumps in order, all of them, for the callers to filter
|
||||
* per viewer.
|
||||
*/
|
||||
function getCurrentMembers(playlistId: string): PlaylistMember[] {
|
||||
function getCurrentMembers(playlistId: string): DatedPlaylistMember[] {
|
||||
const rows = db.prepare(
|
||||
`SELECT pd.dump_id, d.is_private, d.user_id
|
||||
`SELECT pd.dump_id, pd.added_at, d.is_private, d.user_id
|
||||
FROM playlist_dumps pd INNER JOIN dumps d ON d.id = pd.dump_id
|
||||
WHERE pd.playlist_id = ? ORDER BY pd.position ASC;`,
|
||||
).all(playlistId) as Array<
|
||||
{ dump_id: string; is_private: number; user_id: string }
|
||||
{ dump_id: string; added_at: string; is_private: number; user_id: string }
|
||||
>;
|
||||
return rows.map((r) => ({
|
||||
id: r.dump_id,
|
||||
isPrivate: Boolean(r.is_private),
|
||||
userId: r.user_id,
|
||||
addedAt: new Date(r.added_at),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -701,7 +753,7 @@ export function playlistsHolding(dumpId: string): PlaylistHolder[] {
|
||||
|
||||
/**
|
||||
* Sends each of `holders` its dumps again, after one of them was deleted or
|
||||
* changed visibility: the count moved, or what each viewer may see of them.
|
||||
* changed visibility, which changes what each viewer may see of them.
|
||||
*/
|
||||
export function broadcastHoldersDumps(holders: PlaylistHolder[]): void {
|
||||
for (const holder of holders) {
|
||||
|
||||
@@ -18,8 +18,9 @@ import type { OnlineUser, ServerToClientMessage } from "../../shared/ws.ts";
|
||||
import type { Playlist } from "../../shared/playlist.ts";
|
||||
import type { PublicUser, Role } from "../../shared/user.ts";
|
||||
import {
|
||||
type DatedPlaylistMember,
|
||||
dumpFiguresFor,
|
||||
isMemberVisibleTo,
|
||||
type PlaylistMember,
|
||||
} from "../lib/playlist-members.ts";
|
||||
import type { Job } from "../lib/scheduler.ts";
|
||||
|
||||
@@ -179,35 +180,43 @@ export function broadcastPresence(): void {
|
||||
* `before` is null for a new item, `after` for a deleted one. A private item's
|
||||
* changes reach its owner's sockets alone. When it goes private, everyone else
|
||||
* is told it was deleted, and gets nothing of the change; when it goes public,
|
||||
* they are told it was created, since to them it is new.
|
||||
* they are told it was created, since to them it is new. The owner's sockets
|
||||
* and the others' each get a message of their own, built with `toOwner` set
|
||||
* for the former, for what the two don't see alike.
|
||||
*/
|
||||
function broadcastChange<T extends { userId: string }>(
|
||||
before: T | null,
|
||||
after: T | null,
|
||||
isPrivate: (item: T) => boolean,
|
||||
messages: {
|
||||
created: (item: T) => ServerToClientMessage;
|
||||
updated: (item: T) => ServerToClientMessage;
|
||||
created: (item: T, toOwner: boolean) => ServerToClientMessage;
|
||||
updated: (item: T, toOwner: boolean) => ServerToClientMessage;
|
||||
deleted: (item: T) => ServerToClientMessage;
|
||||
},
|
||||
): void {
|
||||
const item = after ?? before;
|
||||
if (!item) return;
|
||||
const toOwner = (client: WsClient) => client.userId === item.userId;
|
||||
const toOthers = (client: WsClient) => client.userId !== item.userId;
|
||||
const wasPublic = !!before && !isPrivate(before);
|
||||
const isPublic = !!after && !isPrivate(after);
|
||||
|
||||
if (!after) {
|
||||
broadcast(messages.deleted(item), isPrivate(item) ? toOwner : undefined);
|
||||
} else if (!before) {
|
||||
broadcast(messages.created(after), isPrivate(after) ? toOwner : undefined);
|
||||
} else if (isPrivate(after)) {
|
||||
broadcast(messages.updated(after), toOwner);
|
||||
if (!isPrivate(before)) broadcast(messages.deleted(after), toOthers);
|
||||
} else if (isPrivate(before)) {
|
||||
broadcast(messages.updated(after), toOwner);
|
||||
broadcast(messages.created(after), toOthers);
|
||||
} else {
|
||||
broadcast(messages.updated(after));
|
||||
broadcast(
|
||||
!after
|
||||
? messages.deleted(item)
|
||||
: before
|
||||
? messages.updated(after, true)
|
||||
: messages.created(after, true),
|
||||
(client) => client.userId === item.userId,
|
||||
);
|
||||
const toOthers = (client: WsClient) => client.userId !== item.userId;
|
||||
if (after && isPublic) {
|
||||
broadcast(
|
||||
wasPublic
|
||||
? messages.updated(after, false)
|
||||
: messages.created(after, false),
|
||||
toOthers,
|
||||
);
|
||||
} else if (wasPublic) {
|
||||
broadcast(messages.deleted(item), toOthers);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,6 +235,29 @@ export function broadcastDumpChange(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends `dump_related_changed` after the links between `dump` and each of
|
||||
* `others` changed, for the backlink service. A link shows only to those who
|
||||
* may see both its ends, so a socket is told only when its user may see
|
||||
* `dump` and one of `others`, and of those alone.
|
||||
*/
|
||||
export function broadcastDumpRelatedChanged(
|
||||
dump: Pick<Dump, "id" | "isPrivate" | "userId">,
|
||||
others: Pick<Dump, "id" | "isPrivate" | "userId">[],
|
||||
): void {
|
||||
for (const client of clients) {
|
||||
const sees = (d: Pick<Dump, "isPrivate" | "userId">) =>
|
||||
!d.isPrivate || d.userId === client.userId;
|
||||
if (!sees(dump)) continue;
|
||||
const seen = others.filter(sees);
|
||||
if (seen.length === 0) continue;
|
||||
send(client.socket, {
|
||||
type: "dump_related_changed",
|
||||
dumpIds: [dump.id, ...seen.map((d) => d.id)],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends `votes_update` after the route stored a vote, to everyone, or with
|
||||
* `onlyUserId` to that user's sockets alone (for a private dump).
|
||||
@@ -265,15 +297,28 @@ export function broadcastCommentLikeUpdate(
|
||||
* Tells the clients that may see a playlist how it changed, as
|
||||
* {@link broadcastChange} says, for the playlist service after every write to
|
||||
* its details. A playlist turned private thus leaves the others' views without
|
||||
* their learning what it became.
|
||||
* their learning what it became. `members` are the dumps of `after`: each
|
||||
* socket gets the playlist with the count of those its user may see and the
|
||||
* date of the latest, the owner's private dumps being theirs alone to count.
|
||||
*/
|
||||
export function broadcastPlaylistChange(
|
||||
before: Playlist | null,
|
||||
after: Playlist | null,
|
||||
members: DatedPlaylistMember[],
|
||||
): void {
|
||||
const counted = (playlist: Playlist, toOwner: boolean): Playlist => ({
|
||||
...playlist,
|
||||
...dumpFiguresFor(members, playlist, toOwner ? playlist.userId : null),
|
||||
});
|
||||
broadcastChange(before, after, (playlist) => !playlist.isPublic, {
|
||||
created: (playlist) => ({ type: "playlist_created", playlist }),
|
||||
updated: (playlist) => ({ type: "playlist_updated", playlist }),
|
||||
created: (playlist, toOwner) => ({
|
||||
type: "playlist_created",
|
||||
playlist: counted(playlist, toOwner),
|
||||
}),
|
||||
updated: (playlist, toOwner) => ({
|
||||
type: "playlist_updated",
|
||||
playlist: counted(playlist, toOwner),
|
||||
}),
|
||||
deleted: (p) => ({
|
||||
type: "playlist_deleted",
|
||||
playlistId: p.id,
|
||||
@@ -285,11 +330,12 @@ export function broadcastPlaylistChange(
|
||||
/**
|
||||
* Sends `playlist_dumps_updated` to the playlist's audience, each socket with
|
||||
* only the dumps its user may see, as `GET` would list them. The private dumps
|
||||
* in a public playlist stay hidden from everyone but their posters.
|
||||
* in a public playlist stay hidden from everyone but their posters, and so
|
||||
* does their number: a client counts the dumps it is sent.
|
||||
*/
|
||||
export function broadcastPlaylistDumpsUpdated(
|
||||
playlist: Pick<Playlist, "id" | "isPublic" | "userId">,
|
||||
members: PlaylistMember[],
|
||||
members: DatedPlaylistMember[],
|
||||
): void {
|
||||
for (const client of clients) {
|
||||
if (!playlist.isPublic && client.userId !== playlist.userId) continue;
|
||||
@@ -299,7 +345,7 @@ export function broadcastPlaylistDumpsUpdated(
|
||||
dumpIds: members
|
||||
.filter((m) => isMemberVisibleTo(m, playlist, client.userId))
|
||||
.map((m) => m.id),
|
||||
dumpCount: members.length,
|
||||
lastDumpAt: dumpFiguresFor(members, playlist, client.userId).lastDumpAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,6 +388,60 @@ export const PNG: Uint8Array<ArrayBuffer> = Uint8Array.from(
|
||||
(c) => c.charCodeAt(0),
|
||||
);
|
||||
|
||||
/** Whether ffmpeg runs here; the tests of what it makes are skipped without. */
|
||||
export async function hasFfmpeg(): Promise<boolean> {
|
||||
try {
|
||||
return (await new Deno.Command("ffmpeg", {
|
||||
args: ["-version"],
|
||||
stdout: "null",
|
||||
stderr: "null",
|
||||
}).output()).success;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a real image with ffmpeg, a `width`×`height` field of `color`, as a
|
||||
* PNG, or as a GIF of three frames.
|
||||
*/
|
||||
export async function makeImage(
|
||||
width: number,
|
||||
height: number,
|
||||
color = "red",
|
||||
format: "png" | "gif" = "png",
|
||||
): Promise<Uint8Array<ArrayBuffer>> {
|
||||
const { stdout } = await new Deno.Command("ffmpeg", {
|
||||
args: [
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
`color=c=${color}:size=${width}x${height}:rate=3`,
|
||||
"-frames:v",
|
||||
format === "gif" ? "3" : "1",
|
||||
"-f",
|
||||
format === "gif" ? "gif" : "image2pipe",
|
||||
...(format === "gif" ? [] : ["-c:v", "png"]),
|
||||
"-",
|
||||
],
|
||||
stdout: "piped",
|
||||
stderr: "null",
|
||||
}).output();
|
||||
return new Uint8Array(stdout);
|
||||
}
|
||||
|
||||
/** Returns the width, in pixels, a WebP file's header states. */
|
||||
export function webpWidth(bytes: Uint8Array): number {
|
||||
const kind = new TextDecoder().decode(bytes.subarray(12, 16));
|
||||
// Lossy: the frame's start code, then 14 bits of width.
|
||||
if (kind === "VP8 ") return (bytes[26] | (bytes[27] << 8)) & 0x3FFF;
|
||||
// Lossless: a signature byte, then 14 bits of width minus one.
|
||||
if (kind === "VP8L") return ((bytes[21] | (bytes[22] << 8)) & 0x3FFF) + 1;
|
||||
// Extended, as an animation or an image with transparency: the canvas's
|
||||
// width minus one, on 24 bits.
|
||||
return (bytes[24] | (bytes[25] << 8) | (bytes[26] << 16)) + 1;
|
||||
}
|
||||
|
||||
export function formWithFile(
|
||||
bytes: Uint8Array<ArrayBuffer>,
|
||||
name = "file.png",
|
||||
|
||||
62
api/test/lib/ffmpeg.test.ts
Normal file
62
api/test/lib/ffmpeg.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { TEST_DIR } from "../setup.ts";
|
||||
import { assertEquals } from "@std/assert";
|
||||
import { sweepGeneratedMedia } from "../../lib/ffmpeg.ts";
|
||||
|
||||
const UPLOADS = `${TEST_DIR}/uploads`;
|
||||
|
||||
async function write(path: string, ageMs = 0): Promise<void> {
|
||||
const full = `${UPLOADS}/${path}`;
|
||||
await Deno.mkdir(full.slice(0, full.lastIndexOf("/")), { recursive: true });
|
||||
await Deno.writeTextFile(full, "x");
|
||||
if (ageMs > 0) {
|
||||
const at = new Date(Date.now() - ageMs);
|
||||
await Deno.utime(full, at, at);
|
||||
}
|
||||
}
|
||||
|
||||
const exists = (path: string) =>
|
||||
Deno.stat(`${UPLOADS}/${path}`).then(() => true, () => false);
|
||||
|
||||
Deno.test("sweepGeneratedMedia: takes the copies of gone uploads, the old previews and abandoned temporary files", async () => {
|
||||
const HOURS = 60 * 60_000;
|
||||
const kept = [
|
||||
// An upload and its copies, in each format.
|
||||
"avatars/u1",
|
||||
"scaled/avatars/u1.w128.webp",
|
||||
"scaled/avatars/u1.w640.png",
|
||||
// A custom thumbnail is an upload; a still and a waveform go with their
|
||||
// dump, not here.
|
||||
"thumbnails/d1-custom",
|
||||
"scaled/thumbnails/d1-custom.w960.webp",
|
||||
"thumbnails/d1.jpg",
|
||||
"thumbnails/d1.waveform.json",
|
||||
// Being written right now.
|
||||
"scaled/avatars/u1.w320.webp.1234.tmp",
|
||||
"scaled/tmp/fresh",
|
||||
"thumbnails/d1.jpg.1234.tmp",
|
||||
];
|
||||
const swept = [
|
||||
// Their upload is gone.
|
||||
"scaled/avatars/u2.w128.webp",
|
||||
"scaled/dumps/d2.w960.jpg",
|
||||
// The name the previews of image dumps had.
|
||||
"thumbnails/d1.preview.jpg",
|
||||
"thumbnails/d3.preview.png",
|
||||
];
|
||||
const abandoned = [
|
||||
"scaled/avatars/u1.w960.webp.5678.tmp",
|
||||
"scaled/tmp/stale",
|
||||
"scaled/tmp/stale.webp",
|
||||
"thumbnails/d1.waveform.json.5678.tmp",
|
||||
];
|
||||
for (const path of [...kept, ...swept]) await write(path);
|
||||
for (const path of abandoned) await write(path, 2 * HOURS);
|
||||
|
||||
assertEquals(await sweepGeneratedMedia(), swept.length + abandoned.length);
|
||||
for (const path of kept) assertEquals(await exists(path), true, path);
|
||||
for (const path of [...swept, ...abandoned]) {
|
||||
assertEquals(await exists(path), false, path);
|
||||
}
|
||||
// Nothing left to take.
|
||||
assertEquals(await sweepGeneratedMedia(), 0);
|
||||
});
|
||||
@@ -1,7 +1,11 @@
|
||||
import "../setup.ts";
|
||||
import { PNG } from "../helpers.ts";
|
||||
import { assertEquals, assertThrows } from "@std/assert";
|
||||
import { detectImageMime, validateImageUpload } from "../../lib/upload.ts";
|
||||
import {
|
||||
detectImageMime,
|
||||
scalableImage,
|
||||
validateImageUpload,
|
||||
} from "../../lib/upload.ts";
|
||||
import { APIException } from "../../lib/api-exception.ts";
|
||||
|
||||
Deno.test("detectImageMime: sniffs magic bytes, not names", () => {
|
||||
@@ -40,3 +44,77 @@ Deno.test("validateImageUpload: rejects non-images and anything over 5 MB", () =
|
||||
big.set(PNG);
|
||||
assertThrows(() => validateImageUpload(big), APIException, "too large");
|
||||
});
|
||||
|
||||
/** A JPEG's first bytes: SOI, then an EXIF segment stating `orientation`. */
|
||||
function jpegWithOrientation(orientation: number, little: boolean): Uint8Array {
|
||||
const u16 = (n: number) => little ? [n & 0xff, n >> 8] : [n >> 8, n & 0xff];
|
||||
const u32 = (n: number) => little ? [...u16(n), 0, 0] : [0, 0, ...u16(n)];
|
||||
const tiff = [
|
||||
...(little ? [0x49, 0x49] : [0x4d, 0x4d]),
|
||||
...u16(42),
|
||||
...u32(8),
|
||||
...u16(1),
|
||||
...u16(0x0112),
|
||||
...u16(3),
|
||||
...u32(1),
|
||||
...u16(orientation),
|
||||
...[0, 0],
|
||||
...u32(0),
|
||||
];
|
||||
const exif = [0x45, 0x78, 0x69, 0x66, 0, 0, ...tiff];
|
||||
const length = exif.length + 2;
|
||||
return new Uint8Array([
|
||||
...[0xff, 0xd8],
|
||||
// A JFIF segment first, as a camera's file may have.
|
||||
...[0xff, 0xe0, 0x00, 0x04, 0x00, 0x00],
|
||||
...[0xff, 0xe1, length >> 8, length & 0xff],
|
||||
...exif,
|
||||
...[0xff, 0xda],
|
||||
]);
|
||||
}
|
||||
|
||||
Deno.test("scalableImage: a JPEG's EXIF orientation, upright without one", () => {
|
||||
const turn = (data: Uint8Array) => scalableImage(data)?.orientation;
|
||||
assertEquals(turn(jpegWithOrientation(6, false)), 6);
|
||||
assertEquals(turn(jpegWithOrientation(8, true)), 8);
|
||||
assertEquals(turn(jpegWithOrientation(42, true)), 1);
|
||||
assertEquals(turn(new Uint8Array([0xff, 0xd8, 0xff, 0xda, 0, 2])), 1);
|
||||
// Cut short of what it announces: upright, not an error.
|
||||
assertEquals(turn(jpegWithOrientation(6, false).subarray(0, 20)), 1);
|
||||
assertEquals(turn(PNG), 1);
|
||||
});
|
||||
|
||||
Deno.test("scalableImage: an animated WebP and what isn't an image are served as they are", () => {
|
||||
const webp = (flags: number) =>
|
||||
new Uint8Array([
|
||||
...new TextEncoder().encode("RIFF\0\0\0\0WEBPVP8X"),
|
||||
...[10, 0, 0, 0],
|
||||
flags,
|
||||
]);
|
||||
assertEquals(scalableImage(webp(0x02)), null);
|
||||
// A reader without WebP gets a PNG of what may be transparent, a JPEG of
|
||||
// the rest.
|
||||
const still = { orientation: 1, animated: false };
|
||||
assertEquals(scalableImage(webp(0x10)), { ...still, compat: "png" });
|
||||
assertEquals(
|
||||
scalableImage(new TextEncoder().encode("RIFF\0\0\0\0WEBPVP8 ")),
|
||||
{ ...still, compat: "png" },
|
||||
);
|
||||
assertEquals(scalableImage(PNG), { ...still, compat: "png" });
|
||||
assertEquals(scalableImage(jpegWithOrientation(1, true)), {
|
||||
...still,
|
||||
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 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", () => {
|
||||
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"), [
|
||||
"#a06d00",
|
||||
"#bc5c00",
|
||||
"#c77b8b",
|
||||
"#be79bb",
|
||||
"#6e9b72",
|
||||
"#ea6a45",
|
||||
"#e2568f",
|
||||
"#8262d8",
|
||||
"#3a8fd4",
|
||||
"#27a583",
|
||||
]);
|
||||
// 18% of the muted text over the card, as a badge shows on the site.
|
||||
const smooth = readTokens("smooth", "light");
|
||||
|
||||
90
api/test/middleware/compress.test.ts
Normal file
90
api/test/middleware/compress.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { BASE_URL } from "../helpers.ts";
|
||||
import { assertEquals } from "@std/assert";
|
||||
import { Application } from "@oak/oak";
|
||||
import { brotliDecompressSync, gunzipSync } from "node:zlib";
|
||||
import { compressMiddleware } from "../../middleware/compress.ts";
|
||||
|
||||
const BIG = { success: true, data: { items: Array(200).fill("dump title") } };
|
||||
|
||||
function makeApp(): Application {
|
||||
const app = new Application();
|
||||
app.use(compressMiddleware);
|
||||
app.use((ctx) => {
|
||||
const path = ctx.request.url.pathname;
|
||||
if (path === "/big") ctx.response.body = BIG;
|
||||
if (path === "/small") ctx.response.body = { success: true };
|
||||
if (path === "/html") {
|
||||
ctx.response.type = "text/html";
|
||||
ctx.response.body = `<!doctype html>${"<p>gerbeur</p>".repeat(200)}`;
|
||||
}
|
||||
if (path === "/bytes") ctx.response.body = new Uint8Array(4096);
|
||||
if (path === "/encoded") {
|
||||
ctx.response.headers.set("Content-Encoding", "br");
|
||||
ctx.response.type = "text/css";
|
||||
ctx.response.body = "x".repeat(4096);
|
||||
}
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
async function get(path: string, acceptEncoding?: string) {
|
||||
const res = await makeApp().handle(
|
||||
new Request(`${BASE_URL}${path}`, {
|
||||
headers: acceptEncoding ? { "Accept-Encoding": acceptEncoding } : {},
|
||||
}),
|
||||
);
|
||||
if (!res) throw new Error("no response");
|
||||
return { res, bytes: new Uint8Array(await res.arrayBuffer()) };
|
||||
}
|
||||
|
||||
const text = (bytes: Uint8Array) => new TextDecoder().decode(bytes);
|
||||
|
||||
Deno.test("compressMiddleware: JSON goes out in brotli, or gzip for a client without it", async () => {
|
||||
const br = await get("/big", "gzip, deflate, br");
|
||||
assertEquals(br.res.headers.get("Content-Encoding"), "br");
|
||||
assertEquals(
|
||||
br.res.headers.get("Content-Type"),
|
||||
"application/json; charset=UTF-8",
|
||||
);
|
||||
assertEquals(br.res.headers.get("Vary"), "Accept-Encoding");
|
||||
assertEquals(JSON.parse(text(brotliDecompressSync(br.bytes))), BIG);
|
||||
|
||||
const gzip = await get("/big", "gzip");
|
||||
assertEquals(gzip.res.headers.get("Content-Encoding"), "gzip");
|
||||
assertEquals(JSON.parse(text(gunzipSync(gzip.bytes))), BIG);
|
||||
|
||||
const refused = await get("/big", "br;q=0, gzip;q=0.5");
|
||||
assertEquals(refused.res.headers.get("Content-Encoding"), "gzip");
|
||||
});
|
||||
|
||||
Deno.test("compressMiddleware: a client that accepts no coding gets the JSON as it is", async () => {
|
||||
for (const accept of [undefined, "identity", "br;q=0"]) {
|
||||
const { res, bytes } = await get("/big", accept);
|
||||
assertEquals(res.headers.get("Content-Encoding"), null);
|
||||
// A cache still has to tell this answer from the compressed one.
|
||||
assertEquals(res.headers.get("Vary"), "Accept-Encoding");
|
||||
assertEquals(JSON.parse(text(bytes)), BIG);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("compressMiddleware: the HTML page is compressed too", async () => {
|
||||
const { res, bytes } = await get("/html", "br");
|
||||
assertEquals(res.headers.get("Content-Encoding"), "br");
|
||||
assertEquals(res.headers.get("Content-Type"), "text/html; charset=UTF-8");
|
||||
assertEquals(text(brotliDecompressSync(bytes)).startsWith("<!doctype"), true);
|
||||
});
|
||||
|
||||
Deno.test("compressMiddleware: short answers, bytes and encoded files are left alone", async () => {
|
||||
const small = await get("/small", "br");
|
||||
assertEquals(small.res.headers.get("Content-Encoding"), null);
|
||||
assertEquals(small.res.headers.get("Vary"), null);
|
||||
assertEquals(JSON.parse(text(small.bytes)), { success: true });
|
||||
|
||||
const bytes = await get("/bytes", "br");
|
||||
assertEquals(bytes.res.headers.get("Content-Encoding"), null);
|
||||
assertEquals(bytes.bytes.length, 4096);
|
||||
|
||||
const encoded = await get("/encoded", "br");
|
||||
assertEquals(encoded.res.headers.get("Content-Encoding"), "br");
|
||||
assertEquals(text(encoded.bytes), "x".repeat(4096));
|
||||
});
|
||||
@@ -1,4 +1,14 @@
|
||||
import { createUser, formWithFile, get, PNG, put, test } from "../helpers.ts";
|
||||
import {
|
||||
createUser,
|
||||
formWithFile,
|
||||
get,
|
||||
hasFfmpeg,
|
||||
makeImage,
|
||||
PNG,
|
||||
put,
|
||||
test,
|
||||
webpWidth,
|
||||
} from "../helpers.ts";
|
||||
import type { SelfUser } from "../../../shared/user.ts";
|
||||
import { assertEquals, assertFalse } from "@std/assert";
|
||||
|
||||
@@ -27,3 +37,53 @@ test("avatars: upload, serve, and 404 when there is none", async () => {
|
||||
400,
|
||||
);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
if (!await hasFfmpeg()) return;
|
||||
const user = await createUser();
|
||||
const red = await makeImage(600, 400, "red");
|
||||
await put("/api/me/avatar", { auth: user.auth, form: formWithFile(red) });
|
||||
|
||||
const small = await get(`/api/users/${user.id}/avatar?w=128`);
|
||||
assertEquals(small.status, 200);
|
||||
assertEquals(small.headers.get("content-type"), "image/webp");
|
||||
assertEquals(small.headers.get("cache-control"), "public, max-age=3600");
|
||||
assertEquals(webpWidth(small.bytes), 128);
|
||||
// Never wider than the upload itself.
|
||||
assertEquals(
|
||||
webpWidth((await get(`/api/users/${user.id}/avatar?w=960`)).bytes),
|
||||
600,
|
||||
);
|
||||
// Without a width, or with one that isn't a number, the upload goes out.
|
||||
assertEquals((await get(`/api/users/${user.id}/avatar`)).bytes, red);
|
||||
assertEquals((await get(`/api/users/${user.id}/avatar?w=big`)).bytes, red);
|
||||
|
||||
await put("/api/me/avatar", {
|
||||
auth: user.auth,
|
||||
form: formWithFile(await makeImage(600, 400, "blue")),
|
||||
});
|
||||
const again = await get(`/api/users/${user.id}/avatar?w=128`);
|
||||
assertEquals(webpWidth(again.bytes), 128);
|
||||
assertFalse(
|
||||
again.bytes.length === small.bytes.length &&
|
||||
again.bytes.every((b, i) => b === small.bytes[i]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { createUser, db, get, insertDump, test } from "../helpers.ts";
|
||||
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";
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
del,
|
||||
formWithFile,
|
||||
get,
|
||||
hasFfmpeg,
|
||||
html,
|
||||
insertCategory,
|
||||
insertComment,
|
||||
@@ -767,16 +768,27 @@ test("custom thumbnails: set, served, removed", async () => {
|
||||
});
|
||||
assertEquals(set.status, 200);
|
||||
assertEquals(set.data.thumbnailMime, "image/png");
|
||||
// A scaled copy by default, as for an upload's own thumbnail; the image
|
||||
// itself where no copy can be made.
|
||||
const thumb = await get(`/api/dumps/${id}/thumbnail`);
|
||||
assertEquals(thumb.status, 200);
|
||||
assertEquals(thumb.headers.get("content-type"), "image/png");
|
||||
assertEquals(thumb.bytes, PNG);
|
||||
if (await hasFfmpeg()) {
|
||||
assertEquals(thumb.headers.get("content-type"), "image/webp");
|
||||
assertEquals(
|
||||
readFile(`scaled/thumbnails/${id}-custom.w960.webp`) !== null,
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
assertEquals(thumb.headers.get("content-type"), "image/png");
|
||||
assertEquals(thumb.bytes, PNG);
|
||||
}
|
||||
|
||||
const removed = await del<Dump>(`/api/dumps/${id}/thumbnail`, {
|
||||
auth: owner.auth,
|
||||
});
|
||||
assertEquals(removed.data.thumbnailMime, undefined);
|
||||
assertEquals(readFile(`thumbnails/${id}-custom`), null);
|
||||
assertEquals(readFile(`scaled/thumbnails/${id}-custom.w960.webp`), null);
|
||||
// A URL dump without its own thumbnail has none to serve.
|
||||
assertEquals((await get(`/api/dumps/${id}/thumbnail`)).status, 404);
|
||||
});
|
||||
|
||||
@@ -16,10 +16,12 @@ import {
|
||||
TEST_DIR,
|
||||
} from "../helpers.ts";
|
||||
import type { PaginatedData } from "../../../shared/api.ts";
|
||||
import type {
|
||||
Playlist,
|
||||
PlaylistMembership,
|
||||
PlaylistWithDumps,
|
||||
import {
|
||||
comparePlaylists,
|
||||
type Playlist,
|
||||
PLAYLIST_SORTS,
|
||||
type PlaylistMembership,
|
||||
type PlaylistWithDumps,
|
||||
} from "../../../shared/playlist.ts";
|
||||
import { assert, assertEquals, assertMatch } from "@std/assert";
|
||||
|
||||
@@ -146,6 +148,72 @@ test("a public playlist hides its private dumps from everyone but their owner",
|
||||
);
|
||||
});
|
||||
|
||||
test("a playlist's dumpCount is of the dumps the viewer may see", async () => {
|
||||
const owner = await createUser();
|
||||
const author = await createUser();
|
||||
const pl = insertPlaylist({ userId: owner.id, title: "Counted banana" });
|
||||
const pub = insertDump({ userId: owner.id });
|
||||
const mine = insertDump({ userId: owner.id, isPrivate: true });
|
||||
// Someone else's, gone private after it was added: its owner no longer
|
||||
// sees it there, and neither does its poster.
|
||||
const theirs = insertDump({ userId: author.id, isPrivate: true });
|
||||
for (const [i, d] of [pub, mine, theirs].entries()) {
|
||||
db.prepare(
|
||||
`INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at)
|
||||
VALUES (?, ?, ?, ?);`,
|
||||
).run(pl.id, d.id, i, new Date().toISOString());
|
||||
}
|
||||
await put(`/api/me/follow/playlists/${pl.id}`, { auth: author.auth });
|
||||
|
||||
const lists = [
|
||||
"/api/playlists",
|
||||
`/api/users/@${owner.username}/playlists`,
|
||||
`/api/users/@${author.username}/follow/playlists`,
|
||||
];
|
||||
for (
|
||||
const [auth, expected] of [
|
||||
[owner.auth, 2],
|
||||
[author.auth, 1],
|
||||
[undefined, 1],
|
||||
] as const
|
||||
) {
|
||||
for (const url of lists) {
|
||||
const { items } = (await get<PaginatedData<Playlist>>(url, { auth }))
|
||||
.data;
|
||||
assertEquals(items.map((p) => p.dumpCount), [expected], url);
|
||||
}
|
||||
assertEquals(
|
||||
(await get<{ playlists: Playlist[] }>("/api/search?q=banana", { auth }))
|
||||
.data.playlists.map((p) => p.dumpCount),
|
||||
[expected],
|
||||
);
|
||||
assertEquals(
|
||||
(await get<Playlist[]>(`/api/dumps/${pub.id}/playlists`, { auth })).data
|
||||
.map((p) => p.dumpCount),
|
||||
[expected],
|
||||
);
|
||||
const page = await get<PlaylistWithDumps>(`/api/playlists/${pl.id}`, {
|
||||
auth,
|
||||
});
|
||||
assertEquals(page.data.dumpCount, expected);
|
||||
assertEquals(ids(page).length, expected);
|
||||
}
|
||||
assertEquals(
|
||||
(await get<PlaylistMembership[]>(`/api/me/playlists?contains=${pub.id}`, {
|
||||
auth: owner.auth,
|
||||
})).data.map((m) => [m.hasDump, m.playlist.dumpCount]),
|
||||
[[true, 2]],
|
||||
);
|
||||
// An edit answers with the editor's count.
|
||||
assertEquals(
|
||||
(await put<Playlist>(`/api/playlists/${pl.id}`, {
|
||||
auth: owner.auth,
|
||||
json: { title: "Counted" },
|
||||
})).data.dumpCount,
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test("reorder: must name exactly the current members", async () => {
|
||||
const owner = await createUser();
|
||||
const { id } = insertPlaylist({ userId: owner.id });
|
||||
@@ -599,7 +667,7 @@ test("playlist categories: filed on creation, replaced on update, dump categorie
|
||||
assertEquals(cleared.data.categoryIds, []);
|
||||
});
|
||||
|
||||
test("GET /api/playlists: every public playlist, newest first, by category", async () => {
|
||||
test("GET /api/playlists: every public playlist, by category", async () => {
|
||||
const owner = await createUser();
|
||||
const other = await createUser();
|
||||
const mixes = insertCategory({ slug: "mixes", kind: "playlist" });
|
||||
@@ -663,3 +731,110 @@ test("GET /api/users/:key/playlists?category=: their playlists in one category",
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("?sort=: by the last dump added, by creation or by dumps, as the viewer sees them", async () => {
|
||||
const owner = await createUser();
|
||||
const made = (title: string, createdAt: string) => {
|
||||
const { id } = insertPlaylist({ userId: owner.id, title });
|
||||
db.prepare(`UPDATE playlists SET created_at = ? WHERE id = ?;`)
|
||||
.run(createdAt, id);
|
||||
return id;
|
||||
};
|
||||
const add = (playlistId: string, addedAt: string, isPrivate = false) =>
|
||||
db.prepare(
|
||||
`INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at)
|
||||
VALUES (?, ?, 0, ?);`,
|
||||
).run(playlistId, insertDump({ userId: owner.id, isPrivate }).id, addedAt);
|
||||
const a = made("A", "2026-01-01T00:00:00.000Z");
|
||||
const b = made("B", "2026-02-01T00:00:00.000Z");
|
||||
const c = made("C", "2026-03-01T00:00:00.000Z");
|
||||
add(a, "2026-04-01T00:00:00.000Z");
|
||||
add(b, "2026-02-10T00:00:00.000Z");
|
||||
add(b, "2026-02-11T00:00:00.000Z");
|
||||
// Only the owner sees it: to anyone else, C is empty and as it was made.
|
||||
add(c, "2026-05-01T00:00:00.000Z", true);
|
||||
|
||||
type Auth = typeof owner.auth;
|
||||
const list = async (query: string, auth?: Auth) =>
|
||||
(await get<PaginatedData<Playlist>>(query, { auth })).data.items;
|
||||
const titles = async (query: string, auth?: Auth) =>
|
||||
(await list(query, auth)).map((p) => p.title);
|
||||
|
||||
for (
|
||||
const url of ["/api/playlists", `/api/users/${owner.id}/playlists`]
|
||||
) {
|
||||
assertEquals(await titles(`${url}?sort=updated`), ["A", "C", "B"], url);
|
||||
assertEquals(await titles(`${url}?sort=new`), ["C", "B", "A"], url);
|
||||
assertEquals(await titles(`${url}?sort=dumps`), ["B", "A", "C"], url);
|
||||
assertEquals(
|
||||
await titles(`${url}?sort=updated`, owner.auth),
|
||||
["C", "A", "B"],
|
||||
url,
|
||||
);
|
||||
// A and C hold one dump each to their owner: the newer goes first.
|
||||
assertEquals(
|
||||
await titles(`${url}?sort=dumps`, owner.auth),
|
||||
["B", "C", "A"],
|
||||
url,
|
||||
);
|
||||
// The first order is the default, which an unknown one falls back to.
|
||||
assertEquals(await titles(url), ["A", "C", "B"], url);
|
||||
assertEquals(await titles(`${url}?sort=nope`), ["A", "C", "B"], url);
|
||||
assertEquals(await titles(`${url}?sort=new&limit=1&page=2`), ["B"], url);
|
||||
}
|
||||
|
||||
const dates = (await list("/api/playlists?sort=new")).map((p) =>
|
||||
p.lastDumpAt
|
||||
);
|
||||
assertEquals(dates, [
|
||||
undefined,
|
||||
"2026-02-11T00:00:00.000Z",
|
||||
"2026-04-01T00:00:00.000Z",
|
||||
] as unknown[]);
|
||||
assertEquals(
|
||||
(await get<PlaylistWithDumps>(`/api/playlists/${c}`, { auth: owner.auth }))
|
||||
.data.lastDumpAt as unknown,
|
||||
"2026-05-01T00:00:00.000Z",
|
||||
);
|
||||
assertEquals(
|
||||
(await get<PlaylistWithDumps>(`/api/playlists/${c}`)).data.lastDumpAt,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("comparePlaylists orders playlists as the API's ?sort= pages them", async () => {
|
||||
const owner = await createUser();
|
||||
// Ties of every kind: dates and counts shared by several playlists.
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const { id } = insertPlaylist({ userId: owner.id, title: `P${i}` });
|
||||
db.prepare(`UPDATE playlists SET created_at = ? WHERE id = ?;`)
|
||||
.run(`2026-01-0${1 + i}T00:00:00.000Z`, id);
|
||||
for (let n = 0; n < i % 3; n++) {
|
||||
db.prepare(
|
||||
`INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at)
|
||||
VALUES (?, ?, ?, ?);`,
|
||||
).run(
|
||||
id,
|
||||
insertDump({ userId: owner.id }).id,
|
||||
n,
|
||||
`2026-02-0${1 + ((i * 5 + n) % 9)}T00:00:00.000Z`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const sort of PLAYLIST_SORTS) {
|
||||
const { items } = (await get<PaginatedData<Playlist>>(
|
||||
`/api/playlists?sort=${sort}`,
|
||||
)).data;
|
||||
// The dates come as strings over the wire.
|
||||
const playlists = items.map((p) => ({
|
||||
...p,
|
||||
createdAt: new Date(p.createdAt),
|
||||
lastDumpAt: p.lastDumpAt && new Date(p.lastDumpAt),
|
||||
}));
|
||||
assertEquals(
|
||||
playlists.toSorted(comparePlaylists(sort)).map((p) => p.title),
|
||||
items.map((p) => p.title),
|
||||
sort,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { get, html, PNG, stubNetwork } from "../helpers.ts";
|
||||
import {
|
||||
get,
|
||||
hasFfmpeg,
|
||||
html,
|
||||
makeImage,
|
||||
PNG,
|
||||
stubNetwork,
|
||||
webpWidth,
|
||||
} from "../helpers.ts";
|
||||
import { assertEquals, assertFalse } from "@std/assert";
|
||||
import type { Tralbum, YouTubePlaylist } from "../../../shared/player.ts";
|
||||
|
||||
@@ -29,6 +37,25 @@ Deno.test("GET /api/remote/image: rasters only, fetched once for everyone", asyn
|
||||
assertEquals(svg.status, 400);
|
||||
});
|
||||
|
||||
Deno.test("GET /api/remote/image: `?w=` scales the image, fetched once for every width", async () => {
|
||||
if (!await hasFfmpeg()) return;
|
||||
const wide = await makeImage(800, 200);
|
||||
using net = stubNetwork(() =>
|
||||
new Response(wide, { headers: { "content-type": "image/png" } })
|
||||
);
|
||||
const path = `/api/remote/image?url=${
|
||||
encodeURIComponent("https://cdn.example.com/wide.png")
|
||||
}`;
|
||||
const small = await get(`${path}&w=128`);
|
||||
assertEquals(small.status, 200);
|
||||
assertEquals(small.headers.get("content-type"), "image/webp");
|
||||
assertEquals(webpWidth(small.bytes), 128);
|
||||
assertEquals(webpWidth((await get(`${path}&w=320`)).bytes), 320);
|
||||
assertEquals((await get(`${path}&w=128`)).bytes, small.bytes);
|
||||
assertEquals((await get(path)).bytes, wide);
|
||||
assertEquals(net.calls.length, 1);
|
||||
});
|
||||
|
||||
Deno.test("GET /api/remote/image: an error answer is never served nor cached", async () => {
|
||||
let failing = true;
|
||||
using net = stubNetwork(() =>
|
||||
|
||||
@@ -1,34 +1,24 @@
|
||||
import {
|
||||
createUser,
|
||||
db,
|
||||
del,
|
||||
get,
|
||||
hasFfmpeg,
|
||||
insertDump,
|
||||
makeImage,
|
||||
PNG,
|
||||
test,
|
||||
TEST_DIR,
|
||||
webpWidth,
|
||||
} from "../helpers.ts";
|
||||
import { assertEquals } from "@std/assert";
|
||||
|
||||
const BYTES = new TextEncoder().encode("0123456789");
|
||||
|
||||
function createdAtOf(id: string): string {
|
||||
return (db.prepare(`SELECT created_at FROM dumps WHERE id = ?;`).get(id) as {
|
||||
created_at: string;
|
||||
}).created_at;
|
||||
}
|
||||
|
||||
async function hasFfmpeg(): Promise<boolean> {
|
||||
try {
|
||||
return (await new Deno.Command("ffmpeg", {
|
||||
args: ["-version"],
|
||||
stdout: "null",
|
||||
stderr: "null",
|
||||
}).output()).success;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function readDir(path: string): string[] {
|
||||
try {
|
||||
return [...Deno.readDirSync(path)].map((e) => e.name);
|
||||
@@ -81,54 +71,87 @@ test("GET /api/dumps/:id/thumbnail: serves a cached video still, with privacy",
|
||||
assertEquals((await get(`/api/dumps/nope/thumbnail`)).status, 400);
|
||||
});
|
||||
|
||||
test("GET /api/dumps/:id/thumbnail: an image dump gets a preview, a GIF itself", async () => {
|
||||
test("GET /api/dumps/:id/thumbnail: an image dump gets a scaled copy, or itself when none can be made", async () => {
|
||||
const owner = await createUser();
|
||||
const scaled = (id: string) =>
|
||||
readDir(`${TEST_DIR}/uploads/scaled/dumps`).filter((n) => n.startsWith(id))
|
||||
.sort();
|
||||
|
||||
// Not an image ffmpeg can read (or no ffmpeg at all): the original goes out.
|
||||
const gif = new TextEncoder().encode("GIF89a-not-really");
|
||||
const animated = insertDump({
|
||||
const broken = insertDump({
|
||||
userId: owner.id,
|
||||
fileName: "a.gif",
|
||||
fileMime: "image/gif",
|
||||
fileData: gif,
|
||||
});
|
||||
const res = await get(`/api/dumps/${animated.id}/thumbnail`);
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(res.headers.get("content-type"), "image/gif");
|
||||
assertEquals(res.bytes, gif);
|
||||
const fallback = await get(`/api/dumps/${broken.id}/thumbnail`);
|
||||
assertEquals(fallback.status, 200);
|
||||
assertEquals(fallback.headers.get("content-type"), "image/gif");
|
||||
assertEquals(fallback.bytes, gif);
|
||||
assertEquals(scaled(broken.id), []);
|
||||
|
||||
// A real PNG is scaled into a preview, kept beside the video stills.
|
||||
if (!await hasFfmpeg()) {
|
||||
const photo = insertDump({
|
||||
userId: owner.id,
|
||||
fileName: "p.png",
|
||||
fileMime: "image/png",
|
||||
fileData: PNG,
|
||||
});
|
||||
const itself = await get(`/api/dumps/${photo.id}/thumbnail`);
|
||||
assertEquals(itself.headers.get("content-type"), "image/png");
|
||||
assertEquals(itself.bytes, PNG);
|
||||
return;
|
||||
}
|
||||
|
||||
// A real image is scaled to the widest width without `w`, and to the width
|
||||
// asked for with it, each copy kept under the upload's own path.
|
||||
const photo = insertDump({
|
||||
userId: owner.id,
|
||||
fileName: "p.png",
|
||||
fileMime: "image/png",
|
||||
fileData: PNG,
|
||||
fileData: await makeImage(1400, 300),
|
||||
});
|
||||
const preview = await get(`/api/dumps/${photo.id}/thumbnail`);
|
||||
assertEquals(preview.status, 200);
|
||||
assertEquals(preview.headers.get("content-type"), "image/png");
|
||||
if (await hasFfmpeg()) {
|
||||
assertEquals(
|
||||
readDir(`${TEST_DIR}/uploads/thumbnails`).filter((n) =>
|
||||
n.startsWith(photo.id)
|
||||
),
|
||||
[`${photo.id}.preview.png`],
|
||||
);
|
||||
}
|
||||
const widest = await get(`/api/dumps/${photo.id}/thumbnail`);
|
||||
assertEquals(widest.status, 200);
|
||||
assertEquals(widest.headers.get("content-type"), "image/webp");
|
||||
assertEquals(webpWidth(widest.bytes), 960);
|
||||
const small = await get(`/api/dumps/${photo.id}/thumbnail?w=128`);
|
||||
assertEquals(small.headers.get("content-type"), "image/webp");
|
||||
assertEquals(webpWidth(small.bytes), 128);
|
||||
// A width between two of the list's takes the one above.
|
||||
const between = await get(`/api/dumps/${photo.id}/thumbnail?w=200`);
|
||||
assertEquals(webpWidth(between.bytes), 320);
|
||||
assertEquals(scaled(photo.id), [
|
||||
`${photo.id}.w128.webp`,
|
||||
`${photo.id}.w320.webp`,
|
||||
`${photo.id}.w960.webp`,
|
||||
]);
|
||||
|
||||
// Not an image ffmpeg can read (or no ffmpeg at all): the original goes out.
|
||||
const broken = insertDump({
|
||||
// A reader without WebP, as a mail client, asks for a PNG or a JPEG.
|
||||
const compat = await get(`/api/dumps/${photo.id}/thumbnail?w=640&f=compat`);
|
||||
assertEquals(compat.status, 200);
|
||||
assertEquals(compat.headers.get("content-type"), "image/png");
|
||||
// Its header states the width, on four bytes from the sixteenth.
|
||||
assertEquals(new DataView(compat.bytes.buffer).getUint32(16), 640);
|
||||
assertEquals(scaled(photo.id).includes(`${photo.id}.w640.png`), true);
|
||||
|
||||
// An animation keeps its frames.
|
||||
const animated = insertDump({
|
||||
userId: owner.id,
|
||||
fileName: "b.png",
|
||||
fileMime: "image/png",
|
||||
fileData: BYTES,
|
||||
fileName: "anim.gif",
|
||||
fileMime: "image/gif",
|
||||
fileData: await makeImage(400, 300, "blue", "gif"),
|
||||
});
|
||||
const fallback = await get(`/api/dumps/${broken.id}/thumbnail`);
|
||||
assertEquals(fallback.status, 200);
|
||||
assertEquals(fallback.headers.get("content-type"), "image/png");
|
||||
assertEquals(fallback.bytes, BYTES);
|
||||
const moving = await get(`/api/dumps/${animated.id}/thumbnail?w=128`);
|
||||
assertEquals(moving.headers.get("content-type"), "image/webp");
|
||||
assertEquals(webpWidth(moving.bytes), 128);
|
||||
assertEquals(new TextDecoder().decode(moving.bytes).includes("ANMF"), true);
|
||||
|
||||
// The copies go with the dump.
|
||||
assertEquals(
|
||||
readDir(`${TEST_DIR}/uploads/thumbnails`).filter((n) =>
|
||||
n.startsWith(broken.id)
|
||||
),
|
||||
[],
|
||||
(await del(`/api/dumps/${photo.id}`, { auth: owner.auth })).status,
|
||||
200,
|
||||
);
|
||||
assertEquals(scaled(photo.id), []);
|
||||
});
|
||||
|
||||
@@ -654,6 +654,78 @@ wsTest("REST writes broadcast to the right audience", async (connect) => {
|
||||
assertEquals(x.all("playlist_dumps_updated"), []);
|
||||
});
|
||||
|
||||
wsTest(
|
||||
"dump_related_changed reaches those who may see both ends of a link",
|
||||
async (connect) => {
|
||||
const owner = await createUser();
|
||||
const other = await createUser();
|
||||
const src = insertDump({ userId: owner.id, title: "src" });
|
||||
const target = insertDump({ userId: other.id, title: "target" });
|
||||
const secret = insertDump({
|
||||
userId: other.id,
|
||||
title: "secret",
|
||||
isPrivate: true,
|
||||
});
|
||||
const o = await connect(owner);
|
||||
const x = await connect(other);
|
||||
const anon = await connect();
|
||||
|
||||
// A comment linking a public dump and a private one: the latter's poster
|
||||
// alone hears of it.
|
||||
const links =
|
||||
`http://gerbeur.test${target.path} http://gerbeur.test${secret.path}`;
|
||||
const comment = await post<Comment>(`/api/dumps/${src.id}/comments`, {
|
||||
auth: owner.auth,
|
||||
json: { body: links },
|
||||
});
|
||||
for (const c of [o, anon]) {
|
||||
assertEquals((await c.next("dump_related_changed")).dumpIds, [
|
||||
src.id,
|
||||
target.id,
|
||||
]);
|
||||
}
|
||||
const both = (await x.next("dump_related_changed")).dumpIds;
|
||||
assertEquals(both[0], src.id);
|
||||
assertEquals(new Set(both.slice(1)), new Set([target.id, secret.id]));
|
||||
|
||||
// An edit that changes no link says nothing; a deletion drops them.
|
||||
await patch(`/api/comments/${comment.data.id}`, {
|
||||
auth: owner.auth,
|
||||
json: { body: `${links} again` },
|
||||
});
|
||||
await o.next("comment_updated");
|
||||
assertEquals(o.all("dump_related_changed").length, 1);
|
||||
await del(`/api/comments/${comment.data.id}`, { auth: owner.auth });
|
||||
assertEquals((await anon.next("dump_related_changed")).dumpIds, [
|
||||
src.id,
|
||||
target.id,
|
||||
]);
|
||||
await o.next("dump_related_changed");
|
||||
await x.next("dump_related_changed");
|
||||
|
||||
// A private dump's links are its poster's alone, until it turns public.
|
||||
const hidden = insertDump({ userId: owner.id, isPrivate: true });
|
||||
await put(`/api/dumps/${hidden.id}`, {
|
||||
auth: owner.auth,
|
||||
json: { comment: `http://gerbeur.test${target.path}` },
|
||||
});
|
||||
assertEquals((await o.next("dump_related_changed")).dumpIds, [
|
||||
hidden.id,
|
||||
target.id,
|
||||
]);
|
||||
await x.sync();
|
||||
assertEquals(x.all("dump_related_changed").length, 2);
|
||||
await put(`/api/dumps/${hidden.id}`, {
|
||||
auth: owner.auth,
|
||||
json: { isPrivate: false },
|
||||
});
|
||||
assertEquals((await x.next("dump_related_changed")).dumpIds, [
|
||||
hidden.id,
|
||||
target.id,
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
wsTest("a password change closes the user's sockets", async (connect) => {
|
||||
const user = await createUser();
|
||||
const c = await connect(user);
|
||||
@@ -672,6 +744,47 @@ wsTest("a password change closes the user's sockets", async (connect) => {
|
||||
assertFalse(res.data.token === user.token);
|
||||
});
|
||||
|
||||
wsTest(
|
||||
"playlist_dumps_updated dates the latest dump each viewer can see",
|
||||
async (connect) => {
|
||||
const owner = await createUser();
|
||||
const pl = insertPlaylist({ userId: owner.id });
|
||||
const o = await connect(owner);
|
||||
const anon = await connect();
|
||||
|
||||
const add = async (isPrivate: boolean) => {
|
||||
const dump = insertDump({ userId: owner.id, isPrivate });
|
||||
await post(`/api/playlists/${pl.id}/dumps/${dump.id}`, {
|
||||
auth: owner.auth,
|
||||
});
|
||||
return [
|
||||
(await o.next("playlist_dumps_updated")).lastDumpAt,
|
||||
(await anon.next("playlist_dumps_updated")).lastDumpAt,
|
||||
];
|
||||
};
|
||||
const [first, firstToAnon] = await add(false);
|
||||
assert(first);
|
||||
assertEquals(firstToAnon, first);
|
||||
// The owner's private dump moves the date for them alone.
|
||||
const [second, secondToAnon] = await add(true);
|
||||
assert(second && second >= first);
|
||||
assertEquals(secondToAnon, first);
|
||||
|
||||
await put(`/api/playlists/${pl.id}`, {
|
||||
auth: owner.auth,
|
||||
json: { title: "Renamed" },
|
||||
});
|
||||
assertEquals(
|
||||
(await o.next("playlist_updated")).playlist.lastDumpAt,
|
||||
second,
|
||||
);
|
||||
assertEquals(
|
||||
(await anon.next("playlist_updated")).playlist.lastDumpAt,
|
||||
first,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
wsTest(
|
||||
"playlist_dumps_updated lists only what each viewer can see",
|
||||
async (connect) => {
|
||||
@@ -709,8 +822,32 @@ wsTest(
|
||||
for (const c of [a, x, anon]) {
|
||||
const msg = await c.next("playlist_dumps_updated");
|
||||
assertEquals(msg.dumpIds, [added.id, pub.id]);
|
||||
// The count is the playlist's, as its dumpCount gives it to everyone.
|
||||
assertEquals(msg.dumpCount, 4);
|
||||
}
|
||||
|
||||
// A change to its details carries the count of what each may see, too.
|
||||
await put(`/api/playlists/${pl.id}`, {
|
||||
auth: owner.auth,
|
||||
json: { title: "Renamed" },
|
||||
});
|
||||
assertEquals((await o.next("playlist_updated")).playlist.dumpCount, 3);
|
||||
for (const c of [a, x, anon]) {
|
||||
assertEquals((await c.next("playlist_updated")).playlist.dumpCount, 2);
|
||||
}
|
||||
await put(`/api/playlists/${pl.id}`, {
|
||||
auth: owner.auth,
|
||||
json: { isPublic: false },
|
||||
});
|
||||
await put(`/api/playlists/${pl.id}`, {
|
||||
auth: owner.auth,
|
||||
json: { isPublic: true },
|
||||
});
|
||||
assertEquals(
|
||||
(await o.next("playlist_updated", (m) => m.playlist.isPublic)).playlist
|
||||
.dumpCount,
|
||||
3,
|
||||
);
|
||||
for (const c of [a, x, anon]) {
|
||||
assertEquals((await c.next("playlist_created")).playlist.dumpCount, 2);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -817,7 +954,6 @@ wsTest(
|
||||
});
|
||||
const hidden = await x.next("playlist_dumps_updated");
|
||||
assertEquals(hidden.dumpIds, [keep.id, gone.id]);
|
||||
assertEquals(hidden.dumpCount, 3);
|
||||
assertEquals(
|
||||
(await o.next("playlist_dumps_updated")).dumpIds,
|
||||
[keep.id, flip.id, gone.id],
|
||||
@@ -835,7 +971,7 @@ wsTest(
|
||||
await del(`/api/dumps/${gone.id}`, { auth: owner.auth });
|
||||
const after = await x.next(
|
||||
"playlist_dumps_updated",
|
||||
(m) => m.dumpCount === 2,
|
||||
(m) => m.dumpIds.length === 2,
|
||||
);
|
||||
assertEquals(after.dumpIds, [keep.id, flip.id]);
|
||||
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
import { assertEquals } from "@std/assert";
|
||||
import {
|
||||
communityStatsOf,
|
||||
getCommunityFeed,
|
||||
getCommunityStats,
|
||||
listCommunityEvents,
|
||||
refreshCommunityStats,
|
||||
} from "../../services/community-service.ts";
|
||||
|
||||
@@ -43,9 +45,25 @@ test("communityStatsOf: the period's top public dumps, members, public playlists
|
||||
insertPlaylist({ userId: ann.id, title: "Small" });
|
||||
const full = insertPlaylist({ userId: bob.id, title: "Full" });
|
||||
insertPlaylist({ userId: bob.id, title: "Hidden", isPublic: false });
|
||||
// Bob's private dump isn't counted: every reader gets the same figures.
|
||||
const secret = insertDump({
|
||||
userId: bob.id,
|
||||
title: "Secret",
|
||||
isPrivate: true,
|
||||
});
|
||||
db.prepare(
|
||||
`INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at) VALUES (?, ?, 0, ?), (?, ?, 1, ?);`,
|
||||
).run(full.id, hit.id, ago(0), full.id, tie.id, ago(0));
|
||||
`INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at) VALUES (?, ?, 0, ?), (?, ?, 1, ?), (?, ?, 2, ?);`,
|
||||
).run(
|
||||
full.id,
|
||||
hit.id,
|
||||
ago(0),
|
||||
full.id,
|
||||
tie.id,
|
||||
ago(0),
|
||||
full.id,
|
||||
secret.id,
|
||||
ago(0),
|
||||
);
|
||||
|
||||
const stats = communityStatsOf(...week());
|
||||
assertEquals(stats.top.map((d) => d.title), ["Hit", "Newer", "Tie"]);
|
||||
@@ -86,3 +104,109 @@ test("getCommunityStats: serves the last refresh, of the week or the month", asy
|
||||
"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/testing@^1.0.21": "1.0.21",
|
||||
"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:@fontsource-variable/fraunces@5.3.0": "5.3.0",
|
||||
"npm:@eslint/js@^10.0.1": "10.0.1_eslint@10.11.0_jiti@2.7.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/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/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/manufacturing-consent@5.3.0": "5.3.0",
|
||||
"npm:@lingui/cli@*": "6.8.0",
|
||||
"npm:@lingui/cli@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/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/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/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/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",
|
||||
"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/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/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/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_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:@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@^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:@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:@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",
|
||||
"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-refresh@~0.5.7": "0.5.7_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",
|
||||
"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: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-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-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: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: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: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": "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"
|
||||
},
|
||||
"jsr": {
|
||||
"@db/sqlite@0.13.0": {
|
||||
@@ -382,14 +386,14 @@
|
||||
"@csstools/color-helpers@6.1.2": {
|
||||
"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==",
|
||||
"dependencies": [
|
||||
"@csstools/css-parser-algorithms",
|
||||
"@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==",
|
||||
"dependencies": [
|
||||
"@csstools/color-helpers",
|
||||
@@ -416,7 +420,7 @@
|
||||
"@csstools/css-tokenizer@4.0.2": {
|
||||
"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==",
|
||||
"dependencies": [
|
||||
"eslint",
|
||||
@@ -446,7 +450,7 @@
|
||||
"@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==",
|
||||
"dependencies": [
|
||||
"eslint"
|
||||
@@ -468,21 +472,33 @@
|
||||
"@exodus/bytes@1.16.0": {
|
||||
"integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA=="
|
||||
},
|
||||
"@fontsource-variable/fraunces@5.3.0": {
|
||||
"integrity": "sha512-9BYGySn4AHEJdgp9Z28tQ3X+laJMEOITXkQarZXeloWQZDq5oOvXJ3kDA8c7MGIfpogIaZfjrQBqmda8POOCKA=="
|
||||
"@fontsource-variable/bricolage-grotesque@5.3.0": {
|
||||
"integrity": "sha512-TLi9Q4hJjS2UvoTMRSS2nHu6c4R56lAw60NR9QYtVRCHn0XtsFpiEhNffZ8Glsoxu6wEEwLKBP8lb94J52PNBA=="
|
||||
},
|
||||
"@fontsource-variable/jetbrains-mono@5.3.0": {
|
||||
"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": {
|
||||
"integrity": "sha512-C1rm7n1OuEx4Se/SAB/5EeUuCviGFFnYTr5vxuQQm2YIOfgbQWx/P6qqhdXlVCBRm/1UzNWCtDK5jNbVmO6W2w=="
|
||||
},
|
||||
"@fontsource-variable/space-grotesk@5.3.0": {
|
||||
"integrity": "sha512-2IxmvfB08i9vnGB3Ym/AXvhRE+8XOjWMXIyDum03c+tPwH0FUoMNQfGpU8NXPxjbws0Vvss3AH0Zqt4oJBBAdw=="
|
||||
},
|
||||
"@fontsource/comic-neue@5.3.0": {
|
||||
"integrity": "sha512-uZsyUTExw8tCySNhL65dN6rEUhASMPGintSS5Kw/ZkrQTK1vcAgramzOLT/mZKtZ+EXr03MHj0s5osVVJITCow=="
|
||||
},
|
||||
"@fontsource/instrument-serif@5.3.0": {
|
||||
"integrity": "sha512-mDiaIg0u67sYV59fie92Wz4sM8UiVlbL7fLxnFPCKkX15DMASEnQTREbaP5S5/3DCcsoAOcQ0sV9E4AeY4QqYQ=="
|
||||
},
|
||||
"@fontsource/manufacturing-consent@5.3.0": {
|
||||
"integrity": "sha512-p/omJ13hFgV6+THlT370nyHjlMkrZWkKPiMSZHROcyFoylVvazo19Rp9KJhwudWqcbkClgzjEryvC330y4Pr6Q=="
|
||||
},
|
||||
"@humanfs/core@0.19.2": {
|
||||
"integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
|
||||
"dependencies": [
|
||||
@@ -647,14 +663,14 @@
|
||||
"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==",
|
||||
"dependencies": [
|
||||
"@lingui/conf",
|
||||
"@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==",
|
||||
"dependencies": [
|
||||
"@babel/core",
|
||||
@@ -869,7 +885,7 @@
|
||||
"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==",
|
||||
"dependencies": [
|
||||
"@adobe/css-tools",
|
||||
@@ -885,7 +901,7 @@
|
||||
"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==",
|
||||
"dependencies": [
|
||||
"@babel/runtime",
|
||||
@@ -1003,7 +1019,7 @@
|
||||
"@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==",
|
||||
"dependencies": [
|
||||
"@eslint-community/regexpp",
|
||||
@@ -1019,7 +1035,7 @@
|
||||
"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==",
|
||||
"dependencies": [
|
||||
"@typescript-eslint/scope-manager",
|
||||
@@ -1053,7 +1069,7 @@
|
||||
"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==",
|
||||
"dependencies": [
|
||||
"@typescript-eslint/types",
|
||||
@@ -1083,7 +1099,7 @@
|
||||
"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==",
|
||||
"dependencies": [
|
||||
"@eslint-community/eslint-utils",
|
||||
@@ -1105,7 +1121,7 @@
|
||||
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
|
||||
"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==",
|
||||
"dependencies": [
|
||||
"@rolldown/pluginutils",
|
||||
@@ -1113,7 +1129,7 @@
|
||||
"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==",
|
||||
"dependencies": [
|
||||
"@bcoe/v8-coverage",
|
||||
@@ -1136,7 +1152,7 @@
|
||||
"@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==",
|
||||
"dependencies": [
|
||||
"@jridgewell/trace-mapping",
|
||||
@@ -1428,7 +1444,7 @@
|
||||
"escape-string-regexp@5.0.0": {
|
||||
"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==",
|
||||
"dependencies": [
|
||||
"@babel/core",
|
||||
@@ -1439,7 +1455,7 @@
|
||||
"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==",
|
||||
"dependencies": [
|
||||
"eslint"
|
||||
@@ -2569,7 +2585,7 @@
|
||||
"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==",
|
||||
"dependencies": [
|
||||
"@remix-run/route-pattern",
|
||||
@@ -2834,7 +2850,7 @@
|
||||
"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==",
|
||||
"dependencies": [
|
||||
"@typescript-eslint/eslint-plugin",
|
||||
@@ -2955,7 +2971,7 @@
|
||||
],
|
||||
"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==",
|
||||
"dependencies": [
|
||||
"@types/chai",
|
||||
@@ -3069,11 +3085,15 @@
|
||||
"packageJson": {
|
||||
"dependencies": [
|
||||
"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/libre-franklin@5.3.0",
|
||||
"npm:@fontsource-variable/newsreader@5.3.0",
|
||||
"npm:@fontsource-variable/saira@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/manufacturing-consent@5.3.0",
|
||||
"npm:@lingui/cli@6.8.0",
|
||||
"npm:@lingui/conf@6.8.0",
|
||||
"npm:@lingui/core@6.8.0",
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png?v=__ICON_VERSION__" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/favicon-192x192.png?v=__ICON_VERSION__" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- Keeps the Dark Reader extension off the page, which has dark palettes
|
||||
of its own: it rewrites the stylesheet's colors, and its rewrite of
|
||||
the fallback the build gives every light-dark() leaves them all
|
||||
invalid under a dark system theme, so no background is painted. -->
|
||||
<meta name="darkreader-lock" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#01152a" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#faf5e9" />
|
||||
<meta name="site-name" content="__SITE_NAME__" />
|
||||
|
||||
File diff suppressed because one or more lines are too long
337
locales/en.po
337
locales/en.po
@@ -13,18 +13,18 @@ msgstr ""
|
||||
"Language-Team: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#: src/components/comment/CommentNode.tsx:140
|
||||
#: src/components/comment/CommentNode.tsx:137
|
||||
msgid "[deleted]"
|
||||
msgstr "[deleted]"
|
||||
|
||||
#. placeholder {0}: dump.commentCount
|
||||
#: 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}}"
|
||||
msgstr "{0, plural, one {# comment} other {# comments}}"
|
||||
|
||||
#. placeholder {0}: playlist.dumpCount
|
||||
#: src/components/playlist/PlaylistCard.tsx:120
|
||||
#: src/components/playlist/PlaylistCard.tsx:141
|
||||
msgid "{0, plural, one {# dump} other {# dumps}}"
|
||||
msgstr "{0, plural, one {# dump} other {# dumps}}"
|
||||
|
||||
@@ -60,7 +60,7 @@ msgstr "{0}–{1} characters: letters, numbers, or underscores"
|
||||
msgid "email.digest.totals"
|
||||
msgstr "{dumps, plural, one {# dump} other {# dumps}}, {upvotes, plural, one {# upvote} other {# upvotes}} and {comments, plural, one {# comment} other {# comments}}."
|
||||
|
||||
#: src/pages/Search.tsx:178
|
||||
#: src/pages/Search.tsx:215
|
||||
msgid "{label} ({count})"
|
||||
msgstr "{label} ({count})"
|
||||
|
||||
@@ -206,14 +206,14 @@ msgstr "# Welcome to {site}!\n"
|
||||
"Your account has been created successfully. Welcome aboard!"
|
||||
|
||||
#: src/components/dump/create/DumpCreateModal.tsx:736
|
||||
#: src/pages/PlaylistDetail.tsx:98
|
||||
#: src/pages/UserPublicProfile.tsx:125
|
||||
#: src/pages/PlaylistDetail.tsx:99
|
||||
#: src/pages/UserPublicProfile.tsx:129
|
||||
msgid "← Back"
|
||||
msgstr "← Back"
|
||||
|
||||
#: src/pages/Dump.tsx:353
|
||||
#: src/pages/Dump.tsx:378
|
||||
#: src/pages/Dump.tsx:605
|
||||
#: src/pages/Dump.tsx:374
|
||||
#: src/pages/Dump.tsx:399
|
||||
#: src/pages/Dump.tsx:560
|
||||
#: src/pages/DumpEdit.tsx:234
|
||||
msgid "← Back to all dumps"
|
||||
msgstr "← Back to all dumps"
|
||||
@@ -226,22 +226,42 @@ msgstr "+ Invite someone"
|
||||
msgid "+ New playlist"
|
||||
msgstr "+ New playlist"
|
||||
|
||||
#: src/pages/Dump.tsx:452
|
||||
#: src/pages/Dump.tsx:451
|
||||
msgid "+ Playlist"
|
||||
msgstr "+ Playlist"
|
||||
|
||||
#. placeholder {0}: d.commenterUsername
|
||||
#. placeholder {0}: event.username
|
||||
#. placeholder {1}: d.dumpTitle
|
||||
#. placeholder {1}: event.dump.title
|
||||
#: src/components/social/CommunityFeed.tsx:114
|
||||
#: src/pages/Notifications.tsx:239
|
||||
msgid "<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 {1}: d.playlistTitle
|
||||
#: src/pages/Notifications.tsx:199
|
||||
msgid "<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 {1}: d.dumpTitle
|
||||
#: 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>"
|
||||
|
||||
#. placeholder {0}: d.dumperUsername
|
||||
#. placeholder {0}: event.username
|
||||
#. placeholder {1}: d.dumpTitle
|
||||
#. placeholder {1}: event.dump.title
|
||||
#: src/components/social/CommunityFeed.tsx:94
|
||||
#: src/pages/Notifications.tsx:209
|
||||
msgid "<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"
|
||||
|
||||
#. placeholder {0}: d.voterUsername
|
||||
#. placeholder {0}: event.username
|
||||
#. placeholder {1}: d.dumpTitle
|
||||
#. placeholder {1}: event.dump.title
|
||||
#: src/components/social/CommunityFeed.tsx:104
|
||||
#: src/pages/Notifications.tsx:229
|
||||
msgid "<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."
|
||||
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
|
||||
msgid "1 new message"
|
||||
msgstr "1 new message"
|
||||
@@ -305,7 +344,7 @@ msgstr "a post"
|
||||
msgid "Account"
|
||||
msgstr "Account"
|
||||
|
||||
#: src/components/profile/ProfileHeader.tsx:144
|
||||
#: src/components/profile/ProfileHeader.tsx:151
|
||||
msgid "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…"
|
||||
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:116
|
||||
msgid "Add category"
|
||||
msgstr "Add category"
|
||||
|
||||
#: src/components/profile/ProfileHeader.tsx:173
|
||||
#: src/components/profile/ProfileHeader.tsx:168
|
||||
msgid "Add email…"
|
||||
msgstr "Add email…"
|
||||
|
||||
@@ -345,10 +389,6 @@ msgstr "Admin"
|
||||
msgid "All"
|
||||
msgstr "All"
|
||||
|
||||
#: src/components/layout/UserMenu.tsx:108
|
||||
msgid "All playlists"
|
||||
msgstr "All playlists"
|
||||
|
||||
#. placeholder {0}: first.ownerUsername
|
||||
#. placeholder {1}: relativeTime(first.createdAt)
|
||||
#: src/components/dump/create/DuplicateNotice.tsx:39
|
||||
@@ -419,8 +459,8 @@ msgstr "by @{username}"
|
||||
msgid "email.digest.playlistMeta"
|
||||
msgstr "by @{username} · {n, plural, one {# dump} other {# dumps}}"
|
||||
|
||||
#: src/contexts/ws/WSProvider.tsx:376
|
||||
#: src/contexts/ws/WSProvider.tsx:765
|
||||
#: src/contexts/ws/WSProvider.tsx:377
|
||||
#: src/contexts/ws/WSProvider.tsx:770
|
||||
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
|
||||
msgstr "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
|
||||
|
||||
@@ -431,16 +471,13 @@ msgstr "Can't sign in there"
|
||||
|
||||
#: src/components/category/CategoryManager.tsx:241
|
||||
#: src/components/category/CategoryManager.tsx:242
|
||||
#: src/components/chat/ChatEditBox.tsx:92
|
||||
#: src/components/comment/CommentForm.tsx:127
|
||||
#: src/components/comment/CommentForm.tsx:125
|
||||
#: src/components/dump/create/DumpCreateModal.tsx:746
|
||||
#: src/components/form/FormActions.tsx:42
|
||||
#: src/components/form/InlineEdit.tsx:318
|
||||
#: 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/pages/Authorize.tsx:158
|
||||
#: src/pages/Dump.tsx:494
|
||||
#: src/pages/DumpEdit.tsx:561
|
||||
msgid "Cancel"
|
||||
msgstr "Cancel"
|
||||
@@ -466,7 +503,7 @@ msgstr "Categories"
|
||||
msgid "Category"
|
||||
msgstr "Category"
|
||||
|
||||
#: src/components/profile/ProfileHeader.tsx:102
|
||||
#: src/components/profile/ProfileHeader.tsx:109
|
||||
msgid "Change avatar"
|
||||
msgstr "Change avatar"
|
||||
|
||||
@@ -525,7 +562,7 @@ msgstr "Close anyway"
|
||||
msgid "Close emoji picker"
|
||||
msgstr "Close emoji picker"
|
||||
|
||||
#: src/components/player/GlobalPlayer.tsx:262
|
||||
#: src/components/player/GlobalPlayer.tsx:264
|
||||
msgid "Close player"
|
||||
msgstr "Close player"
|
||||
|
||||
@@ -533,7 +570,7 @@ msgstr "Close player"
|
||||
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."
|
||||
|
||||
#: src/components/player/GlobalPlayer.tsx:254
|
||||
#: src/components/player/GlobalPlayer.tsx:256
|
||||
msgid "Collapse player"
|
||||
msgstr "Collapse player"
|
||||
|
||||
@@ -555,7 +592,7 @@ msgstr "Confirm new password"
|
||||
msgid "Connecting…"
|
||||
msgstr "Connecting…"
|
||||
|
||||
#: src/contexts/ws/WSProvider.tsx:758
|
||||
#: src/contexts/ws/WSProvider.tsx:763
|
||||
msgid "Connection lost — this message may not have been sent"
|
||||
msgstr "Connection lost — this message may not have been sent"
|
||||
|
||||
@@ -583,7 +620,7 @@ msgstr "Could not change password"
|
||||
msgid "Could not connect to the server. Check your connection and try again."
|
||||
msgstr "Could not connect to the server. Check your connection and try again."
|
||||
|
||||
#: src/components/profile/ProfilePlaylistsTab.tsx:171
|
||||
#: src/components/profile/ProfilePlaylistsTab.tsx:194
|
||||
#: src/pages/DumpEdit.tsx:555
|
||||
msgid "Could not delete"
|
||||
msgstr "Could not delete"
|
||||
@@ -632,6 +669,10 @@ msgstr "Create"
|
||||
msgid "Create & Add"
|
||||
msgstr "Create & Add"
|
||||
|
||||
#: src/components/playlist/PlaylistCard.tsx:152
|
||||
msgid "created {ago}"
|
||||
msgstr "created {ago}"
|
||||
|
||||
#: src/components/playlist/PlaylistCreateForm.tsx:121
|
||||
msgid "Creating…"
|
||||
msgstr "Creating…"
|
||||
@@ -652,9 +693,9 @@ msgstr "Default tab"
|
||||
#: src/components/category/CategoryManager.tsx:253
|
||||
#: src/components/chat/ChatMessageItem.tsx:118
|
||||
#: 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:271
|
||||
#: src/components/form/DeleteButton.tsx:36
|
||||
#: src/components/shared/ConfirmModal.tsx:34
|
||||
msgid "Delete"
|
||||
@@ -679,14 +720,14 @@ msgstr "Delete dump"
|
||||
msgid "Delete failed ({0})"
|
||||
msgstr "Delete failed ({0})"
|
||||
|
||||
#: src/components/playlist/PlaylistCard.tsx:149
|
||||
#: src/components/playlist/PlaylistCard.tsx:172
|
||||
#: src/components/playlist/PlaylistEditForm.tsx:213
|
||||
#: src/components/profile/ProfilePlaylistsTab.tsx:201
|
||||
#: src/pages/PlaylistDetail.tsx:172
|
||||
#: src/components/profile/ProfilePlaylistsTab.tsx:224
|
||||
#: src/pages/PlaylistDetail.tsx:174
|
||||
msgid "Delete playlist"
|
||||
msgstr "Delete playlist"
|
||||
|
||||
#: src/components/comment/CommentNode.tsx:270
|
||||
#: src/components/comment/CommentNode.tsx:264
|
||||
msgid "Delete this comment?"
|
||||
msgstr "Delete this comment?"
|
||||
|
||||
@@ -694,12 +735,12 @@ msgstr "Delete this comment?"
|
||||
msgid "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?"
|
||||
msgstr "Delete this message?"
|
||||
|
||||
#: src/components/profile/ProfilePlaylistsTab.tsx:200
|
||||
#: src/pages/PlaylistDetail.tsx:171
|
||||
#: src/components/profile/ProfilePlaylistsTab.tsx:223
|
||||
#: src/pages/PlaylistDetail.tsx:173
|
||||
msgid "Delete this playlist? This cannot be undone."
|
||||
msgstr "Delete this playlist? This cannot be undone."
|
||||
|
||||
@@ -737,7 +778,7 @@ msgid "Done"
|
||||
msgstr "Done"
|
||||
|
||||
#. placeholder {0}: dump.fileName
|
||||
#: src/components/dump/FilePreview.tsx:226
|
||||
#: src/components/dump/FilePreview.tsx:233
|
||||
msgid "Download {0}"
|
||||
msgstr "Download {0}"
|
||||
|
||||
@@ -770,8 +811,8 @@ msgid "Dumped!"
|
||||
msgstr "Dumped!"
|
||||
|
||||
#: src/components/profile/ProfileDumpsTab.tsx:46
|
||||
#: src/pages/Search.tsx:174
|
||||
#: src/pages/UserPublicProfile.tsx:160
|
||||
#: src/pages/Search.tsx:211
|
||||
#: src/pages/UserPublicProfile.tsx:164
|
||||
msgid "Dumps"
|
||||
msgstr "Dumps"
|
||||
|
||||
@@ -787,9 +828,9 @@ msgstr "Earlier"
|
||||
#: src/components/chat/ChatMessageItem.tsx:107
|
||||
#: src/components/chat/ChatMessageItem.tsx:108
|
||||
#: src/components/chat/PendingItem.tsx:93
|
||||
#: src/components/comment/CommentNode.tsx:256
|
||||
#: src/components/playlist/PlaylistHeader.tsx:60
|
||||
#: src/pages/Dump.tsx:601
|
||||
#: src/components/comment/CommentNode.tsx:250
|
||||
#: src/components/playlist/PlaylistHeader.tsx:104
|
||||
#: src/pages/Dump.tsx:556
|
||||
msgid "Edit"
|
||||
msgstr "Edit"
|
||||
|
||||
@@ -798,16 +839,22 @@ msgstr "Edit"
|
||||
msgid "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"
|
||||
msgstr "Edit description"
|
||||
|
||||
#: src/components/profile/ProfileHeader.tsx:171
|
||||
#: src/components/profile/ProfileHeader.tsx:161
|
||||
msgid "Edit email"
|
||||
msgstr "Edit email"
|
||||
|
||||
#: src/pages/Dump.tsx:510
|
||||
msgid "Edit note"
|
||||
msgstr "Edit note"
|
||||
|
||||
#: src/components/dump/create/TitleField.tsx:69
|
||||
#: src/pages/Dump.tsx:511
|
||||
#: src/components/playlist/PlaylistHeader.tsx:84
|
||||
#: src/pages/Dump.tsx:459
|
||||
msgid "Edit title"
|
||||
msgstr "Edit title"
|
||||
|
||||
@@ -819,9 +866,9 @@ msgstr "edited"
|
||||
#. placeholder {0}: relativeTime(dump.updatedAt)
|
||||
#. placeholder {0}: relativeTime(message.updatedAt)
|
||||
#: src/components/chat/ChatMessageItem.tsx:132
|
||||
#: src/components/comment/CommentNode.tsx:205
|
||||
#: src/components/playlist/PlaylistHeader.tsx:95
|
||||
#: src/pages/Dump.tsx:554
|
||||
#: src/components/comment/CommentNode.tsx:202
|
||||
#: src/components/playlist/PlaylistHeader.tsx:158
|
||||
#: src/pages/Dump.tsx:492
|
||||
msgid "edited {0}"
|
||||
msgstr "edited {0}"
|
||||
|
||||
@@ -829,9 +876,9 @@ msgstr "edited {0}"
|
||||
#. placeholder {0}: dump.updatedAt.toLocaleString()
|
||||
#. placeholder {0}: message.updatedAt.toLocaleString()
|
||||
#: src/components/chat/ChatMessageItem.tsx:128
|
||||
#: src/components/comment/CommentNode.tsx:203
|
||||
#: src/components/playlist/PlaylistHeader.tsx:93
|
||||
#: src/pages/Dump.tsx:552
|
||||
#: src/components/comment/CommentNode.tsx:200
|
||||
#: src/components/playlist/PlaylistHeader.tsx:156
|
||||
#: src/pages/Dump.tsx:490
|
||||
msgid "Edited {0}"
|
||||
msgstr "Edited {0}"
|
||||
|
||||
@@ -852,11 +899,11 @@ msgstr "Email"
|
||||
msgid "Email address"
|
||||
msgstr "Email address"
|
||||
|
||||
#: src/pages/Search.tsx:230
|
||||
#: src/pages/Search.tsx:267
|
||||
msgid "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"
|
||||
msgstr "Expand player"
|
||||
|
||||
@@ -890,15 +937,13 @@ msgstr "Failed to post"
|
||||
msgid "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"
|
||||
msgstr "Failed to post reply"
|
||||
|
||||
#: src/components/federation/GrantList.tsx:98
|
||||
#: src/components/federation/PeerManager.tsx:158
|
||||
#: src/components/playlist/PlaylistEditForm.tsx:207
|
||||
#: src/components/profile/DescriptionEditor.tsx:99
|
||||
#: src/components/profile/EmailEditor.tsx:86
|
||||
msgid "Failed to save"
|
||||
msgstr "Failed to save"
|
||||
|
||||
@@ -906,11 +951,7 @@ msgstr "Failed to save"
|
||||
msgid "Failed to save category"
|
||||
msgstr "Failed to save category"
|
||||
|
||||
#: src/components/comment/CommentNode.tsx:218
|
||||
msgid "Failed to save edit"
|
||||
msgstr "Failed to save edit"
|
||||
|
||||
#: src/components/profile/ProfileHeader.tsx:182
|
||||
#: src/components/profile/ProfileHeader.tsx:174
|
||||
msgid "Failed to update avatar"
|
||||
msgstr "Failed to update avatar"
|
||||
|
||||
@@ -952,9 +993,9 @@ msgid "Follow some users to see their dumps here."
|
||||
msgstr "Follow some users to see their dumps here."
|
||||
|
||||
#: src/components/feed/FeedTabBar.tsx:61
|
||||
#: src/components/profile/ProfileFollowTab.tsx:144
|
||||
#: src/components/profile/ProfileFollowTab.tsx:143
|
||||
#: src/pages/Settings.tsx:57
|
||||
#: src/pages/UserPublicProfile.tsx:163
|
||||
#: src/pages/UserPublicProfile.tsx:167
|
||||
msgid "Followed"
|
||||
msgstr "Followed"
|
||||
|
||||
@@ -1025,7 +1066,7 @@ msgstr "If you didn't ask for this, ignore this email. Your password stays the s
|
||||
msgid "image"
|
||||
msgstr "image"
|
||||
|
||||
#: src/pages/Dump.tsx:621
|
||||
#: src/pages/Dump.tsx:576
|
||||
msgid "In collections"
|
||||
msgstr "In collections"
|
||||
|
||||
@@ -1056,7 +1097,7 @@ msgstr "Invalid link"
|
||||
msgid "Invite link"
|
||||
msgstr "Invite link"
|
||||
|
||||
#: src/components/profile/ProfileHeader.tsx:126
|
||||
#: src/components/profile/ProfileHeader.tsx:133
|
||||
msgid "invited by"
|
||||
msgstr "invited by"
|
||||
|
||||
@@ -1071,7 +1112,7 @@ msgid "digest.invitedByYou"
|
||||
msgstr "invited by you"
|
||||
|
||||
#: src/components/profile/ProfileInviteesTab.tsx:77
|
||||
#: src/pages/UserPublicProfile.tsx:164
|
||||
#: src/pages/UserPublicProfile.tsx:168
|
||||
msgid "Invitees"
|
||||
msgstr "Invitees"
|
||||
|
||||
@@ -1110,11 +1151,11 @@ msgstr "Link"
|
||||
msgid "Link or file"
|
||||
msgstr "Link or file"
|
||||
|
||||
#: src/contexts/ws/WSProvider.tsx:764
|
||||
#: src/contexts/ws/WSProvider.tsx:769
|
||||
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||
msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||
|
||||
#: src/components/layout/AppHeader.tsx:234
|
||||
#: src/components/layout/AppHeader.tsx:236
|
||||
msgid "Live updates unavailable."
|
||||
msgstr "Live updates unavailable."
|
||||
|
||||
@@ -1126,7 +1167,7 @@ msgstr "Load more"
|
||||
msgid "Load older messages"
|
||||
msgstr "Load older messages"
|
||||
|
||||
#: src/pages/Dump.tsx:337
|
||||
#: src/pages/Dump.tsx:358
|
||||
#: src/pages/DumpEdit.tsx:210
|
||||
msgid "Loading dump…"
|
||||
msgstr "Loading dump…"
|
||||
@@ -1139,11 +1180,11 @@ msgstr "Loading messages…"
|
||||
msgid "Loading more…"
|
||||
msgstr "Loading more…"
|
||||
|
||||
#: src/pages/PlaylistDetail.tsx:82
|
||||
#: src/pages/PlaylistDetail.tsx:83
|
||||
msgid "Loading playlist…"
|
||||
msgstr "Loading playlist…"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:108
|
||||
#: src/pages/UserPublicProfile.tsx:112
|
||||
msgid "Loading profile…"
|
||||
msgstr "Loading profile…"
|
||||
|
||||
@@ -1180,8 +1221,8 @@ msgstr "Log in to vote"
|
||||
msgid "Log in with {0}"
|
||||
msgstr "Log in with {0}"
|
||||
|
||||
#: src/components/layout/UserMenu.tsx:139
|
||||
#: src/pages/UserPublicProfile.tsx:133
|
||||
#: src/components/layout/UserMenu.tsx:132
|
||||
#: src/pages/UserPublicProfile.tsx:137
|
||||
msgid "Log out"
|
||||
msgstr "Log out"
|
||||
|
||||
@@ -1198,10 +1239,10 @@ msgstr "Login failed"
|
||||
msgid "Lowercase letters, numbers and single hyphens only"
|
||||
msgstr "Lowercase letters, numbers and single hyphens only"
|
||||
|
||||
#: src/components/layout/UserMenu.tsx:126
|
||||
#: src/components/layout/UserMenu.tsx:119
|
||||
#: src/pages/Manage.tsx:32
|
||||
#: src/pages/Manage.tsx:40
|
||||
#: src/pages/UserPublicProfile.tsx:166
|
||||
#: src/pages/UserPublicProfile.tsx:170
|
||||
msgid "Manage"
|
||||
msgstr "Manage"
|
||||
|
||||
@@ -1209,6 +1250,10 @@ msgstr "Manage"
|
||||
msgid "Max {limit}"
|
||||
msgstr "Max {limit}"
|
||||
|
||||
#: src/components/playlist/PlaylistSortTabs.tsx:34
|
||||
msgid "Mine"
|
||||
msgstr "Mine"
|
||||
|
||||
#: src/components/profile/RoleSwitcher.tsx:23
|
||||
msgid "Moderator"
|
||||
msgstr "Moderator"
|
||||
@@ -1218,6 +1263,10 @@ msgstr "Moderator"
|
||||
msgid "digest.monthly"
|
||||
msgstr "Monthly recap"
|
||||
|
||||
#: src/components/playlist/PlaylistSortTabs.tsx:33
|
||||
msgid "Most dumps"
|
||||
msgstr "Most dumps"
|
||||
|
||||
#: src/components/player/MediaPlayer.tsx:299
|
||||
msgid "Mute"
|
||||
msgstr "Mute"
|
||||
@@ -1229,6 +1278,7 @@ msgstr "Name"
|
||||
|
||||
#: src/components/chat/ChatModal.tsx:692
|
||||
#: src/components/feed/FeedTabBar.tsx:58
|
||||
#: src/components/playlist/PlaylistSortTabs.tsx:32
|
||||
#: src/pages/Settings.tsx:55
|
||||
msgid "New"
|
||||
msgstr "New"
|
||||
@@ -1264,7 +1314,7 @@ msgstr "New public playlists"
|
||||
msgid "Next →"
|
||||
msgstr "Next →"
|
||||
|
||||
#: src/components/player/GlobalPlayer.tsx:244
|
||||
#: src/components/player/GlobalPlayer.tsx:246
|
||||
msgid "Next track"
|
||||
msgstr "Next track"
|
||||
|
||||
@@ -1272,7 +1322,7 @@ msgstr "Next track"
|
||||
msgid "No dumps in this playlist yet."
|
||||
msgstr "No dumps in this playlist yet."
|
||||
|
||||
#: src/pages/Search.tsx:246
|
||||
#: src/pages/Search.tsx:283
|
||||
msgid "No dumps match \"{q}\"."
|
||||
msgstr "No dumps match \"{q}\"."
|
||||
|
||||
@@ -1285,7 +1335,7 @@ msgstr "No dumps yet. Be the first!"
|
||||
msgid "No emoji found."
|
||||
msgstr "No emoji found."
|
||||
|
||||
#: src/components/profile/ProfileFollowTab.tsx:117
|
||||
#: src/components/profile/ProfileFollowTab.tsx:116
|
||||
msgid "No followed playlists yet."
|
||||
msgstr "No followed playlists yet."
|
||||
|
||||
@@ -1301,21 +1351,21 @@ msgstr "No messages yet. Say hello!"
|
||||
msgid "No one yet."
|
||||
msgstr "No one yet."
|
||||
|
||||
#: src/pages/Search.tsx:287
|
||||
#: src/pages/Search.tsx:330
|
||||
msgid "No playlists match \"{q}\"."
|
||||
msgstr "No playlists match \"{q}\"."
|
||||
|
||||
#: src/components/playlist/PlaylistMembershipPanel.tsx:51
|
||||
#: src/components/profile/ProfilePlaylistsTab.tsx:178
|
||||
#: src/pages/Playlists.tsx:126
|
||||
#: src/components/profile/ProfilePlaylistsTab.tsx:201
|
||||
#: src/pages/Playlists.tsx:180
|
||||
msgid "No playlists yet."
|
||||
msgstr "No playlists yet."
|
||||
|
||||
#: src/contexts/ws/WSProvider.tsx:934
|
||||
#: src/contexts/ws/WSProvider.tsx:939
|
||||
msgid "No reply from the server — this message may not have been sent"
|
||||
msgstr "No reply from the server — this message may not have been sent"
|
||||
|
||||
#: src/pages/Search.tsx:261
|
||||
#: src/pages/Search.tsx:298
|
||||
msgid "No users match \"{q}\"."
|
||||
msgstr "No users match \"{q}\"."
|
||||
|
||||
@@ -1379,11 +1429,11 @@ msgid "email.welcome.button"
|
||||
msgstr "Open {site}"
|
||||
|
||||
#. placeholder {0}: richContent.siteName
|
||||
#: src/components/dump/RichContentCard.tsx:122
|
||||
#: src/components/dump/RichContentCard.tsx:124
|
||||
msgid "Open on {0}"
|
||||
msgstr "Open on {0}"
|
||||
|
||||
#: src/components/dump/RichContentCard.tsx:123
|
||||
#: src/components/dump/RichContentCard.tsx:125
|
||||
msgid "Open source"
|
||||
msgstr "Open source"
|
||||
|
||||
@@ -1408,7 +1458,7 @@ msgstr "Or log in with your account on another gerbeur"
|
||||
msgid "Other instances"
|
||||
msgstr "Other instances"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:159
|
||||
#: src/pages/UserPublicProfile.tsx:163
|
||||
msgid "Overview"
|
||||
msgstr "Overview"
|
||||
|
||||
@@ -1444,9 +1494,9 @@ msgstr "Passwords do not match"
|
||||
msgid "Paste a link…"
|
||||
msgstr "Paste a link…"
|
||||
|
||||
#: src/components/dump/FilePreview.tsx:70
|
||||
#: src/components/dump/FilePreview.tsx:184
|
||||
#: src/components/dump/RichContentCard.tsx:103
|
||||
#: src/components/dump/FilePreview.tsx:72
|
||||
#: src/components/dump/FilePreview.tsx:191
|
||||
#: src/components/dump/RichContentCard.tsx:105
|
||||
#: src/components/player/MediaPlayer.tsx:276
|
||||
msgid "Pause"
|
||||
msgstr "Pause"
|
||||
@@ -1455,10 +1505,10 @@ msgstr "Pause"
|
||||
msgid "Picked up where you left off."
|
||||
msgstr "Picked up where you left off."
|
||||
|
||||
#: src/components/dump/FilePreview.tsx:70
|
||||
#: src/components/dump/FilePreview.tsx:184
|
||||
#: src/components/dump/RichContentCard.tsx:103
|
||||
#: src/components/dump/RichContentCard.tsx:150
|
||||
#: src/components/dump/FilePreview.tsx:72
|
||||
#: src/components/dump/FilePreview.tsx:191
|
||||
#: src/components/dump/RichContentCard.tsx:105
|
||||
#: src/components/dump/RichContentCard.tsx:153
|
||||
#: src/components/player/MediaPlayer.tsx:276
|
||||
msgid "Play"
|
||||
msgstr "Play"
|
||||
@@ -1468,14 +1518,14 @@ msgid "Playlist categories"
|
||||
msgstr "Playlist categories"
|
||||
|
||||
#: src/components/dump/create/StepIndicator.tsx:21
|
||||
#: src/components/layout/AppHeader.tsx:172
|
||||
#: src/components/layout/UserMenu.tsx:92
|
||||
#: src/components/profile/ProfileFollowTab.tsx:148
|
||||
#: src/components/profile/ProfilePlaylistsTab.tsx:149
|
||||
#: src/pages/Playlists.tsx:53
|
||||
#: src/pages/Playlists.tsx:114
|
||||
#: src/pages/Search.tsx:177
|
||||
#: src/pages/UserPublicProfile.tsx:162
|
||||
#: src/components/layout/AppHeader.tsx:174
|
||||
#: src/components/layout/UserMenu.tsx:93
|
||||
#: src/components/profile/ProfileFollowTab.tsx:147
|
||||
#: src/components/profile/ProfilePlaylistsTab.tsx:162
|
||||
#: src/pages/Playlists.tsx:74
|
||||
#: src/pages/Playlists.tsx:160
|
||||
#: src/pages/Search.tsx:214
|
||||
#: src/pages/UserPublicProfile.tsx:166
|
||||
msgid "Playlists"
|
||||
msgstr "Playlists"
|
||||
|
||||
@@ -1483,26 +1533,26 @@ msgstr "Playlists"
|
||||
msgid "Post comment"
|
||||
msgstr "Post comment"
|
||||
|
||||
#: src/components/comment/CommentNode.tsx:289
|
||||
#: src/components/comment/CommentNode.tsx:283
|
||||
msgid "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/dump/create/DumpCreateModal.tsx:752
|
||||
msgid "Posting…"
|
||||
msgstr "Posting…"
|
||||
|
||||
#: src/components/player/GlobalPlayer.tsx:228
|
||||
#: src/components/player/GlobalPlayer.tsx:230
|
||||
msgid "Previous track"
|
||||
msgstr "Previous track"
|
||||
|
||||
#: src/components/dump/DumpCard.tsx:144
|
||||
#: src/components/dump/JournalCard.tsx:145
|
||||
#: src/components/playlist/PlaylistCard.tsx:106
|
||||
#: src/components/playlist/PlaylistHeader.tsx:77
|
||||
#: src/components/dump/JournalCard.tsx:146
|
||||
#: src/components/playlist/PlaylistCard.tsx:127
|
||||
#: src/components/playlist/PlaylistHeader.tsx:140
|
||||
#: src/components/playlist/PlaylistMembershipPanel.tsx:81
|
||||
#: src/pages/Dump.tsx:560
|
||||
#: src/pages/Dump.tsx:498
|
||||
msgid "private"
|
||||
msgstr "private"
|
||||
|
||||
@@ -1510,8 +1560,8 @@ msgstr "private"
|
||||
msgid "Private"
|
||||
msgstr "Private"
|
||||
|
||||
#: src/components/playlist/PlaylistCard.tsx:105
|
||||
#: src/components/playlist/PlaylistHeader.tsx:77
|
||||
#: src/components/playlist/PlaylistCard.tsx:126
|
||||
#: src/components/playlist/PlaylistHeader.tsx:140
|
||||
msgid "public"
|
||||
msgstr "public"
|
||||
|
||||
@@ -1523,7 +1573,7 @@ msgstr "Public"
|
||||
msgid "Refresh metadata"
|
||||
msgstr "Refresh metadata"
|
||||
|
||||
#: src/components/player/GlobalPlayer.tsx:324
|
||||
#: src/components/player/GlobalPlayer.tsx:326
|
||||
msgid "Refreshing stream…"
|
||||
msgstr "Refreshing stream…"
|
||||
|
||||
@@ -1545,7 +1595,7 @@ msgstr "Registering…"
|
||||
msgid "Registration failed"
|
||||
msgstr "Registration failed"
|
||||
|
||||
#: src/pages/Dump.tsx:612
|
||||
#: src/pages/Dump.tsx:567
|
||||
msgid "Related"
|
||||
msgstr "Related"
|
||||
|
||||
@@ -1585,7 +1635,7 @@ msgstr "Replace file"
|
||||
|
||||
#: src/components/chat/ChatMessageItem.tsx:97
|
||||
#: src/components/chat/ChatMessageItem.tsx:98
|
||||
#: src/components/comment/CommentNode.tsx:244
|
||||
#: src/components/comment/CommentNode.tsx:240
|
||||
msgid "Reply"
|
||||
msgstr "Reply"
|
||||
|
||||
@@ -1632,7 +1682,7 @@ msgid "email.reset.title"
|
||||
msgstr "Reset your password"
|
||||
|
||||
#: src/components/chat/PendingItem.tsx:86
|
||||
#: src/pages/Dump.tsx:371
|
||||
#: src/pages/Dump.tsx:392
|
||||
#: src/pages/DumpEdit.tsx:227
|
||||
msgid "Retry"
|
||||
msgstr "Retry"
|
||||
@@ -1642,29 +1692,22 @@ msgid "Role"
|
||||
msgstr "Role"
|
||||
|
||||
#: src/components/category/CategoryManager.tsx:232
|
||||
#: src/components/chat/ChatEditBox.tsx:85
|
||||
#: src/components/comment/CommentNode.tsx:216
|
||||
#: src/components/form/InlineEdit.tsx:310
|
||||
#: src/components/playlist/PlaylistEditForm.tsx:229
|
||||
#: src/components/profile/DescriptionEditor.tsx:89
|
||||
#: src/components/profile/EmailEditor.tsx:75
|
||||
#: src/pages/Dump.tsx:486
|
||||
#: src/pages/DumpEdit.tsx:564
|
||||
msgid "Save"
|
||||
msgstr "Save"
|
||||
|
||||
#: src/components/comment/CommentNode.tsx:217
|
||||
#: src/components/form/InlineEdit.tsx:307
|
||||
#: src/components/playlist/PlaylistEditForm.tsx:226
|
||||
#: src/components/profile/ChangePasswordModal.tsx:119
|
||||
#: src/components/profile/DescriptionEditor.tsx:86
|
||||
#: src/components/profile/EmailEditor.tsx:72
|
||||
#: src/pages/Dump.tsx:485
|
||||
#: src/pages/ResetPassword.tsx:191
|
||||
msgid "Saving…"
|
||||
msgstr "Saving…"
|
||||
|
||||
#: src/components/layout/AppHeader.tsx:182
|
||||
#: src/components/layout/SearchBar.tsx:83
|
||||
#: src/pages/Search.tsx:90
|
||||
#: src/pages/Search.tsx:106
|
||||
msgid "Search"
|
||||
msgstr "Search"
|
||||
|
||||
@@ -1680,11 +1723,11 @@ msgstr "Search emoji"
|
||||
msgid "Search emoji…"
|
||||
msgstr "Search emoji…"
|
||||
|
||||
#: src/pages/Search.tsx:241
|
||||
#: src/pages/Search.tsx:278
|
||||
msgid "Search failed"
|
||||
msgstr "Search failed"
|
||||
|
||||
#: src/pages/Search.tsx:236
|
||||
#: src/pages/Search.tsx:273
|
||||
msgid "Searching…"
|
||||
msgstr "Searching…"
|
||||
|
||||
@@ -1724,7 +1767,7 @@ msgstr "Set new password"
|
||||
msgid "email.settings"
|
||||
msgstr "Settings"
|
||||
|
||||
#: src/components/layout/UserMenu.tsx:116
|
||||
#: src/components/layout/UserMenu.tsx:109
|
||||
#: src/pages/Settings.tsx:75
|
||||
#: src/pages/Settings.tsx:88
|
||||
msgid "Settings"
|
||||
@@ -1766,8 +1809,8 @@ msgstr "Someone asked to reset the password of your {site} account. The button b
|
||||
|
||||
#: src/components/form/FormError.tsx:31
|
||||
#: src/components/layout/PageError.tsx:29
|
||||
#: src/contexts/ws/WSProvider.tsx:733
|
||||
#: src/pages/PlaylistDetail.tsx:155
|
||||
#: src/contexts/ws/WSProvider.tsx:738
|
||||
#: src/pages/PlaylistDetail.tsx:157
|
||||
msgid "Something went wrong"
|
||||
msgstr "Something went wrong"
|
||||
|
||||
@@ -1827,11 +1870,11 @@ msgstr "The month on {site}, {range}"
|
||||
msgid "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."
|
||||
msgstr "This comment couldn't be deleted. Try again."
|
||||
|
||||
#: src/pages/Dump.tsx:346
|
||||
#: src/pages/Dump.tsx:367
|
||||
msgid "This dump was deleted, or made private."
|
||||
msgstr "This dump was deleted, or made private."
|
||||
|
||||
@@ -1902,7 +1945,7 @@ msgid "digest.topWeek"
|
||||
msgstr "Top this week"
|
||||
|
||||
#. placeholder {0}: i + 1
|
||||
#: src/components/player/GlobalPlayer.tsx:338
|
||||
#: src/components/player/GlobalPlayer.tsx:340
|
||||
msgid "Track {0}"
|
||||
msgstr "Track {0}"
|
||||
|
||||
@@ -1972,12 +2015,20 @@ msgstr "Unsubscribe"
|
||||
msgid "Unsubscribed"
|
||||
msgstr "Unsubscribed"
|
||||
|
||||
#: src/components/playlist/PlaylistSortTabs.tsx:31
|
||||
msgid "Updated"
|
||||
msgstr "Updated"
|
||||
|
||||
#: src/components/playlist/PlaylistCard.tsx:151
|
||||
msgid "updated {ago}"
|
||||
msgstr "updated {ago}"
|
||||
|
||||
#. js-lingui-explicit-id
|
||||
#: api/mail/digest.ts:237
|
||||
msgid "email.digest.upload"
|
||||
msgstr "upload"
|
||||
|
||||
#: src/components/profile/ProfileHeader.tsx:79
|
||||
#: src/components/profile/ProfileHeader.tsx:80
|
||||
msgid "Upload failed"
|
||||
msgstr "Upload failed"
|
||||
|
||||
@@ -1999,7 +2050,7 @@ msgid "Upvote"
|
||||
msgstr "Upvote"
|
||||
|
||||
#: src/components/profile/ProfileDumpsTab.tsx:46
|
||||
#: src/pages/UserPublicProfile.tsx:161
|
||||
#: src/pages/UserPublicProfile.tsx:165
|
||||
msgid "Upvoted"
|
||||
msgstr "Upvoted"
|
||||
|
||||
@@ -2028,7 +2079,7 @@ msgstr "User"
|
||||
msgid "User management"
|
||||
msgstr "User management"
|
||||
|
||||
#: src/components/layout/UserMenu.tsx:67
|
||||
#: src/components/layout/UserMenu.tsx:68
|
||||
msgid "User menu"
|
||||
msgstr "User menu"
|
||||
|
||||
@@ -2040,8 +2091,8 @@ msgstr "User menu"
|
||||
msgid "Username"
|
||||
msgstr "Username"
|
||||
|
||||
#: src/components/profile/ProfileFollowTab.tsx:147
|
||||
#: src/pages/Search.tsx:176
|
||||
#: src/components/profile/ProfileFollowTab.tsx:146
|
||||
#: src/pages/Search.tsx:213
|
||||
msgid "Users"
|
||||
msgstr "Users"
|
||||
|
||||
@@ -2080,12 +2131,14 @@ msgid "email.welcome.subject"
|
||||
msgstr "Welcome to {site}"
|
||||
|
||||
#: src/components/dump/create/DumpCreateModal.tsx:705
|
||||
#: src/pages/Dump.tsx:514
|
||||
#: src/pages/Dump.tsx:521
|
||||
#: src/pages/DumpEdit.tsx:539
|
||||
msgid "What makes it worth it?"
|
||||
msgstr "What makes it worth it?"
|
||||
|
||||
#: src/components/profile/DescriptionEditor.tsx:78
|
||||
#: src/components/profile/ProfileDescription.tsx:79
|
||||
#: src/components/profile/ProfileDescription.tsx:62
|
||||
#: src/components/profile/ProfileDescription.tsx:72
|
||||
msgid "Who am I?"
|
||||
msgstr "Who am I?"
|
||||
|
||||
@@ -2102,7 +2155,7 @@ msgstr "Why & where"
|
||||
msgid "Why?"
|
||||
msgstr "Why?"
|
||||
|
||||
#: src/components/comment/CommentNode.tsx:293
|
||||
#: src/components/comment/CommentNode.tsx:287
|
||||
msgid "Write a reply…"
|
||||
msgstr "Write a reply…"
|
||||
|
||||
@@ -2151,7 +2204,7 @@ msgstr "You're getting this email because a password reset was requested for thi
|
||||
msgid "email.welcome.reason"
|
||||
msgstr "You're getting this email because an account was created on {site} with this address."
|
||||
|
||||
#: src/contexts/ws/WSProvider.tsx:915
|
||||
#: src/contexts/ws/WSProvider.tsx:920
|
||||
msgid "You're offline — not sent"
|
||||
msgstr "You're offline — not sent"
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
337
locales/fr.po
337
locales/fr.po
@@ -13,18 +13,18 @@ msgstr ""
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
|
||||
#: src/components/comment/CommentNode.tsx:140
|
||||
#: src/components/comment/CommentNode.tsx:137
|
||||
msgid "[deleted]"
|
||||
msgstr "[supprimé]"
|
||||
|
||||
#. placeholder {0}: dump.commentCount
|
||||
#: 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}}"
|
||||
msgstr "{0, plural, one {# commentaire} other {# commentaires}}"
|
||||
|
||||
#. placeholder {0}: playlist.dumpCount
|
||||
#: src/components/playlist/PlaylistCard.tsx:120
|
||||
#: src/components/playlist/PlaylistCard.tsx:141
|
||||
msgid "{0, plural, one {# dump} other {# dumps}}"
|
||||
msgstr "{0, plural, one {# reco} other {# recos}}"
|
||||
|
||||
@@ -60,7 +60,7 @@ msgstr "{0}–{1} caractères : lettres, chiffres ou tirets bas"
|
||||
msgid "email.digest.totals"
|
||||
msgstr "{dumps, plural, one {# reco} other {# recos}}, {upvotes, plural, one {# vote} other {# votes}} et {comments, plural, one {# commentaire} other {# commentaires}}."
|
||||
|
||||
#: src/pages/Search.tsx:178
|
||||
#: src/pages/Search.tsx:215
|
||||
msgid "{label} ({count})"
|
||||
msgstr "{label} ({count})"
|
||||
|
||||
@@ -206,14 +206,14 @@ msgstr "# Bienvenue sur {site} !\n"
|
||||
"Votre compte a bien été créé. Bienvenue à bord !"
|
||||
|
||||
#: src/components/dump/create/DumpCreateModal.tsx:736
|
||||
#: src/pages/PlaylistDetail.tsx:98
|
||||
#: src/pages/UserPublicProfile.tsx:125
|
||||
#: src/pages/PlaylistDetail.tsx:99
|
||||
#: src/pages/UserPublicProfile.tsx:129
|
||||
msgid "← Back"
|
||||
msgstr "← Retour"
|
||||
|
||||
#: src/pages/Dump.tsx:353
|
||||
#: src/pages/Dump.tsx:378
|
||||
#: src/pages/Dump.tsx:605
|
||||
#: src/pages/Dump.tsx:374
|
||||
#: src/pages/Dump.tsx:399
|
||||
#: src/pages/Dump.tsx:560
|
||||
#: src/pages/DumpEdit.tsx:234
|
||||
msgid "← Back to all dumps"
|
||||
msgstr "← Retour à toutes les recos"
|
||||
@@ -226,22 +226,42 @@ msgstr "+ Inviter quelqu'un"
|
||||
msgid "+ New playlist"
|
||||
msgstr "+ Nouvelle collection"
|
||||
|
||||
#: src/pages/Dump.tsx:452
|
||||
#: src/pages/Dump.tsx:451
|
||||
msgid "+ Playlist"
|
||||
msgstr "+ Collection"
|
||||
|
||||
#. placeholder {0}: d.commenterUsername
|
||||
#. placeholder {0}: event.username
|
||||
#. placeholder {1}: d.dumpTitle
|
||||
#. placeholder {1}: event.dump.title
|
||||
#: src/components/social/CommunityFeed.tsx:114
|
||||
#: src/pages/Notifications.tsx:239
|
||||
msgid "<0>{0}</0> commented on <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 {1}: d.playlistTitle
|
||||
#: src/pages/Notifications.tsx:199
|
||||
msgid "<0>{0}</0> followed your playlist <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 {1}: d.dumpTitle
|
||||
#: 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>"
|
||||
|
||||
#. placeholder {0}: d.dumperUsername
|
||||
#. placeholder {0}: event.username
|
||||
#. placeholder {1}: d.dumpTitle
|
||||
#. placeholder {1}: event.dump.title
|
||||
#: src/components/social/CommunityFeed.tsx:94
|
||||
#: src/pages/Notifications.tsx:209
|
||||
msgid "<0>{0}</0> posted <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"
|
||||
|
||||
#. placeholder {0}: d.voterUsername
|
||||
#. placeholder {0}: event.username
|
||||
#. placeholder {1}: d.dumpTitle
|
||||
#. placeholder {1}: event.dump.title
|
||||
#: src/components/social/CommunityFeed.tsx:104
|
||||
#: src/pages/Notifications.tsx:229
|
||||
msgid "<0>{0}</0> upvoted <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."
|
||||
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
|
||||
msgid "1 new message"
|
||||
msgstr "1 nouveau message"
|
||||
@@ -305,7 +344,7 @@ msgstr "une publication"
|
||||
msgid "Account"
|
||||
msgstr "Compte"
|
||||
|
||||
#: src/components/profile/ProfileHeader.tsx:144
|
||||
#: src/components/profile/ProfileHeader.tsx:151
|
||||
msgid "Account on <0>{host}</0>"
|
||||
msgstr "Compte sur <0>{host}</0>"
|
||||
|
||||
@@ -313,12 +352,17 @@ msgstr "Compte sur <0>{host}</0>"
|
||||
msgid "Add a comment…"
|
||||
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:116
|
||||
msgid "Add category"
|
||||
msgstr "Ajouter une catégorie"
|
||||
|
||||
#: src/components/profile/ProfileHeader.tsx:173
|
||||
#: src/components/profile/ProfileHeader.tsx:168
|
||||
msgid "Add email…"
|
||||
msgstr "Ajouter un e-mail…"
|
||||
|
||||
@@ -345,10 +389,6 @@ msgstr "Administrateur"
|
||||
msgid "All"
|
||||
msgstr "Tout"
|
||||
|
||||
#: src/components/layout/UserMenu.tsx:108
|
||||
msgid "All playlists"
|
||||
msgstr "Toutes les collections"
|
||||
|
||||
#. placeholder {0}: first.ownerUsername
|
||||
#. placeholder {1}: relativeTime(first.createdAt)
|
||||
#: src/components/dump/create/DuplicateNotice.tsx:39
|
||||
@@ -419,8 +459,8 @@ msgstr "par @{username}"
|
||||
msgid "email.digest.playlistMeta"
|
||||
msgstr "par @{username} · {n, plural, one {# reco} other {# recos}}"
|
||||
|
||||
#: src/contexts/ws/WSProvider.tsx:376
|
||||
#: src/contexts/ws/WSProvider.tsx:765
|
||||
#: src/contexts/ws/WSProvider.tsx:377
|
||||
#: src/contexts/ws/WSProvider.tsx:770
|
||||
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
|
||||
msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion."
|
||||
|
||||
@@ -431,16 +471,13 @@ msgstr "Connexion impossible"
|
||||
|
||||
#: src/components/category/CategoryManager.tsx:241
|
||||
#: src/components/category/CategoryManager.tsx:242
|
||||
#: src/components/chat/ChatEditBox.tsx:92
|
||||
#: src/components/comment/CommentForm.tsx:127
|
||||
#: src/components/comment/CommentForm.tsx:125
|
||||
#: src/components/dump/create/DumpCreateModal.tsx:746
|
||||
#: src/components/form/FormActions.tsx:42
|
||||
#: src/components/form/InlineEdit.tsx:318
|
||||
#: 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/pages/Authorize.tsx:158
|
||||
#: src/pages/Dump.tsx:494
|
||||
#: src/pages/DumpEdit.tsx:561
|
||||
msgid "Cancel"
|
||||
msgstr "Annuler"
|
||||
@@ -466,7 +503,7 @@ msgstr "Catégories"
|
||||
msgid "Category"
|
||||
msgstr "Catégorie"
|
||||
|
||||
#: src/components/profile/ProfileHeader.tsx:102
|
||||
#: src/components/profile/ProfileHeader.tsx:109
|
||||
msgid "Change avatar"
|
||||
msgstr "Changer l'avatar"
|
||||
|
||||
@@ -525,7 +562,7 @@ msgstr "Fermer quand même"
|
||||
msgid "Close emoji picker"
|
||||
msgstr "Fermer le sélecteur d'emoji"
|
||||
|
||||
#: src/components/player/GlobalPlayer.tsx:262
|
||||
#: src/components/player/GlobalPlayer.tsx:264
|
||||
msgid "Close player"
|
||||
msgstr "Fermer le lecteur"
|
||||
|
||||
@@ -533,7 +570,7 @@ msgstr "Fermer le lecteur"
|
||||
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é."
|
||||
|
||||
#: src/components/player/GlobalPlayer.tsx:254
|
||||
#: src/components/player/GlobalPlayer.tsx:256
|
||||
msgid "Collapse player"
|
||||
msgstr "Réduire le lecteur"
|
||||
|
||||
@@ -555,7 +592,7 @@ msgstr "Confirmer le nouveau mot de passe"
|
||||
msgid "Connecting…"
|
||||
msgstr "Connexion…"
|
||||
|
||||
#: src/contexts/ws/WSProvider.tsx:758
|
||||
#: src/contexts/ws/WSProvider.tsx:763
|
||||
msgid "Connection lost — this message may not have been sent"
|
||||
msgstr "Connexion perdue — ce message n'a peut-être pas été envoyé"
|
||||
|
||||
@@ -583,7 +620,7 @@ msgstr "Impossible de changer le mot de passe"
|
||||
msgid "Could not connect to the server. Check your connection and try again."
|
||||
msgstr "Impossible de joindre le serveur. Vérifiez votre connexion et réessayez."
|
||||
|
||||
#: src/components/profile/ProfilePlaylistsTab.tsx:171
|
||||
#: src/components/profile/ProfilePlaylistsTab.tsx:194
|
||||
#: src/pages/DumpEdit.tsx:555
|
||||
msgid "Could not delete"
|
||||
msgstr "Suppression impossible"
|
||||
@@ -632,6 +669,10 @@ msgstr "Créer"
|
||||
msgid "Create & Add"
|
||||
msgstr "Créer et ajouter"
|
||||
|
||||
#: src/components/playlist/PlaylistCard.tsx:152
|
||||
msgid "created {ago}"
|
||||
msgstr "créée {ago}"
|
||||
|
||||
#: src/components/playlist/PlaylistCreateForm.tsx:121
|
||||
msgid "Creating…"
|
||||
msgstr "Création…"
|
||||
@@ -652,9 +693,9 @@ msgstr "Onglet par défaut"
|
||||
#: src/components/category/CategoryManager.tsx:253
|
||||
#: src/components/chat/ChatMessageItem.tsx:118
|
||||
#: 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:271
|
||||
#: src/components/form/DeleteButton.tsx:36
|
||||
#: src/components/shared/ConfirmModal.tsx:34
|
||||
msgid "Delete"
|
||||
@@ -679,14 +720,14 @@ msgstr "Supprimer la reco"
|
||||
msgid "Delete failed ({0})"
|
||||
msgstr "Échec de la suppression ({0})"
|
||||
|
||||
#: src/components/playlist/PlaylistCard.tsx:149
|
||||
#: src/components/playlist/PlaylistCard.tsx:172
|
||||
#: src/components/playlist/PlaylistEditForm.tsx:213
|
||||
#: src/components/profile/ProfilePlaylistsTab.tsx:201
|
||||
#: src/pages/PlaylistDetail.tsx:172
|
||||
#: src/components/profile/ProfilePlaylistsTab.tsx:224
|
||||
#: src/pages/PlaylistDetail.tsx:174
|
||||
msgid "Delete playlist"
|
||||
msgstr "Supprimer la collection"
|
||||
|
||||
#: src/components/comment/CommentNode.tsx:270
|
||||
#: src/components/comment/CommentNode.tsx:264
|
||||
msgid "Delete this comment?"
|
||||
msgstr "Supprimer ce commentaire ?"
|
||||
|
||||
@@ -694,12 +735,12 @@ msgstr "Supprimer ce commentaire ?"
|
||||
msgid "Delete this dump? This cannot be undone."
|
||||
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?"
|
||||
msgstr "Supprimer ce message ?"
|
||||
|
||||
#: src/components/profile/ProfilePlaylistsTab.tsx:200
|
||||
#: src/pages/PlaylistDetail.tsx:171
|
||||
#: src/components/profile/ProfilePlaylistsTab.tsx:223
|
||||
#: src/pages/PlaylistDetail.tsx:173
|
||||
msgid "Delete this playlist? This cannot be undone."
|
||||
msgstr "Supprimer cette collection ? Cette action est irréversible."
|
||||
|
||||
@@ -737,7 +778,7 @@ msgid "Done"
|
||||
msgstr "Terminé"
|
||||
|
||||
#. placeholder {0}: dump.fileName
|
||||
#: src/components/dump/FilePreview.tsx:226
|
||||
#: src/components/dump/FilePreview.tsx:233
|
||||
msgid "Download {0}"
|
||||
msgstr "Télécharger {0}"
|
||||
|
||||
@@ -770,8 +811,8 @@ msgid "Dumped!"
|
||||
msgstr "Recommandé !"
|
||||
|
||||
#: src/components/profile/ProfileDumpsTab.tsx:46
|
||||
#: src/pages/Search.tsx:174
|
||||
#: src/pages/UserPublicProfile.tsx:160
|
||||
#: src/pages/Search.tsx:211
|
||||
#: src/pages/UserPublicProfile.tsx:164
|
||||
msgid "Dumps"
|
||||
msgstr "Recos"
|
||||
|
||||
@@ -787,9 +828,9 @@ msgstr "Plus tôt"
|
||||
#: src/components/chat/ChatMessageItem.tsx:107
|
||||
#: src/components/chat/ChatMessageItem.tsx:108
|
||||
#: src/components/chat/PendingItem.tsx:93
|
||||
#: src/components/comment/CommentNode.tsx:256
|
||||
#: src/components/playlist/PlaylistHeader.tsx:60
|
||||
#: src/pages/Dump.tsx:601
|
||||
#: src/components/comment/CommentNode.tsx:250
|
||||
#: src/components/playlist/PlaylistHeader.tsx:104
|
||||
#: src/pages/Dump.tsx:556
|
||||
msgid "Edit"
|
||||
msgstr "Modifier"
|
||||
|
||||
@@ -798,16 +839,22 @@ msgstr "Modifier"
|
||||
msgid "Edit {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"
|
||||
msgstr "Modifier la description"
|
||||
|
||||
#: src/components/profile/ProfileHeader.tsx:171
|
||||
#: src/components/profile/ProfileHeader.tsx:161
|
||||
msgid "Edit email"
|
||||
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/pages/Dump.tsx:511
|
||||
#: src/components/playlist/PlaylistHeader.tsx:84
|
||||
#: src/pages/Dump.tsx:459
|
||||
msgid "Edit title"
|
||||
msgstr "Modifier le titre"
|
||||
|
||||
@@ -819,9 +866,9 @@ msgstr "modifié"
|
||||
#. placeholder {0}: relativeTime(dump.updatedAt)
|
||||
#. placeholder {0}: relativeTime(message.updatedAt)
|
||||
#: src/components/chat/ChatMessageItem.tsx:132
|
||||
#: src/components/comment/CommentNode.tsx:205
|
||||
#: src/components/playlist/PlaylistHeader.tsx:95
|
||||
#: src/pages/Dump.tsx:554
|
||||
#: src/components/comment/CommentNode.tsx:202
|
||||
#: src/components/playlist/PlaylistHeader.tsx:158
|
||||
#: src/pages/Dump.tsx:492
|
||||
msgid "edited {0}"
|
||||
msgstr "modifié {0}"
|
||||
|
||||
@@ -829,9 +876,9 @@ msgstr "modifié {0}"
|
||||
#. placeholder {0}: dump.updatedAt.toLocaleString()
|
||||
#. placeholder {0}: message.updatedAt.toLocaleString()
|
||||
#: src/components/chat/ChatMessageItem.tsx:128
|
||||
#: src/components/comment/CommentNode.tsx:203
|
||||
#: src/components/playlist/PlaylistHeader.tsx:93
|
||||
#: src/pages/Dump.tsx:552
|
||||
#: src/components/comment/CommentNode.tsx:200
|
||||
#: src/components/playlist/PlaylistHeader.tsx:156
|
||||
#: src/pages/Dump.tsx:490
|
||||
msgid "Edited {0}"
|
||||
msgstr "Modifié le {0}"
|
||||
|
||||
@@ -852,11 +899,11 @@ msgstr "E-mail"
|
||||
msgid "Email address"
|
||||
msgstr "Adresse e-mail"
|
||||
|
||||
#: src/pages/Search.tsx:230
|
||||
#: src/pages/Search.tsx:267
|
||||
msgid "Enter a query to search."
|
||||
msgstr "Saisissez une recherche."
|
||||
|
||||
#: src/components/player/GlobalPlayer.tsx:254
|
||||
#: src/components/player/GlobalPlayer.tsx:256
|
||||
msgid "Expand player"
|
||||
msgstr "Agrandir le lecteur"
|
||||
|
||||
@@ -890,15 +937,13 @@ msgstr "Publication échouée"
|
||||
msgid "Failed to post comment"
|
||||
msgstr "Impossible de publier le commentaire"
|
||||
|
||||
#: src/components/comment/CommentNode.tsx:291
|
||||
#: src/components/comment/CommentNode.tsx:285
|
||||
msgid "Failed to post reply"
|
||||
msgstr "Impossible de publier la réponse"
|
||||
|
||||
#: src/components/federation/GrantList.tsx:98
|
||||
#: src/components/federation/PeerManager.tsx:158
|
||||
#: src/components/playlist/PlaylistEditForm.tsx:207
|
||||
#: src/components/profile/DescriptionEditor.tsx:99
|
||||
#: src/components/profile/EmailEditor.tsx:86
|
||||
msgid "Failed to save"
|
||||
msgstr "Enregistrement échoué"
|
||||
|
||||
@@ -906,11 +951,7 @@ msgstr "Enregistrement échoué"
|
||||
msgid "Failed to save category"
|
||||
msgstr "Échec de l’enregistrement de la catégorie"
|
||||
|
||||
#: src/components/comment/CommentNode.tsx:218
|
||||
msgid "Failed to save edit"
|
||||
msgstr "Impossible d'enregistrer la modification"
|
||||
|
||||
#: src/components/profile/ProfileHeader.tsx:182
|
||||
#: src/components/profile/ProfileHeader.tsx:174
|
||||
msgid "Failed to update avatar"
|
||||
msgstr "Impossible de mettre à jour l'avatar"
|
||||
|
||||
@@ -952,9 +993,9 @@ msgid "Follow some users to see their dumps here."
|
||||
msgstr "Suivez des utilisateurs pour voir leurs recos ici."
|
||||
|
||||
#: src/components/feed/FeedTabBar.tsx:61
|
||||
#: src/components/profile/ProfileFollowTab.tsx:144
|
||||
#: src/components/profile/ProfileFollowTab.tsx:143
|
||||
#: src/pages/Settings.tsx:57
|
||||
#: src/pages/UserPublicProfile.tsx:163
|
||||
#: src/pages/UserPublicProfile.tsx:167
|
||||
msgid "Followed"
|
||||
msgstr "Suivi"
|
||||
|
||||
@@ -1025,7 +1066,7 @@ msgstr "Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail. V
|
||||
msgid "image"
|
||||
msgstr "image"
|
||||
|
||||
#: src/pages/Dump.tsx:621
|
||||
#: src/pages/Dump.tsx:576
|
||||
msgid "In collections"
|
||||
msgstr "Dans les collections"
|
||||
|
||||
@@ -1056,7 +1097,7 @@ msgstr "Lien invalide"
|
||||
msgid "Invite link"
|
||||
msgstr "Lien d'invitation"
|
||||
|
||||
#: src/components/profile/ProfileHeader.tsx:126
|
||||
#: src/components/profile/ProfileHeader.tsx:133
|
||||
msgid "invited by"
|
||||
msgstr "invité par"
|
||||
|
||||
@@ -1071,7 +1112,7 @@ msgid "digest.invitedByYou"
|
||||
msgstr "sur votre invitation"
|
||||
|
||||
#: src/components/profile/ProfileInviteesTab.tsx:77
|
||||
#: src/pages/UserPublicProfile.tsx:164
|
||||
#: src/pages/UserPublicProfile.tsx:168
|
||||
msgid "Invitees"
|
||||
msgstr "Invités"
|
||||
|
||||
@@ -1110,11 +1151,11 @@ msgstr "Lien"
|
||||
msgid "Link or file"
|
||||
msgstr "Lien ou fichier"
|
||||
|
||||
#: src/contexts/ws/WSProvider.tsx:764
|
||||
#: src/contexts/ws/WSProvider.tsx:769
|
||||
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
|
||||
|
||||
#: src/components/layout/AppHeader.tsx:234
|
||||
#: src/components/layout/AppHeader.tsx:236
|
||||
msgid "Live updates unavailable."
|
||||
msgstr "Mises à jour en direct indisponibles."
|
||||
|
||||
@@ -1126,7 +1167,7 @@ msgstr "Charger plus"
|
||||
msgid "Load older messages"
|
||||
msgstr "Charger les messages plus anciens"
|
||||
|
||||
#: src/pages/Dump.tsx:337
|
||||
#: src/pages/Dump.tsx:358
|
||||
#: src/pages/DumpEdit.tsx:210
|
||||
msgid "Loading dump…"
|
||||
msgstr "Chargement de la reco…"
|
||||
@@ -1139,11 +1180,11 @@ msgstr "Chargement des messages…"
|
||||
msgid "Loading more…"
|
||||
msgstr "Chargement…"
|
||||
|
||||
#: src/pages/PlaylistDetail.tsx:82
|
||||
#: src/pages/PlaylistDetail.tsx:83
|
||||
msgid "Loading playlist…"
|
||||
msgstr "Chargement de la collection…"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:108
|
||||
#: src/pages/UserPublicProfile.tsx:112
|
||||
msgid "Loading profile…"
|
||||
msgstr "Chargement du profil…"
|
||||
|
||||
@@ -1180,8 +1221,8 @@ msgstr "Se connecter pour valider"
|
||||
msgid "Log in with {0}"
|
||||
msgstr "Se connecter avec {0}"
|
||||
|
||||
#: src/components/layout/UserMenu.tsx:139
|
||||
#: src/pages/UserPublicProfile.tsx:133
|
||||
#: src/components/layout/UserMenu.tsx:132
|
||||
#: src/pages/UserPublicProfile.tsx:137
|
||||
msgid "Log out"
|
||||
msgstr "Se déconnecter"
|
||||
|
||||
@@ -1198,10 +1239,10 @@ msgstr "Connexion échouée"
|
||||
msgid "Lowercase letters, numbers and single hyphens only"
|
||||
msgstr "Minuscules, chiffres et tirets simples seulement"
|
||||
|
||||
#: src/components/layout/UserMenu.tsx:126
|
||||
#: src/components/layout/UserMenu.tsx:119
|
||||
#: src/pages/Manage.tsx:32
|
||||
#: src/pages/Manage.tsx:40
|
||||
#: src/pages/UserPublicProfile.tsx:166
|
||||
#: src/pages/UserPublicProfile.tsx:170
|
||||
msgid "Manage"
|
||||
msgstr "Administration"
|
||||
|
||||
@@ -1209,6 +1250,10 @@ msgstr "Administration"
|
||||
msgid "Max {limit}"
|
||||
msgstr "Max {limit}"
|
||||
|
||||
#: src/components/playlist/PlaylistSortTabs.tsx:34
|
||||
msgid "Mine"
|
||||
msgstr "Les miennes"
|
||||
|
||||
#: src/components/profile/RoleSwitcher.tsx:23
|
||||
msgid "Moderator"
|
||||
msgstr "Modérateur"
|
||||
@@ -1218,6 +1263,10 @@ msgstr "Modérateur"
|
||||
msgid "digest.monthly"
|
||||
msgstr "Récap mensuel"
|
||||
|
||||
#: src/components/playlist/PlaylistSortTabs.tsx:33
|
||||
msgid "Most dumps"
|
||||
msgstr "Les plus fournies"
|
||||
|
||||
#: src/components/player/MediaPlayer.tsx:299
|
||||
msgid "Mute"
|
||||
msgstr "Couper le son"
|
||||
@@ -1229,6 +1278,7 @@ msgstr "Nom"
|
||||
|
||||
#: src/components/chat/ChatModal.tsx:692
|
||||
#: src/components/feed/FeedTabBar.tsx:58
|
||||
#: src/components/playlist/PlaylistSortTabs.tsx:32
|
||||
#: src/pages/Settings.tsx:55
|
||||
msgid "New"
|
||||
msgstr "Nouveau"
|
||||
@@ -1264,7 +1314,7 @@ msgstr "Nouvelles collections publiques"
|
||||
msgid "Next →"
|
||||
msgstr "Suivant →"
|
||||
|
||||
#: src/components/player/GlobalPlayer.tsx:244
|
||||
#: src/components/player/GlobalPlayer.tsx:246
|
||||
msgid "Next track"
|
||||
msgstr "Piste suivante"
|
||||
|
||||
@@ -1272,7 +1322,7 @@ msgstr "Piste suivante"
|
||||
msgid "No dumps in this playlist yet."
|
||||
msgstr "Aucune reco dans cette collection pour l'instant."
|
||||
|
||||
#: src/pages/Search.tsx:246
|
||||
#: src/pages/Search.tsx:283
|
||||
msgid "No dumps match \"{q}\"."
|
||||
msgstr "Aucune reco ne correspond à « {q} »."
|
||||
|
||||
@@ -1285,7 +1335,7 @@ msgstr "Pas encore de recos. Soyez le premier !"
|
||||
msgid "No emoji found."
|
||||
msgstr "Aucun emoji trouvé."
|
||||
|
||||
#: src/components/profile/ProfileFollowTab.tsx:117
|
||||
#: src/components/profile/ProfileFollowTab.tsx:116
|
||||
msgid "No followed playlists yet."
|
||||
msgstr "Pas encore de collections suivies."
|
||||
|
||||
@@ -1301,21 +1351,21 @@ msgstr "Aucun message pour l'instant. Dites bonjour !"
|
||||
msgid "No one yet."
|
||||
msgstr "Personne pour le moment."
|
||||
|
||||
#: src/pages/Search.tsx:287
|
||||
#: src/pages/Search.tsx:330
|
||||
msgid "No playlists match \"{q}\"."
|
||||
msgstr "Aucune collection ne correspond à « {q} »."
|
||||
|
||||
#: src/components/playlist/PlaylistMembershipPanel.tsx:51
|
||||
#: src/components/profile/ProfilePlaylistsTab.tsx:178
|
||||
#: src/pages/Playlists.tsx:126
|
||||
#: src/components/profile/ProfilePlaylistsTab.tsx:201
|
||||
#: src/pages/Playlists.tsx:180
|
||||
msgid "No playlists yet."
|
||||
msgstr "Pas encore de collections."
|
||||
|
||||
#: src/contexts/ws/WSProvider.tsx:934
|
||||
#: src/contexts/ws/WSProvider.tsx:939
|
||||
msgid "No reply from the server — this message may not have been sent"
|
||||
msgstr "Pas de réponse du serveur — ce message n'a peut-être pas été envoyé"
|
||||
|
||||
#: src/pages/Search.tsx:261
|
||||
#: src/pages/Search.tsx:298
|
||||
msgid "No users match \"{q}\"."
|
||||
msgstr "Aucun utilisateur ne correspond à « {q} »."
|
||||
|
||||
@@ -1379,11 +1429,11 @@ msgid "email.welcome.button"
|
||||
msgstr "Ouvrir {site}"
|
||||
|
||||
#. placeholder {0}: richContent.siteName
|
||||
#: src/components/dump/RichContentCard.tsx:122
|
||||
#: src/components/dump/RichContentCard.tsx:124
|
||||
msgid "Open on {0}"
|
||||
msgstr "Ouvrir sur {0}"
|
||||
|
||||
#: src/components/dump/RichContentCard.tsx:123
|
||||
#: src/components/dump/RichContentCard.tsx:125
|
||||
msgid "Open source"
|
||||
msgstr "Ouvrir la source"
|
||||
|
||||
@@ -1408,7 +1458,7 @@ msgstr "Ou connectez-vous avec votre compte d'un autre gerbeur"
|
||||
msgid "Other instances"
|
||||
msgstr "Autres instances"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:159
|
||||
#: src/pages/UserPublicProfile.tsx:163
|
||||
msgid "Overview"
|
||||
msgstr "Aperçu"
|
||||
|
||||
@@ -1444,9 +1494,9 @@ msgstr "Les mots de passe ne correspondent pas"
|
||||
msgid "Paste a link…"
|
||||
msgstr "Collez un lien…"
|
||||
|
||||
#: src/components/dump/FilePreview.tsx:70
|
||||
#: src/components/dump/FilePreview.tsx:184
|
||||
#: src/components/dump/RichContentCard.tsx:103
|
||||
#: src/components/dump/FilePreview.tsx:72
|
||||
#: src/components/dump/FilePreview.tsx:191
|
||||
#: src/components/dump/RichContentCard.tsx:105
|
||||
#: src/components/player/MediaPlayer.tsx:276
|
||||
msgid "Pause"
|
||||
msgstr "Pause"
|
||||
@@ -1455,10 +1505,10 @@ msgstr "Pause"
|
||||
msgid "Picked up where you left off."
|
||||
msgstr "Reprise de votre brouillon."
|
||||
|
||||
#: src/components/dump/FilePreview.tsx:70
|
||||
#: src/components/dump/FilePreview.tsx:184
|
||||
#: src/components/dump/RichContentCard.tsx:103
|
||||
#: src/components/dump/RichContentCard.tsx:150
|
||||
#: src/components/dump/FilePreview.tsx:72
|
||||
#: src/components/dump/FilePreview.tsx:191
|
||||
#: src/components/dump/RichContentCard.tsx:105
|
||||
#: src/components/dump/RichContentCard.tsx:153
|
||||
#: src/components/player/MediaPlayer.tsx:276
|
||||
msgid "Play"
|
||||
msgstr "Lecture"
|
||||
@@ -1468,14 +1518,14 @@ msgid "Playlist categories"
|
||||
msgstr "Catégories de collections"
|
||||
|
||||
#: src/components/dump/create/StepIndicator.tsx:21
|
||||
#: src/components/layout/AppHeader.tsx:172
|
||||
#: src/components/layout/UserMenu.tsx:92
|
||||
#: src/components/profile/ProfileFollowTab.tsx:148
|
||||
#: src/components/profile/ProfilePlaylistsTab.tsx:149
|
||||
#: src/pages/Playlists.tsx:53
|
||||
#: src/pages/Playlists.tsx:114
|
||||
#: src/pages/Search.tsx:177
|
||||
#: src/pages/UserPublicProfile.tsx:162
|
||||
#: src/components/layout/AppHeader.tsx:174
|
||||
#: src/components/layout/UserMenu.tsx:93
|
||||
#: src/components/profile/ProfileFollowTab.tsx:147
|
||||
#: src/components/profile/ProfilePlaylistsTab.tsx:162
|
||||
#: src/pages/Playlists.tsx:74
|
||||
#: src/pages/Playlists.tsx:160
|
||||
#: src/pages/Search.tsx:214
|
||||
#: src/pages/UserPublicProfile.tsx:166
|
||||
msgid "Playlists"
|
||||
msgstr "Collections"
|
||||
|
||||
@@ -1483,26 +1533,26 @@ msgstr "Collections"
|
||||
msgid "Post comment"
|
||||
msgstr "Publier le commentaire"
|
||||
|
||||
#: src/components/comment/CommentNode.tsx:289
|
||||
#: src/components/comment/CommentNode.tsx:283
|
||||
msgid "Post reply"
|
||||
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/dump/create/DumpCreateModal.tsx:752
|
||||
msgid "Posting…"
|
||||
msgstr "Publication…"
|
||||
|
||||
#: src/components/player/GlobalPlayer.tsx:228
|
||||
#: src/components/player/GlobalPlayer.tsx:230
|
||||
msgid "Previous track"
|
||||
msgstr "Piste précédente"
|
||||
|
||||
#: src/components/dump/DumpCard.tsx:144
|
||||
#: src/components/dump/JournalCard.tsx:145
|
||||
#: src/components/playlist/PlaylistCard.tsx:106
|
||||
#: src/components/playlist/PlaylistHeader.tsx:77
|
||||
#: src/components/dump/JournalCard.tsx:146
|
||||
#: src/components/playlist/PlaylistCard.tsx:127
|
||||
#: src/components/playlist/PlaylistHeader.tsx:140
|
||||
#: src/components/playlist/PlaylistMembershipPanel.tsx:81
|
||||
#: src/pages/Dump.tsx:560
|
||||
#: src/pages/Dump.tsx:498
|
||||
msgid "private"
|
||||
msgstr "privé"
|
||||
|
||||
@@ -1510,8 +1560,8 @@ msgstr "privé"
|
||||
msgid "Private"
|
||||
msgstr "Privé"
|
||||
|
||||
#: src/components/playlist/PlaylistCard.tsx:105
|
||||
#: src/components/playlist/PlaylistHeader.tsx:77
|
||||
#: src/components/playlist/PlaylistCard.tsx:126
|
||||
#: src/components/playlist/PlaylistHeader.tsx:140
|
||||
msgid "public"
|
||||
msgstr "public"
|
||||
|
||||
@@ -1523,7 +1573,7 @@ msgstr "Public"
|
||||
msgid "Refresh metadata"
|
||||
msgstr "Actualiser les métadonnées"
|
||||
|
||||
#: src/components/player/GlobalPlayer.tsx:324
|
||||
#: src/components/player/GlobalPlayer.tsx:326
|
||||
msgid "Refreshing stream…"
|
||||
msgstr "Actualisation du flux…"
|
||||
|
||||
@@ -1545,7 +1595,7 @@ msgstr "Inscription…"
|
||||
msgid "Registration failed"
|
||||
msgstr "Inscription échouée"
|
||||
|
||||
#: src/pages/Dump.tsx:612
|
||||
#: src/pages/Dump.tsx:567
|
||||
msgid "Related"
|
||||
msgstr "Connexe"
|
||||
|
||||
@@ -1585,7 +1635,7 @@ msgstr "Remplacer le fichier"
|
||||
|
||||
#: src/components/chat/ChatMessageItem.tsx:97
|
||||
#: src/components/chat/ChatMessageItem.tsx:98
|
||||
#: src/components/comment/CommentNode.tsx:244
|
||||
#: src/components/comment/CommentNode.tsx:240
|
||||
msgid "Reply"
|
||||
msgstr "Répondre"
|
||||
|
||||
@@ -1632,7 +1682,7 @@ msgid "email.reset.title"
|
||||
msgstr "Réinitialiser votre mot de passe"
|
||||
|
||||
#: src/components/chat/PendingItem.tsx:86
|
||||
#: src/pages/Dump.tsx:371
|
||||
#: src/pages/Dump.tsx:392
|
||||
#: src/pages/DumpEdit.tsx:227
|
||||
msgid "Retry"
|
||||
msgstr "Réessayer"
|
||||
@@ -1642,29 +1692,22 @@ msgid "Role"
|
||||
msgstr "Rôle"
|
||||
|
||||
#: src/components/category/CategoryManager.tsx:232
|
||||
#: src/components/chat/ChatEditBox.tsx:85
|
||||
#: src/components/comment/CommentNode.tsx:216
|
||||
#: src/components/form/InlineEdit.tsx:310
|
||||
#: src/components/playlist/PlaylistEditForm.tsx:229
|
||||
#: src/components/profile/DescriptionEditor.tsx:89
|
||||
#: src/components/profile/EmailEditor.tsx:75
|
||||
#: src/pages/Dump.tsx:486
|
||||
#: src/pages/DumpEdit.tsx:564
|
||||
msgid "Save"
|
||||
msgstr "Enregistrer"
|
||||
|
||||
#: src/components/comment/CommentNode.tsx:217
|
||||
#: src/components/form/InlineEdit.tsx:307
|
||||
#: src/components/playlist/PlaylistEditForm.tsx:226
|
||||
#: src/components/profile/ChangePasswordModal.tsx:119
|
||||
#: src/components/profile/DescriptionEditor.tsx:86
|
||||
#: src/components/profile/EmailEditor.tsx:72
|
||||
#: src/pages/Dump.tsx:485
|
||||
#: src/pages/ResetPassword.tsx:191
|
||||
msgid "Saving…"
|
||||
msgstr "Enregistrement…"
|
||||
|
||||
#: src/components/layout/AppHeader.tsx:182
|
||||
#: src/components/layout/SearchBar.tsx:83
|
||||
#: src/pages/Search.tsx:90
|
||||
#: src/pages/Search.tsx:106
|
||||
msgid "Search"
|
||||
msgstr "Rechercher"
|
||||
|
||||
@@ -1680,11 +1723,11 @@ msgstr "Rechercher un emoji"
|
||||
msgid "Search emoji…"
|
||||
msgstr "Rechercher un emoji…"
|
||||
|
||||
#: src/pages/Search.tsx:241
|
||||
#: src/pages/Search.tsx:278
|
||||
msgid "Search failed"
|
||||
msgstr "Recherche échouée"
|
||||
|
||||
#: src/pages/Search.tsx:236
|
||||
#: src/pages/Search.tsx:273
|
||||
msgid "Searching…"
|
||||
msgstr "Recherche…"
|
||||
|
||||
@@ -1724,7 +1767,7 @@ msgstr "Définir un nouveau mot de passe"
|
||||
msgid "email.settings"
|
||||
msgstr "Paramètres"
|
||||
|
||||
#: src/components/layout/UserMenu.tsx:116
|
||||
#: src/components/layout/UserMenu.tsx:109
|
||||
#: src/pages/Settings.tsx:75
|
||||
#: src/pages/Settings.tsx:88
|
||||
msgid "Settings"
|
||||
@@ -1766,8 +1809,8 @@ msgstr "Quelqu'un a demandé à réinitialiser le mot de passe de votre compte {
|
||||
|
||||
#: src/components/form/FormError.tsx:31
|
||||
#: src/components/layout/PageError.tsx:29
|
||||
#: src/contexts/ws/WSProvider.tsx:733
|
||||
#: src/pages/PlaylistDetail.tsx:155
|
||||
#: src/contexts/ws/WSProvider.tsx:738
|
||||
#: src/pages/PlaylistDetail.tsx:157
|
||||
msgid "Something went wrong"
|
||||
msgstr "Une erreur est survenue"
|
||||
|
||||
@@ -1827,11 +1870,11 @@ msgstr "Le mois sur {site}, {range}"
|
||||
msgid "The new order couldn't be saved. Try again."
|
||||
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."
|
||||
msgstr "Ce commentaire n'a pas pu être supprimé. Réessayez."
|
||||
|
||||
#: src/pages/Dump.tsx:346
|
||||
#: src/pages/Dump.tsx:367
|
||||
msgid "This dump was deleted, or made private."
|
||||
msgstr "Cette reco a été supprimée, ou rendue privée."
|
||||
|
||||
@@ -1902,7 +1945,7 @@ msgid "digest.topWeek"
|
||||
msgstr "Les plus validées de la semaine"
|
||||
|
||||
#. placeholder {0}: i + 1
|
||||
#: src/components/player/GlobalPlayer.tsx:338
|
||||
#: src/components/player/GlobalPlayer.tsx:340
|
||||
msgid "Track {0}"
|
||||
msgstr "Piste {0}"
|
||||
|
||||
@@ -1972,12 +2015,20 @@ msgstr "Se désabonner"
|
||||
msgid "Unsubscribed"
|
||||
msgstr "Désabonné"
|
||||
|
||||
#: src/components/playlist/PlaylistSortTabs.tsx:31
|
||||
msgid "Updated"
|
||||
msgstr "Mises à jour"
|
||||
|
||||
#: src/components/playlist/PlaylistCard.tsx:151
|
||||
msgid "updated {ago}"
|
||||
msgstr "mise à jour {ago}"
|
||||
|
||||
#. js-lingui-explicit-id
|
||||
#: api/mail/digest.ts:237
|
||||
msgid "email.digest.upload"
|
||||
msgstr "fichier"
|
||||
|
||||
#: src/components/profile/ProfileHeader.tsx:79
|
||||
#: src/components/profile/ProfileHeader.tsx:80
|
||||
msgid "Upload failed"
|
||||
msgstr "Envoi échoué"
|
||||
|
||||
@@ -1999,7 +2050,7 @@ msgid "Upvote"
|
||||
msgstr "Valider"
|
||||
|
||||
#: src/components/profile/ProfileDumpsTab.tsx:46
|
||||
#: src/pages/UserPublicProfile.tsx:161
|
||||
#: src/pages/UserPublicProfile.tsx:165
|
||||
msgid "Upvoted"
|
||||
msgstr "Validé"
|
||||
|
||||
@@ -2028,7 +2079,7 @@ msgstr "Utilisateur"
|
||||
msgid "User management"
|
||||
msgstr "Gestion utilisateur"
|
||||
|
||||
#: src/components/layout/UserMenu.tsx:67
|
||||
#: src/components/layout/UserMenu.tsx:68
|
||||
msgid "User menu"
|
||||
msgstr "Menu utilisateur"
|
||||
|
||||
@@ -2040,8 +2091,8 @@ msgstr "Menu utilisateur"
|
||||
msgid "Username"
|
||||
msgstr "Nom d'utilisateur"
|
||||
|
||||
#: src/components/profile/ProfileFollowTab.tsx:147
|
||||
#: src/pages/Search.tsx:176
|
||||
#: src/components/profile/ProfileFollowTab.tsx:146
|
||||
#: src/pages/Search.tsx:213
|
||||
msgid "Users"
|
||||
msgstr "Utilisateurs"
|
||||
|
||||
@@ -2080,12 +2131,14 @@ msgid "email.welcome.subject"
|
||||
msgstr "Bienvenue sur {site}"
|
||||
|
||||
#: src/components/dump/create/DumpCreateModal.tsx:705
|
||||
#: src/pages/Dump.tsx:514
|
||||
#: src/pages/Dump.tsx:521
|
||||
#: src/pages/DumpEdit.tsx:539
|
||||
msgid "What makes it worth it?"
|
||||
msgstr "Pourquoi on en voudrait ?"
|
||||
|
||||
#: src/components/profile/DescriptionEditor.tsx:78
|
||||
#: src/components/profile/ProfileDescription.tsx:79
|
||||
#: src/components/profile/ProfileDescription.tsx:62
|
||||
#: src/components/profile/ProfileDescription.tsx:72
|
||||
msgid "Who am I?"
|
||||
msgstr "Qui suis-je ?"
|
||||
|
||||
@@ -2102,7 +2155,7 @@ msgstr "Pourquoi & où"
|
||||
msgid "Why?"
|
||||
msgstr "Pourquoi ?"
|
||||
|
||||
#: src/components/comment/CommentNode.tsx:293
|
||||
#: src/components/comment/CommentNode.tsx:287
|
||||
msgid "Write a reply…"
|
||||
msgstr "Écrire une réponse…"
|
||||
|
||||
@@ -2151,7 +2204,7 @@ msgstr "Vous recevez cet e-mail car une réinitialisation de mot de passe a ét
|
||||
msgid "email.welcome.reason"
|
||||
msgstr "Vous recevez cet e-mail car un compte {site} a été créé avec cette adresse."
|
||||
|
||||
#: src/contexts/ws/WSProvider.tsx:915
|
||||
#: src/contexts/ws/WSProvider.tsx:920
|
||||
msgid "You're offline — not sent"
|
||||
msgstr "Hors ligne — message non envoyé"
|
||||
|
||||
|
||||
@@ -10,11 +10,15 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/fraunces": "5.3.0",
|
||||
"@fontsource-variable/bricolage-grotesque": "5.3.0",
|
||||
"@fontsource-variable/jetbrains-mono": "5.3.0",
|
||||
"@fontsource-variable/saira": "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/manufacturing-consent": "5.3.0",
|
||||
"@lingui/cli": "6.8.0",
|
||||
"@lingui/conf": "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
|
||||
* most upvoted dumps, who joined, the new public playlists and the totals.
|
||||
* This module types it for the `/community` page, which shows it as it was at
|
||||
* the server's last refresh; the digests gather the same figures for each
|
||||
* reader's own period.
|
||||
* What the whole site did, told two ways. Over a period, one of the digests'
|
||||
* cadences: its most upvoted dumps, who joined, the new public playlists and
|
||||
* the totals, which the `/community` page shows as they were at the server's
|
||||
* last refresh and the digests gather for each reader's own period. And as it
|
||||
* happens: the community feed, the latest things its members did in public,
|
||||
* which the GeoCities style's marquee scrolls.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { Dump } from "./dump.ts";
|
||||
import type { OwnedItem } from "./paths.ts";
|
||||
import type { Playlist } from "./playlist.ts";
|
||||
import type { PublicUser } from "./user.ts";
|
||||
|
||||
@@ -30,3 +32,86 @@ export interface CommunityStats {
|
||||
/** Public dumps posted, upvotes cast and comments written in it. */
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
42
shared/image.ts
Normal file
42
shared/image.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Names the widths the site's own images come in. Every image the API serves
|
||||
* (avatars, covers, thumbnails, attachments, proxied images) takes `?w=` and
|
||||
* answers a copy scaled down to it, so a card or an avatar doesn't load the
|
||||
* upload at full size. The frontend builds those URLs from this list
|
||||
* (`imageSrcSet` in `src/utils/shared/urls.ts`) and the server makes no other
|
||||
* width (`serveImage` in `api/lib/upload.ts`), which bounds what it keeps.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/**
|
||||
* The widths, in pixels, smallest first: an avatar on a dense screen, a small
|
||||
* cover, a card on a phone, and the widest feed card on a dense screen.
|
||||
*/
|
||||
export const IMAGE_WIDTHS = [128, 320, 640, 960] as const;
|
||||
|
||||
/** One of {@link IMAGE_WIDTHS}. */
|
||||
export type ImageWidth = typeof IMAGE_WIDTHS[number];
|
||||
|
||||
/** The largest of {@link IMAGE_WIDTHS}, which a thumbnail never exceeds. */
|
||||
export const MAX_IMAGE_WIDTH: ImageWidth =
|
||||
IMAGE_WIDTHS[IMAGE_WIDTHS.length - 1];
|
||||
|
||||
/**
|
||||
* Returns the narrowest of {@link IMAGE_WIDTHS} that covers `width` pixels, or
|
||||
* the widest when none does.
|
||||
*/
|
||||
export function snapImageWidth(width: number): ImageWidth {
|
||||
return IMAGE_WIDTHS.find((w) => w >= width) ?? MAX_IMAGE_WIDTH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the width a `?w=` value asks for, snapped to {@link IMAGE_WIDTHS},
|
||||
* or null when it is absent or not a positive number, which asks for the image
|
||||
* as it is.
|
||||
*/
|
||||
export function parseImageWidth(raw: string | null): ImageWidth | null {
|
||||
if (raw === null || !/^\d{1,5}$/.test(raw)) return null;
|
||||
const width = Number(raw);
|
||||
return width > 0 ? snapImageWidth(width) : null;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Flattens the Markdown users write into one line of plain text, where a
|
||||
* quote of it can't render markup: the chat's reply previews and the digest
|
||||
* email's quotes of a comment or a note.
|
||||
* quote of it can't render markup: the chat's reply previews, the digest
|
||||
* email's quotes of a comment or a note, and the line of a description on a
|
||||
* playlist's card and on a user in the search results.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
* categories. Under a user each segment is a fixed tab name, and under `dumps`
|
||||
* or `playlists` a slug. A view of a page is one more segment, and a default
|
||||
* view is the bare path. A category narrowing a list on another page, a
|
||||
* profile's or a search's, is a query, `?category=`, not a segment.
|
||||
* profile's or a search's, is a query, `?category=`, not a segment, and so is
|
||||
* the order of a list of playlists, `?sort=`, the default order being the
|
||||
* bare path, and the playlists page's list of the viewer's own, `?mine=1`.
|
||||
*
|
||||
* The client links every page through the builders (`src/utils/shared/urls.ts`
|
||||
* wraps the dump and playlist ones), the emails prefix them with the
|
||||
@@ -19,6 +21,11 @@
|
||||
*/
|
||||
|
||||
import type { CategoryKind } from "./category.ts";
|
||||
import {
|
||||
parsePlaylistSort,
|
||||
PLAYLIST_SORTS,
|
||||
type PlaylistSort,
|
||||
} from "./playlist.ts";
|
||||
import { DIGEST_CADENCES, type DigestCadence } from "./settings.ts";
|
||||
import { HANDLE_RE, splitHandle } from "./validation.ts";
|
||||
|
||||
@@ -64,6 +71,15 @@ export const SEARCH_TABS = ["dumps", "users", "playlists"] as const;
|
||||
/** One of the {@link SEARCH_TABS}. */
|
||||
export type SearchTab = (typeof SEARCH_TABS)[number];
|
||||
|
||||
/**
|
||||
* The tabs of the playlists page, in the tab bar's order: each order of every
|
||||
* public playlist, `?sort=`, then the viewer's own playlists, `?mine=1`,
|
||||
* which only a signed-in user is offered.
|
||||
*/
|
||||
export const PLAYLISTS_TABS = [...PLAYLIST_SORTS, "mine"] as const;
|
||||
/** One of the {@link PLAYLISTS_TABS}. */
|
||||
export type PlaylistsTab = (typeof PLAYLISTS_TABS)[number];
|
||||
|
||||
/**
|
||||
* The views of the community page, one per digest cadence: the week at
|
||||
* `/community`, the month at `/community/monthly`.
|
||||
@@ -142,10 +158,40 @@ export function feedPath(
|
||||
|
||||
/**
|
||||
* Returns the path of the page of every public playlist, `/playlists`, or of a
|
||||
* playlist category's at `/playlists/<slug>`.
|
||||
* playlist category's at `/playlists/<slug>`, on the tab `tab` names: an
|
||||
* order, the default one without it, or the viewer's own playlists.
|
||||
*/
|
||||
export function playlistsPath(categorySlug?: string): string {
|
||||
return categorySlug ? `/playlists/${seg(categorySlug)}` : "/playlists";
|
||||
export function playlistsPath(
|
||||
categorySlug?: string,
|
||||
tab?: PlaylistsTab,
|
||||
): string {
|
||||
const path = categorySlug ? `/playlists/${seg(categorySlug)}` : "/playlists";
|
||||
return `${path}${tab === "mine" ? "?mine=1" : playlistSortQuery(tab)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the tab of the playlists page its query names: the viewer's own
|
||||
* playlists with a `mine`, else the order `sort` names, as
|
||||
* `parsePlaylistSort` reads it.
|
||||
*/
|
||||
export function parsePlaylistsTab(params: URLSearchParams): PlaylistsTab {
|
||||
return params.has("mine") ? "mine" : parsePlaylistSort(params.get("sort"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the query string that puts a list of playlists in the order `sort`
|
||||
* names, with its `?`, or `""` for the default order. `extra` holds the
|
||||
* list's other parameters, such as a profile's `category`.
|
||||
*/
|
||||
export function playlistSortQuery(
|
||||
sort: PlaylistSort = PLAYLIST_SORTS[0],
|
||||
extra?: URLSearchParams,
|
||||
): string {
|
||||
const params = new URLSearchParams(extra);
|
||||
if (sort === PLAYLIST_SORTS[0]) params.delete("sort");
|
||||
else params.set("sort", sort);
|
||||
const query = params.toString();
|
||||
return query ? `?${query}` : "";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,26 @@
|
||||
|
||||
import type { Dump } from "./dump.ts";
|
||||
|
||||
/**
|
||||
* The orders a list of playlists comes in, the first being the default one:
|
||||
* `updated`, by {@link playlistActivity}, the latest first; `new`, by
|
||||
* creation, the newest first; `dumps`, the fullest first. `GET /api/playlists`
|
||||
* and `GET /api/users/:key/playlists` take one as `?sort=`, the `/playlists`
|
||||
* page and a profile's playlists tab show them as tabs and keep the one
|
||||
* picked in their URL's `?sort=`.
|
||||
*/
|
||||
export const PLAYLIST_SORTS = ["updated", "new", "dumps"] as const;
|
||||
/** One of the {@link PLAYLIST_SORTS}. */
|
||||
export type PlaylistSort = (typeof PLAYLIST_SORTS)[number];
|
||||
|
||||
/**
|
||||
* Returns the order a `?sort=` names, the default one when it is missing or
|
||||
* names none: like a page number, an order never fails a list.
|
||||
*/
|
||||
export function parsePlaylistSort(value: string | null): PlaylistSort {
|
||||
return PLAYLIST_SORTS.find((sort) => sort === value) ?? PLAYLIST_SORTS[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returned by the playlist routes, and by the search, users and dumps routes
|
||||
* that list playlists. The `playlist_created` and `playlist_updated` WebSocket
|
||||
@@ -40,10 +60,16 @@ export interface Playlist {
|
||||
/** It is undefined when the playlist has no cover. */
|
||||
imageMime?: string;
|
||||
/**
|
||||
* It counts the dumps the viewer can't see too. It is undefined when the
|
||||
* query didn't count them.
|
||||
* It counts the dumps the viewer may see in it, as its page lists them. It
|
||||
* is undefined when the query didn't count them.
|
||||
*/
|
||||
dumpCount?: number;
|
||||
/**
|
||||
* When the latest of the dumps the viewer may see was added to it, which
|
||||
* {@link playlistActivity} reads. It is undefined when it holds none, or
|
||||
* when the query didn't count them.
|
||||
*/
|
||||
lastDumpAt?: Date;
|
||||
/** The owner's current name, which the playlist's path starts with. */
|
||||
ownerUsername: string;
|
||||
/**
|
||||
@@ -53,6 +79,40 @@ export interface Playlist {
|
||||
categoryIds?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns when `playlist` last took a dump, or was created if it holds none:
|
||||
* what the `updated` order sorts by and the cards show. Its title, cover and
|
||||
* description may change after without bringing it back up a list.
|
||||
*/
|
||||
export function playlistActivity(
|
||||
playlist: Pick<Playlist, "createdAt" | "lastDumpAt">,
|
||||
): Date {
|
||||
return playlist.lastDumpAt ?? playlist.createdAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the comparison that puts playlists in the order `sort` names, as
|
||||
* the API pages them, the newer first on a tie. `usePlaylistListSync` finds
|
||||
* with it where a playlist the socket announces goes in a list.
|
||||
*/
|
||||
export function comparePlaylists(
|
||||
sort: PlaylistSort,
|
||||
): (a: Playlist, b: Playlist) => number {
|
||||
const byCreation = (a: Playlist, b: Playlist) =>
|
||||
b.createdAt.getTime() - a.createdAt.getTime();
|
||||
switch (sort) {
|
||||
case "updated":
|
||||
return (a, b) =>
|
||||
playlistActivity(b).getTime() - playlistActivity(a).getTime() ||
|
||||
byCreation(a, b);
|
||||
case "dumps":
|
||||
return (a, b) =>
|
||||
(b.dumpCount ?? 0) - (a.dumpCount ?? 0) || byCreation(a, b);
|
||||
case "new":
|
||||
return byCreation;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returned by `GET /api/users/:key/playlists/:slug` to `PlaylistDetail`, and
|
||||
* by `GET /api/playlists/:playlistId`, with the dumps the caller may see, in
|
||||
|
||||
22
shared/test/image.test.ts
Normal file
22
shared/test/image.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { assertEquals } from "@std/assert";
|
||||
import { parseImageWidth, snapImageWidth } from "../image.ts";
|
||||
|
||||
Deno.test("snapImageWidth: the narrowest width that covers, the widest past them all", () => {
|
||||
assertEquals(snapImageWidth(1), 128);
|
||||
assertEquals(snapImageWidth(128), 128);
|
||||
assertEquals(snapImageWidth(129), 320);
|
||||
assertEquals(snapImageWidth(700), 960);
|
||||
assertEquals(snapImageWidth(5000), 960);
|
||||
});
|
||||
|
||||
Deno.test("parseImageWidth: a positive number, or no width at all", () => {
|
||||
assertEquals(parseImageWidth("320"), 320);
|
||||
assertEquals(parseImageWidth("200"), 320);
|
||||
assertEquals(parseImageWidth(null), null);
|
||||
assertEquals(parseImageWidth(""), null);
|
||||
assertEquals(parseImageWidth("0"), null);
|
||||
assertEquals(parseImageWidth("-64"), null);
|
||||
assertEquals(parseImageWidth("12.5"), null);
|
||||
assertEquals(parseImageWidth("wide"), null);
|
||||
assertEquals(parseImageWidth("999999999"), null);
|
||||
});
|
||||
@@ -3,7 +3,10 @@ import {
|
||||
dumpPath,
|
||||
feedPath,
|
||||
parsePagePath,
|
||||
parsePlaylistsTab,
|
||||
playlistPath,
|
||||
playlistSortQuery,
|
||||
playlistsPath,
|
||||
searchPath,
|
||||
userPath,
|
||||
} from "../paths.ts";
|
||||
@@ -37,6 +40,27 @@ Deno.test("builders: one hierarchy, default views bare", () => {
|
||||
assertEquals(searchPath(), "/search");
|
||||
assertEquals(searchPath("users", "a b"), "/search/users?q=a%20b");
|
||||
assertEquals(searchPath("dumps", "x"), "/search?q=x");
|
||||
assertEquals(playlistsPath(), "/playlists");
|
||||
assertEquals(playlistsPath("mixes"), "/playlists/mixes");
|
||||
assertEquals(playlistsPath(undefined, "updated"), "/playlists");
|
||||
assertEquals(playlistsPath("mixes", "new"), "/playlists/mixes?sort=new");
|
||||
assertEquals(playlistsPath("mixes", "mine"), "/playlists/mixes?mine=1");
|
||||
// The page's tab is one or the other: its own playlists come in one order.
|
||||
const tab = (query: string) => parsePlaylistsTab(new URLSearchParams(query));
|
||||
assertEquals(tab(""), "updated");
|
||||
assertEquals(tab("sort=dumps"), "dumps");
|
||||
assertEquals(tab("sort=nope"), "updated");
|
||||
assertEquals(tab("mine=1"), "mine");
|
||||
assertEquals(tab("mine=1&sort=dumps"), "mine");
|
||||
// The query of a list that has other parameters: the default order takes
|
||||
// an old `sort` out, and leaves the rest.
|
||||
const category = new URLSearchParams({ category: "mixes", sort: "new" });
|
||||
assertEquals(
|
||||
playlistSortQuery("dumps", category),
|
||||
"?category=mixes&sort=dumps",
|
||||
);
|
||||
assertEquals(playlistSortQuery("updated", category), "?category=mixes");
|
||||
assertEquals(playlistSortQuery("updated"), "");
|
||||
});
|
||||
|
||||
Deno.test("parsePagePath: what a path under a user names", () => {
|
||||
|
||||
27
shared/ws.ts
27
shared/ws.ts
@@ -281,6 +281,22 @@ export interface DumpDeletedMessage {
|
||||
dumpId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sent by the server when the dumps related to some dumps changed: a note or
|
||||
* a comment gained or lost a link to a dump, or a dump that has such links
|
||||
* turned public. It goes to the sockets whose user may see both ends of a
|
||||
* link. The `Dump` page showing one of `dumpIds` fetches its related dumps
|
||||
* again.
|
||||
*/
|
||||
export interface DumpRelatedChangedMessage {
|
||||
type: "dump_related_changed";
|
||||
/**
|
||||
* The dump whose links changed, then the dumps at their other end, without
|
||||
* those this socket's user may not see.
|
||||
*/
|
||||
dumpIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sent by the server when a playlist is created, to every socket if it is
|
||||
* public, else to its owner's. It also goes to everyone but the owner when a
|
||||
@@ -295,7 +311,8 @@ export interface PlaylistCreatedMessage {
|
||||
|
||||
/**
|
||||
* Sent by the server when a playlist changes, to every socket if it is public,
|
||||
* else to its owner's, as when it has just gone private.
|
||||
* else to its owner's, as when it has just gone private. Its `dumpCount` and
|
||||
* `lastDumpAt` are the socket's user's, as in `playlist_created`.
|
||||
*/
|
||||
export interface PlaylistUpdatedMessage {
|
||||
type: "playlist_updated";
|
||||
@@ -325,8 +342,11 @@ export interface PlaylistDumpsUpdatedMessage {
|
||||
* private dump is left out.
|
||||
*/
|
||||
dumpIds: string[];
|
||||
/** Counts every dump, hidden ones included, as `Playlist.dumpCount` does. */
|
||||
dumpCount: number;
|
||||
/**
|
||||
* When the latest of them was added, the playlist's `lastDumpAt` for this
|
||||
* socket's user. It is missing when `dumpIds` is empty.
|
||||
*/
|
||||
lastDumpAt?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -488,6 +508,7 @@ export type ServerToClientMessage =
|
||||
| DumpCreatedMessage
|
||||
| DumpUpdatedMessage
|
||||
| DumpDeletedMessage
|
||||
| DumpRelatedChangedMessage
|
||||
| PlaylistCreatedMessage
|
||||
| PlaylistUpdatedMessage
|
||||
| PlaylistDeletedMessage
|
||||
|
||||
608
src/App.css
608
src/App.css
@@ -223,67 +223,151 @@
|
||||
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;
|
||||
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;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
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;
|
||||
gap: 0.4em;
|
||||
}
|
||||
|
||||
.dump-title-view--editable {
|
||||
cursor: pointer;
|
||||
/* Room on the right for the ✎, which sits in the corner. */
|
||||
.inline-edit--block .inline-edit-view {
|
||||
display: block;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
|
||||
.dump-title-view--editable:hover {
|
||||
background: var(--color-surface);
|
||||
.inline-edit-view:hover::before {
|
||||
background: var(--inline-edit-plate);
|
||||
}
|
||||
|
||||
.dump-title-edit-btn {
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1;
|
||||
.inline-edit-mark {
|
||||
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);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity var(--dur-fast);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dump-title-view--editable:hover .dump-title-edit-btn,
|
||||
.dump-title-view--editable:focus-visible .dump-title-edit-btn {
|
||||
.inline-edit--block .inline-edit-mark {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.inline-edit-view:hover .inline-edit-mark,
|
||||
.inline-edit-mark:focus-visible {
|
||||
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;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* Shared chrome for inline-edit inputs (the editors that live outside `.form`).
|
||||
Each rule below adds only its own size / weight / width deltas. */
|
||||
.dump-title-input,
|
||||
.profile-email-input,
|
||||
.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);
|
||||
.inline-edit-error {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* A name and its "Rename" button, the hint and errors below. */
|
||||
@@ -304,28 +388,18 @@
|
||||
flex: 1 1 10rem;
|
||||
min-width: 0;
|
||||
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);
|
||||
outline: none;
|
||||
transition: border-color var(--dur-slow);
|
||||
}
|
||||
|
||||
.dump-title-input {
|
||||
flex: 1;
|
||||
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);
|
||||
.username-field-input:focus {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* On the narrowest viewports the fixed-width action column squeezes the title
|
||||
@@ -2289,6 +2363,14 @@ body.has-player .chat-fab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* A list's order tabs, as the playlists', take a line of their own under the
|
||||
title where the row has no room for both, rather than scroll beside it. */
|
||||
.profile-section-header:has(.playlist-sort-tabs) {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* ── Profile (own) page ── */
|
||||
@@ -2349,23 +2431,9 @@ body.has-player .chat-fab {
|
||||
/* No margins of its own: the page's gap already sets it apart. */
|
||||
.profile-description {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-description-view {
|
||||
position: relative;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
|
||||
.profile-description-view--editable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.profile-description-view--editable:hover {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.profile-description-text {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
@@ -2373,80 +2441,16 @@ body.has-player .chat-fab {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.profile-description-empty {
|
||||
color: var(--color-muted);
|
||||
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;
|
||||
.profile-email {
|
||||
margin: 0.1rem 0 0.4rem;
|
||||
font-size: var(--text-xs);
|
||||
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 {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.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);
|
||||
/* An address is typed a size up from the line it shows on. */
|
||||
.profile-email .inline-edit-input {
|
||||
max-width: 22rem;
|
||||
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 {
|
||||
@@ -2723,7 +2727,11 @@ body.has-player .chat-fab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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 {
|
||||
@@ -2761,11 +2769,15 @@ body.has-player .chat-fab {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* A list of dump or playlist cards in a group, or right under a page's header
|
||||
as on /playlists, fills the column, its edges in line with the label's rule
|
||||
or the header. */
|
||||
.page-group .dump-feed,
|
||||
.page-content--narrow > .dump-feed {
|
||||
/* 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
|
||||
line with the label's rule. */
|
||||
.page-group .dump-feed {
|
||||
padding: 0;
|
||||
max-width: none;
|
||||
}
|
||||
@@ -2849,6 +2861,13 @@ body.has-player .chat-fab {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text);
|
||||
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 {
|
||||
@@ -2923,7 +2942,7 @@ body.has-player .chat-fab {
|
||||
/* When the search bar is expanded, immediately clear the rest of the center —
|
||||
the expanded input needs the full column width */
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -2936,10 +2955,6 @@ body.has-player .chat-fab {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-header-brand:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.app-header-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -3109,16 +3124,39 @@ body.has-player .chat-fab {
|
||||
--nav-icon-tint-strength: 100%;
|
||||
}
|
||||
|
||||
/* Text links — visible only at wide viewports */
|
||||
/* The text link to every playlist. A guest's header holds no other, so
|
||||
theirs shows at every width. Signed in, the avatar's menu holds one too,
|
||||
and this one shows where the row has room for it beside the icons: from
|
||||
560px, where it fits with the "+ Dump" label in every style and language,
|
||||
up to 860px, where the index page's tabs move into the header, and again
|
||||
from 1150px, where the header holds both. */
|
||||
.nav-links {
|
||||
display: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1150px) {
|
||||
.nav-links {
|
||||
display: flex;
|
||||
@media (max-width: 559px), (min-width: 860px) and (max-width: 1149px) {
|
||||
.app-header:not(.app-header--guest) .nav-links {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* A guest's row is the link, the search and "Log in": on the narrowest
|
||||
phones it gives up its margins rather than the link. */
|
||||
@media (max-width: 400px) {
|
||||
.app-header--guest {
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.app-header--guest .app-header-nav {
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.app-header--guest .app-header-nav button,
|
||||
.app-header--guest .app-header-user {
|
||||
padding-inline: 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3763,90 +3801,54 @@ body.has-player .chat-fab {
|
||||
|
||||
/* ── Feed sort scroller + buttons ── */
|
||||
|
||||
/* Wrapper: constrains width, clips scroll overflow, and carries the gradient
|
||||
hint. --tab-row-bg is what the row sits on, which the fade and the pinned
|
||||
leading content paint so the tabs vanish under them. */
|
||||
/* Wrapper: the leading content (the scope switcher), then the row of tabs,
|
||||
which scrolls on its own beside it. Nothing here paints the ground the row
|
||||
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 {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
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 {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
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. */
|
||||
.feed-sort-leading::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 100%;
|
||||
width: 0.75rem;
|
||||
background: linear-gradient(to left, transparent, var(--tab-row-bg));
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity var(--dur-base);
|
||||
/* The row fades out at an edge while tabs are hidden past it (TabBar's
|
||||
--more and --more-left), so a fade never dims a tab that is scrolled to or
|
||||
fits. The fade is a mask, the tabs thinning out over whatever is behind
|
||||
them. The two lengths are registered so that they can ease in. */
|
||||
@property --tab-fade-start {
|
||||
syntax: "<length>";
|
||||
inherits: true;
|
||||
initial-value: 0px;
|
||||
}
|
||||
|
||||
.feed-sort-scroller--more-left .feed-sort-leading::after {
|
||||
opacity: 1;
|
||||
@property --tab-fade-end {
|
||||
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 */
|
||||
@@ -3860,6 +3862,13 @@ body.has-player .chat-fab {
|
||||
gap: 0.4rem;
|
||||
overflow-x: auto;
|
||||
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
|
||||
brand and buttons), with room on both sides for the focus ring that
|
||||
overflow-x would otherwise clip. */
|
||||
@@ -4064,9 +4073,8 @@ body.has-player .chat-fab {
|
||||
0.25);
|
||||
}
|
||||
|
||||
/* ── Shared card preview icon ── */
|
||||
.dump-card-preview-icon,
|
||||
.playlist-card-icon {
|
||||
/* ── Dump card preview icon ── */
|
||||
.dump-card-preview-icon {
|
||||
font-size: var(--text-3xl);
|
||||
opacity: 0.7;
|
||||
line-height: 1;
|
||||
@@ -4355,6 +4363,82 @@ body.has-player .chat-fab {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Playlist grid ──
|
||||
The playlists page shows its cards as tiles, the cover across the tile's
|
||||
top and the text under it: the same card as in a .dump-feed, laid out by
|
||||
the list it is in, so every theme's card skin carries over. */
|
||||
.playlist-grid {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(11.5rem, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.playlist-grid > li {
|
||||
animation: card-enter 0.2s ease both;
|
||||
}
|
||||
|
||||
/* The tiles of a row are as tall as the tallest, each one's text starting at
|
||||
the same height under covers of one size. */
|
||||
.playlist-grid .playlist-card {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.playlist-grid .playlist-card-inner {
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.65rem;
|
||||
padding: 0.6rem 0.6rem 0.8rem;
|
||||
}
|
||||
|
||||
.playlist-grid .playlist-card-preview {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
/* A cover this size fills the tile: grown as a thumb is, it would spill over
|
||||
the card's edge. */
|
||||
.playlist-grid .playlist-card-inner:hover .playlist-card-preview {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.playlist-grid .playlist-card-preview .thumb-placeholder-initials {
|
||||
font-size: var(--text-display);
|
||||
}
|
||||
|
||||
.playlist-grid .playlist-card-body {
|
||||
margin-left: 0;
|
||||
padding-inline: 0.15rem;
|
||||
}
|
||||
|
||||
.playlist-grid .playlist-card-title,
|
||||
.playlist-grid .playlist-card-description {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Owner, count and date wrap whole rather than run past a narrow tile. */
|
||||
.playlist-grid .playlist-card-meta {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.1rem 0.6rem;
|
||||
}
|
||||
|
||||
@media (max-width: 512px) {
|
||||
.playlist-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.dump-card-vote {
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
@@ -5025,19 +5109,10 @@ body.has-player .chat-fab {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.chat-edit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.chat-edit-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* The input rests on one line, as tall as the Send button beside it: both
|
||||
take --chat-composer-height, which any style's line of text fits in. */
|
||||
.chat-composer {
|
||||
--chat-composer-height: 2.75rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-end;
|
||||
@@ -5066,6 +5141,10 @@ body.has-player .chat-fab {
|
||||
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
|
||||
alignment. */
|
||||
.chat-composer .chat-input {
|
||||
min-height: var(--chat-composer-height);
|
||||
}
|
||||
|
||||
.chat-composer .text-editor-footer {
|
||||
position: absolute;
|
||||
left: 0.25rem;
|
||||
@@ -5080,9 +5159,9 @@ body.has-player .chat-fab {
|
||||
|
||||
.chat-send-btn {
|
||||
flex-shrink: 0;
|
||||
/* Match the input's resting height so the two line up exactly (both edges),
|
||||
while the input is free to grow taller as you type. */
|
||||
height: 2.4rem;
|
||||
/* The input's resting height, so that the two line up at both edges, while
|
||||
the input is free to grow taller as you type. */
|
||||
height: var(--chat-composer-height);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -5430,6 +5509,23 @@ body.has-player .chat-fab {
|
||||
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 {
|
||||
margin: 0;
|
||||
font-size: var(--text-2xl);
|
||||
@@ -5723,7 +5819,7 @@ body.has-player .chat-fab {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.collections-section .playlist-card-icon {
|
||||
.collections-section .playlist-card-preview .thumb-placeholder-initials {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
@@ -7439,7 +7535,7 @@ body.has-player .chat-fab {
|
||||
}
|
||||
|
||||
/* Vertical rule separating the scope selector from the sort tabs. */
|
||||
.feed-sort .category-switcher::after {
|
||||
.feed-sort-leading .category-switcher::after {
|
||||
content: "";
|
||||
width: 2px;
|
||||
height: 1.05rem;
|
||||
@@ -7532,7 +7628,7 @@ body.has-player .chat-fab {
|
||||
.category-link {
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-header-user-bg);
|
||||
background: var(--color-badge-bg);
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: var(--text-xs);
|
||||
@@ -7718,6 +7814,9 @@ body.has-player .chat-fab {
|
||||
.community-figure {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
/* In column-reverse, flex-end is the top: the figures stay on one line
|
||||
when a label wraps to two. */
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
@@ -7742,3 +7841,12 @@ body.has-player .chat-fab {
|
||||
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;
|
||||
}
|
||||
|
||||
35
src/App.tsx
35
src/App.tsx
@@ -23,7 +23,8 @@
|
||||
* - `/community` and `/community/monthly`, the site's week and month
|
||||
* (`Community`), signed in only.
|
||||
* - `/playlists` is every public playlist, and `/playlists/:categorySlug` a
|
||||
* playlist category's (`Playlists`).
|
||||
* playlist category's, in the order `?sort=` names, or with `?mine=1` the
|
||||
* signed-in user's own (`Playlists`).
|
||||
* - `/@:username/*` is a user's (`UserRoutes`): their profile and its tabs,
|
||||
* their dumps at `dumps/:slug` (`Dump`) and `dumps/:slug/edit` (`DumpEdit`,
|
||||
* signed in only), and their playlists at `playlists/:slug`
|
||||
@@ -61,6 +62,7 @@ import { AuthProvider } from "./contexts/auth/AuthProvider.tsx";
|
||||
import { PlayerProvider } from "./contexts/player/PlayerProvider.tsx";
|
||||
import { WSProvider } from "./contexts/ws/WSProvider.tsx";
|
||||
import { FollowProvider } from "./contexts/social/FollowProvider.tsx";
|
||||
import { CommunityFeedProvider } from "./contexts/social/CommunityFeedProvider.tsx";
|
||||
import { CategoriesProvider } from "./contexts/category/CategoriesProvider.tsx";
|
||||
import { ThemeProvider } from "./contexts/theme/ThemeProvider.tsx";
|
||||
import { ChatProvider } from "./contexts/chat/ChatProvider.tsx";
|
||||
@@ -202,15 +204,22 @@ function HandleRoutes() {
|
||||
: <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 (
|
||||
<>
|
||||
<Suspense>
|
||||
<Routes>
|
||||
<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) => (
|
||||
<Route key={tab} path={`/${tab}`} element={<Feed />} />
|
||||
<Route key={tab} path={`/${tab}/*`} element={<Feed />} />
|
||||
))}
|
||||
<Route path="/search" element={<Search />} />
|
||||
<Route path="/search/:searchTab" element={<Search />} />
|
||||
@@ -297,14 +306,16 @@ export function App(): React.JSX.Element {
|
||||
<PlayerProvider>
|
||||
<WSProvider>
|
||||
<FollowProvider>
|
||||
<CategoriesProvider>
|
||||
<BrowserRouter>
|
||||
<ChatProvider>
|
||||
<AppRoutes />
|
||||
<GlobalPlayer />
|
||||
</ChatProvider>
|
||||
</BrowserRouter>
|
||||
</CategoriesProvider>
|
||||
<CommunityFeedProvider>
|
||||
<CategoriesProvider>
|
||||
<BrowserRouter>
|
||||
<ChatProvider>
|
||||
<AppRoutes />
|
||||
<GlobalPlayer />
|
||||
</ChatProvider>
|
||||
</BrowserRouter>
|
||||
</CategoriesProvider>
|
||||
</CommunityFeedProvider>
|
||||
</FollowProvider>
|
||||
</WSProvider>
|
||||
</PlayerProvider>
|
||||
|
||||
@@ -5,39 +5,33 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx";
|
||||
import { COUNT_FROM, sendable } from "../../utils/chat/message.ts";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { InlineEditField } from "../form/InlineEdit.tsx";
|
||||
import { COUNT_FROM } from "../../utils/chat/message.ts";
|
||||
import { VALIDATION } from "../../../shared/validation.ts";
|
||||
|
||||
/** Passed to {@link ChatEditBox} by `ChatMessageItem` while it is editing. */
|
||||
export interface ChatEditBoxProps {
|
||||
initial: string;
|
||||
/**
|
||||
* Receives the trimmed text, only when it can be sent. It may be unchanged:
|
||||
* the caller decides whether to send an edit.
|
||||
*/
|
||||
/** Receives the trimmed text, only when it changed and can be sent. */
|
||||
onSave: (body: string) => void;
|
||||
/** Also called on Escape. */
|
||||
onCancel: () => void;
|
||||
/** Called once the text is saved, or left, Escape included. */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an editor holding `initial`, with Save and Cancel. Enter saves and
|
||||
* Shift+Enter starts a new line, as in the composer.
|
||||
* Renders the field every text edited in place gets, `InlineEditField`, with
|
||||
* Enter saving and Shift+Enter starting a new line, as in the composer. It
|
||||
* scrolls itself into the list's view.
|
||||
*/
|
||||
export function ChatEditBox(
|
||||
{ initial, onSave, onCancel }: ChatEditBoxProps,
|
||||
{ initial, onSave, onClose }: ChatEditBoxProps,
|
||||
): React.JSX.Element {
|
||||
const [draft, setDraft] = useState(initial);
|
||||
const editorRef = useRef<TextEditorHandle>(null);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// The message is still easing to its new height, so a scroll on focus would
|
||||
// land short. Scroll once the transition is over, Save button included.
|
||||
// The message is still easing to its new height, so a scroll now would land
|
||||
// short. Scroll once the transition is over, Save button included.
|
||||
useEffect(() => {
|
||||
editorRef.current?.focus({ atEnd: true, preventScroll: true });
|
||||
const box = boxRef.current;
|
||||
if (!box) return;
|
||||
const settle = parseFloat(
|
||||
@@ -50,48 +44,19 @@ export function ChatEditBox(
|
||||
return () => clearTimeout(id);
|
||||
}, []);
|
||||
|
||||
const save = () => {
|
||||
if (!sendable(draft)) return;
|
||||
onSave(draft.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chat-edit animate-in" ref={boxRef}>
|
||||
<TextEditor
|
||||
ref={editorRef}
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
rows={2}
|
||||
autoResize
|
||||
<div className="chat-edit" ref={boxRef}>
|
||||
<InlineEditField
|
||||
value={initial}
|
||||
onSave={onSave}
|
||||
onClose={onClose}
|
||||
kind="markdown"
|
||||
required
|
||||
maxLength={VALIDATION.CHAT_MESSAGE_MAX}
|
||||
countFrom={COUNT_FROM}
|
||||
className="comment-reply-textarea chat-input"
|
||||
onSubmit={save}
|
||||
onCancel={onCancel}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
save();
|
||||
}
|
||||
}}
|
||||
fieldClassName="chat-input"
|
||||
enterSaves
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -194,11 +194,8 @@ export function ChatMessageItem(
|
||||
? (
|
||||
<ChatEditBox
|
||||
initial={message.body}
|
||||
onSave={(body) => {
|
||||
if (body !== message.body) onEdit(message.id, body);
|
||||
onEndEdit();
|
||||
}}
|
||||
onCancel={onEndEdit}
|
||||
onSave={(body) => onEdit(message.id, body)}
|
||||
onClose={onEndEdit}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
|
||||
@@ -850,7 +850,7 @@ export function ChatModal(
|
||||
value={draft}
|
||||
onChange={handleDraftChange}
|
||||
placeholder={t`Type a message…`}
|
||||
rows={2}
|
||||
rows={1}
|
||||
autoResize
|
||||
maxLength={VALIDATION.CHAT_MESSAGE_MAX}
|
||||
countFrom={COUNT_FROM}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Renders the form of a dump's comment thread that posts a comment, a reply,
|
||||
* or an edit.
|
||||
* Renders the form of a dump's comment thread that posts a comment or a
|
||||
* reply. An edit opens `InlineEditField` in the comment's place.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -15,7 +15,7 @@ import { useApiForm } from "../../hooks/form/useApiForm.ts";
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
/**
|
||||
@@ -28,7 +28,6 @@ export interface CommentFormProps {
|
||||
pendingLabel: React.ReactNode;
|
||||
errorTitle: string;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
/** Without it, the form has no Cancel button. */
|
||||
onCancel?: () => void;
|
||||
/**
|
||||
@@ -39,7 +38,7 @@ export interface CommentFormProps {
|
||||
editorRef?: React.Ref<TextEditorHandle>;
|
||||
/**
|
||||
* `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";
|
||||
leading?: React.ReactNode;
|
||||
@@ -57,7 +56,6 @@ export function CommentForm({
|
||||
pendingLabel,
|
||||
errorTitle,
|
||||
placeholder,
|
||||
defaultValue = "",
|
||||
onCancel,
|
||||
cancelWhenEmpty,
|
||||
editorRef,
|
||||
@@ -66,7 +64,7 @@ export function CommentForm({
|
||||
className,
|
||||
}: CommentFormProps): React.JSX.Element {
|
||||
const form = useApiForm<{ body: string }>({
|
||||
defaultValues: { body: defaultValue },
|
||||
defaultValues: { body: "" },
|
||||
});
|
||||
const body = form.watch("body");
|
||||
const disabled = !body.trim() || body.length > VALIDATION.COMMENT_BODY_MAX;
|
||||
@@ -91,8 +89,8 @@ export function CommentForm({
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onSubmit={() => void submit()}
|
||||
// Escape closes a reply or an edit. The new-comment form has
|
||||
// nothing to close.
|
||||
// Escape closes a reply. The new-comment form has nothing to
|
||||
// close.
|
||||
onCancel={onCancel && !cancelWhenEmpty
|
||||
? () => {
|
||||
form.reset({ body: "" });
|
||||
|
||||
@@ -30,6 +30,8 @@ import { useWS } from "../../hooks/ws/useWS.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { expectOk } from "../../utils/shared/api.ts";
|
||||
import { CommentForm } from "./CommentForm.tsx";
|
||||
import { InlineEditField } from "../form/InlineEdit.tsx";
|
||||
import { VALIDATION } from "../../../shared/validation.ts";
|
||||
import { userPath } from "../../../shared/paths.ts";
|
||||
|
||||
/**
|
||||
@@ -75,7 +77,7 @@ export function CommentNode({
|
||||
);
|
||||
|
||||
const replyEditorRef = useRef<TextEditorHandle>(null);
|
||||
const editEditorRef = useRef<TextEditorHandle>(null);
|
||||
const editButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const { likeCounts, myLikes, castCommentLike, removeCommentLike } = useWS();
|
||||
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) {
|
||||
// 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}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ body } satisfies UpdateCommentRequest),
|
||||
});
|
||||
onCommentUpdated(deserializeComment(await expectOk<Wire<Comment>>(res)));
|
||||
setEditOpen(false);
|
||||
}
|
||||
|
||||
const canModerate = can(currentUser, "comment:moderate");
|
||||
@@ -209,15 +206,14 @@ export function CommentNode({
|
||||
</div>
|
||||
{editOpen
|
||||
? (
|
||||
<CommentForm
|
||||
editorRef={editEditorRef}
|
||||
defaultValue={comment.body}
|
||||
onSubmit={handleEditSave}
|
||||
submitLabel={<Trans>Save</Trans>}
|
||||
pendingLabel={<Trans>Saving…</Trans>}
|
||||
errorTitle={t`Failed to save edit`}
|
||||
className="animate-in"
|
||||
onCancel={() => setEditOpen(false)}
|
||||
<InlineEditField
|
||||
value={comment.body}
|
||||
onSave={handleEditSave}
|
||||
onClose={() => setEditOpen(false)}
|
||||
returnFocusTo={editButtonRef}
|
||||
kind="markdown"
|
||||
required
|
||||
maxLength={VALIDATION.COMMENT_BODY_MAX}
|
||||
/>
|
||||
)
|
||||
: <Markdown className="comment-body">{comment.body}</Markdown>}
|
||||
@@ -246,12 +242,10 @@ export function CommentNode({
|
||||
)}
|
||||
{canEdit && !editOpen && (
|
||||
<button
|
||||
ref={editButtonRef}
|
||||
type="button"
|
||||
className="comment-action-btn"
|
||||
onClick={() => {
|
||||
setEditOpen(true);
|
||||
setTimeout(() => editEditorRef.current?.focus(), 0);
|
||||
}}
|
||||
onClick={() => setEditOpen(true)}
|
||||
>
|
||||
<Trans>Edit</Trans>
|
||||
</button>
|
||||
|
||||
@@ -12,7 +12,9 @@ import {
|
||||
dumpFileUrl,
|
||||
dumpThumbnailUrl,
|
||||
dumpWaveformUrl,
|
||||
imageProps,
|
||||
} from "../../utils/shared/urls.ts";
|
||||
import { IMAGE_SIZES } from "../../config/imageSizes.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import {
|
||||
fileArtworkUrl,
|
||||
@@ -72,6 +74,7 @@ function FilePlayButton({ dump }: { dump: Dump }) {
|
||||
<Thumbnail
|
||||
src={fileArtworkUrl(dump, token)}
|
||||
className="rich-content-compact-thumbnail"
|
||||
sizes={IMAGE_SIZES.compactThumbnail}
|
||||
placeholder={{ seed: dump.fileName ?? dump.id, glyph: mimeIcon(mime) }}
|
||||
placeholderClassName="rich-content-compact-thumbnail"
|
||||
/>
|
||||
@@ -141,7 +144,10 @@ export function FilePreview(
|
||||
// A card needs the server's scaled-down copy, not the full upload.
|
||||
return (
|
||||
<img
|
||||
src={dumpThumbnailUrl(dump, token)}
|
||||
{...imageProps(
|
||||
dumpThumbnailUrl(dump, token),
|
||||
IMAGE_SIZES.compactThumbnail,
|
||||
)}
|
||||
alt={dump.fileName}
|
||||
className="rich-content-compact-thumbnail"
|
||||
loading="lazy"
|
||||
@@ -158,6 +164,7 @@ export function FilePreview(
|
||||
<Thumbnail
|
||||
src={artworkUrl}
|
||||
className="rich-content-compact-thumbnail"
|
||||
sizes={IMAGE_SIZES.compactThumbnail}
|
||||
placeholder={{
|
||||
seed: dump.fileName ?? dump.id,
|
||||
glyph: mimeIcon(mime),
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Plural, Trans } from "@lingui/react/macro";
|
||||
import type { Dump } from "../../../shared/dump.ts";
|
||||
import { relativeTime } from "../../utils/shared/relativeTime.ts";
|
||||
import { dumpUrl } from "../../utils/shared/urls.ts";
|
||||
import { IMAGE_SIZES } from "../../config/imageSizes.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { useDumpCardLink } from "../../hooks/dump/useDumpCardLink.ts";
|
||||
import {
|
||||
@@ -195,6 +196,7 @@ export const JournalCard: React.MemoExoticComponent<
|
||||
<div className="journal-card-image">
|
||||
<Thumbnail
|
||||
src={thumbnailUrl}
|
||||
sizes={IMAGE_SIZES.journal[shape]}
|
||||
placeholder={dump.kind === "file"
|
||||
// A video still fails without ffmpeg on the server, and the MIME
|
||||
// glyph says more than an initial taken from the file name.
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
usePlayRichContent,
|
||||
} from "../../hooks/player/usePlayRichContent.ts";
|
||||
import { Thumbnail } from "../shared/Thumbnail.tsx";
|
||||
import { IMAGE_SIZES } from "../../config/imageSizes.ts";
|
||||
|
||||
/**
|
||||
* Passed to {@link RichContentCard} by `DumpCard` (`compact`), `Dump`,
|
||||
@@ -68,6 +69,7 @@ export function RichContentCard(
|
||||
src={thumbnailSrc}
|
||||
alt={richContent.title ?? ""}
|
||||
className="rich-content-compact-thumbnail"
|
||||
sizes={IMAGE_SIZES.compactThumbnail}
|
||||
placeholder={placeholder}
|
||||
placeholderClassName="rich-content-compact-thumbnail"
|
||||
/>
|
||||
@@ -135,6 +137,7 @@ export function RichContentCard(
|
||||
src={thumbnailSrc}
|
||||
alt={richContent.title ?? ""}
|
||||
className="rich-content-thumbnail"
|
||||
sizes={IMAGE_SIZES.richThumbnail}
|
||||
placeholder={placeholder}
|
||||
placeholderClassName="rich-content-thumbnail"
|
||||
/>
|
||||
|
||||
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,9 +15,10 @@ import { NotificationBell } from "./NotificationBell.tsx";
|
||||
import { ChatButton } from "./ChatButton.tsx";
|
||||
import { UserMenu } from "./UserMenu.tsx";
|
||||
import { SearchBar } from "./SearchBar.tsx";
|
||||
import { Marquee } from "./Marquee.tsx";
|
||||
import { IconClose, IconSearch } from "./NavIcons.tsx";
|
||||
import { SITE_EMOJI, SITE_NAME } from "../../hooks/shared/useDocumentTitle.ts";
|
||||
import { searchPath, userPath } from "../../../shared/paths.ts";
|
||||
import { playlistsPath, searchPath } from "../../../shared/paths.ts";
|
||||
|
||||
/** Loaded on first open, so its editor stays out of every page's bundle. */
|
||||
const DumpCreateModal = lazy(() =>
|
||||
@@ -62,9 +63,10 @@ export interface AppHeaderProps {
|
||||
|
||||
/**
|
||||
* Renders the site's header. It holds the search, which stays open on the
|
||||
* results page and slides open under the header on small screens, the
|
||||
* signed-in user's links and buttons, and a banner while live updates are down.
|
||||
* It also hosts the dump creation dialog that its "+ Dump" button opens.
|
||||
* results page and slides open under the header on small screens, the link to
|
||||
* the playlists page, the signed-in user's menu and buttons or a guest's
|
||||
* "Log in", and a banner while live updates are down. It also hosts the dump
|
||||
* creation dialog that its "+ Dump" button opens.
|
||||
*/
|
||||
export function AppHeader(
|
||||
{ centerSlot, disableNew, initialDumpUrl }: AppHeaderProps,
|
||||
@@ -142,7 +144,11 @@ export function AppHeader(
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="app-header app-header--has-center">
|
||||
<header
|
||||
className={`app-header app-header--has-center${
|
||||
user ? "" : " app-header--guest"
|
||||
}`}
|
||||
>
|
||||
<Link to="/" className="app-header-brand">
|
||||
{SITE_EMOJI}
|
||||
<span className="app-header-brand-name">
|
||||
@@ -158,23 +164,17 @@ export function AppHeader(
|
||||
|
||||
<nav className="app-header-nav">
|
||||
{
|
||||
/* The user's own links lead the row, so the icons after them run
|
||||
unbroken to the "+": Playlists on wide screens, then the
|
||||
avatar's menu, which leads to their profile, at every width. */
|
||||
/* The links lead the row, so the icons after them run unbroken to
|
||||
the "+": the playlists page, at every width for a guest and
|
||||
where the row has room for a signed-in user, then the avatar's
|
||||
menu, which leads there too, at every width. */
|
||||
}
|
||||
{user && (
|
||||
<>
|
||||
<div className="nav-links">
|
||||
<Link
|
||||
to={userPath(user.username, "playlists")}
|
||||
className="app-header-user"
|
||||
>
|
||||
<Trans>Playlists</Trans>
|
||||
</Link>
|
||||
</div>
|
||||
<UserMenu user={user} />
|
||||
</>
|
||||
)}
|
||||
<div className="nav-links">
|
||||
<Link to={playlistsPath()} className="app-header-user">
|
||||
<Trans>Playlists</Trans>
|
||||
</Link>
|
||||
</div>
|
||||
{user && <UserMenu user={user} />}
|
||||
<button
|
||||
ref={searchToggleRef}
|
||||
type="button"
|
||||
@@ -228,6 +228,8 @@ export function AppHeader(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Marquee />
|
||||
|
||||
{wsStatus === "disconnected" && wsErrorMessage && (
|
||||
<div className="app-header-status" role="alert">
|
||||
<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 />;
|
||||
}
|
||||
@@ -17,14 +17,15 @@ export interface PageHeaderProps {
|
||||
title: ReactNode;
|
||||
/**
|
||||
* It goes at the end of the row, such as the notifications' unread count or
|
||||
* the playlists' category menu.
|
||||
* the playlists' category menu and order tabs.
|
||||
*/
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the page's h1 with its icon, then `children`, as the first block of
|
||||
* a narrow `PageShell`; `PageGroup`s follow it.
|
||||
* a `PageShell`: a narrow one's `PageGroup`s follow it, or the playlists
|
||||
* page's grid.
|
||||
*/
|
||||
export function PageHeader(
|
||||
{ icon, title, children }: PageHeaderProps,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Renders the avatar menu that leads the header's icon row at every width: it
|
||||
* holds the profile link, the playlists one (on small screens, the only one),
|
||||
* the community page, the settings, the site's tools, and logging out.
|
||||
* holds the profile link, the playlists page (the header's text link leads
|
||||
* there too, at the widths that have room for it), the community page, the
|
||||
* settings, the site's tools, and logging out.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -29,7 +30,7 @@ export interface UserMenuProps {
|
||||
|
||||
/**
|
||||
* Renders the signed-in user's avatar, which opens a menu with links to their
|
||||
* profile, their playlists, `/community` and `/settings`, to `/manage` for
|
||||
* profile, `/playlists`, `/community` and `/settings`, to `/manage` for
|
||||
* those who hold `category:manage` or `federation:manage`, and a Log out
|
||||
* button. A click outside or Escape closes it.
|
||||
*/
|
||||
@@ -84,7 +85,7 @@ export function UserMenu({ user }: UserMenuProps): React.JSX.Element {
|
||||
@{user.username}
|
||||
</Link>
|
||||
<Link
|
||||
to={userPath(user.username, "playlists")}
|
||||
to={playlistsPath()}
|
||||
className="user-menu-item"
|
||||
role="menuitem"
|
||||
onClick={() => setOpen(false)}
|
||||
@@ -99,14 +100,6 @@ export function UserMenu({ user }: UserMenuProps): React.JSX.Element {
|
||||
>
|
||||
{i18n._(DIGEST_MESSAGES.community)}
|
||||
</Link>
|
||||
<Link
|
||||
to={playlistsPath()}
|
||||
className="user-menu-item"
|
||||
role="menuitem"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<Trans>All playlists</Trans>
|
||||
</Link>
|
||||
<Link
|
||||
to="/settings"
|
||||
className="user-menu-item"
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { MediaPlayer } from "./MediaPlayer.tsx";
|
||||
import { fmt } from "../../utils/player/duration.ts";
|
||||
import { Thumbnail } from "../shared/Thumbnail.tsx";
|
||||
import { IMAGE_SIZES } from "../../config/imageSizes.ts";
|
||||
import { useYouTubePlaylist } from "../../hooks/player/useYouTubePlaylist.ts";
|
||||
import { embedListId, withJsApi } from "../../utils/player/youtube.ts";
|
||||
|
||||
@@ -201,6 +202,7 @@ export function GlobalPlayer(): React.JSX.Element | null {
|
||||
<Thumbnail
|
||||
src={current.artworkUrl}
|
||||
className="global-player-artwork"
|
||||
sizes={IMAGE_SIZES.playerArtwork}
|
||||
placeholder={{ siteName: current.subtitle }}
|
||||
placeholderClassName="global-player-artwork"
|
||||
loading="eager"
|
||||
|
||||
@@ -5,21 +5,29 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { Link } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import type { Playlist } from "../../../shared/playlist.ts";
|
||||
import { type Playlist, playlistActivity } from "../../../shared/playlist.ts";
|
||||
import { relativeTime } from "../../utils/shared/relativeTime.ts";
|
||||
import { playlistImageUrl, playlistUrl } from "../../utils/shared/urls.ts";
|
||||
import {
|
||||
imageProps,
|
||||
playlistImageUrl,
|
||||
playlistUrl,
|
||||
} from "../../utils/shared/urls.ts";
|
||||
import { IMAGE_SIZES } from "../../config/imageSizes.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { useCardLink } from "../../hooks/shared/useCardLink.ts";
|
||||
import {
|
||||
isPlaylistVisited,
|
||||
isRecent,
|
||||
markPlaylistVisited,
|
||||
} from "../../utils/shared/visited.ts";
|
||||
import { ThumbnailPlaceholder } from "../shared/ThumbnailPlaceholder.tsx";
|
||||
import { Tooltip } from "../shared/Tooltip.tsx";
|
||||
import { CategoryChips } from "../category/CategoryChips.tsx";
|
||||
import { userPath } from "../../../shared/paths.ts";
|
||||
import { markdownToPlainText } from "../../../shared/markdown.ts";
|
||||
|
||||
/** Passed to {@link PlaylistCard} by each list of playlists. */
|
||||
export interface PlaylistCardProps {
|
||||
@@ -37,46 +45,59 @@ export interface PlaylistCardProps {
|
||||
* public badges on every card there would say nothing.
|
||||
*/
|
||||
privateBadgeOnly?: boolean;
|
||||
/** Set for a tile of the playlists page's grid, whose cover is larger. */
|
||||
grid?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a playlist as a card that opens the playlist when clicked, with the
|
||||
* playlist categories it is filed under. Another user's recent playlist shows
|
||||
* an unread dot until it is opened on this device. The card is an `<li>`, for
|
||||
* a `<ul>`.
|
||||
* Renders a playlist as a card that opens the playlist when clicked, in a new
|
||||
* tab on a middle click (`useCardLink`), with its cover, or a tile of its own
|
||||
* colour without one, the playlist categories it is filed under and when it
|
||||
* last took a dump. Another user's recent playlist shows an unread dot until
|
||||
* it is opened on this device. The card is an `<li>`, for a `<ul>`, whose
|
||||
* class lays it out: a row in a `.dump-feed`, a tile in a `.playlist-grid`.
|
||||
*/
|
||||
export function PlaylistCard(
|
||||
{ playlist, onDelete, isOwner, privateBadgeOnly }: PlaylistCardProps,
|
||||
{ playlist, onDelete, isOwner, privateBadgeOnly, grid }: PlaylistCardProps,
|
||||
): React.JSX.Element {
|
||||
const navigate = useNavigate();
|
||||
const { mediaToken } = useAuth();
|
||||
const { cardProps } = useCardLink(
|
||||
playlistUrl(playlist),
|
||||
() => markPlaylistVisited(playlist.id),
|
||||
);
|
||||
const unread = !isOwner && isRecent(playlist.createdAt) &&
|
||||
!isPlaylistVisited(playlist.id);
|
||||
const showBadge = !privateBadgeOnly || (!!isOwner && !playlist.isPublic);
|
||||
|
||||
function handleNavigate() {
|
||||
markPlaylistVisited(playlist.id);
|
||||
navigate(playlistUrl(playlist));
|
||||
}
|
||||
// The playlist's page renders the description's Markdown; the card has
|
||||
// room for its text only, and none at all when it is a lone image.
|
||||
const description = playlist.description &&
|
||||
markdownToPlainText(playlist.description, "");
|
||||
const activity = playlistActivity(playlist);
|
||||
const ago = relativeTime(activity);
|
||||
|
||||
return (
|
||||
<li className="playlist-card">
|
||||
<div
|
||||
className="playlist-card-inner"
|
||||
onClick={handleNavigate}
|
||||
>
|
||||
<div className="playlist-card-inner" {...cardProps}>
|
||||
<div className="playlist-card-preview">
|
||||
{playlist.imageMime
|
||||
? (
|
||||
<img
|
||||
src={playlistImageUrl(playlist, mediaToken)}
|
||||
{...imageProps(
|
||||
playlistImageUrl(playlist, mediaToken),
|
||||
grid ? IMAGE_SIZES.playlistTile : IMAGE_SIZES.playlistThumb,
|
||||
)}
|
||||
alt=""
|
||||
className="playlist-card-img"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
)
|
||||
: <span className="playlist-card-icon">📋</span>}
|
||||
: (
|
||||
<ThumbnailPlaceholder
|
||||
seed={playlist.id}
|
||||
siteName={playlist.title}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="playlist-card-body">
|
||||
<Link
|
||||
@@ -91,8 +112,8 @@ export function PlaylistCard(
|
||||
{unread && <span className="unread-dot" aria-hidden="true" />}
|
||||
{playlist.title}
|
||||
</Link>
|
||||
{playlist.description && (
|
||||
<p className="playlist-card-description">{playlist.description}</p>
|
||||
{description && (
|
||||
<p className="playlist-card-description">{description}</p>
|
||||
)}
|
||||
<div className="playlist-card-meta">
|
||||
{showBadge && (
|
||||
@@ -124,9 +145,11 @@ export function PlaylistCard(
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<Tooltip text={playlist.createdAt.toLocaleString()}>
|
||||
<time dateTime={playlist.createdAt.toISOString()}>
|
||||
{relativeTime(playlist.createdAt)}
|
||||
<Tooltip text={activity.toLocaleString()}>
|
||||
<time dateTime={activity.toISOString()}>
|
||||
{playlist.lastDumpAt
|
||||
? <Trans>updated {ago}</Trans>
|
||||
: <Trans>created {ago}</Trans>}
|
||||
</time>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -15,18 +15,24 @@ export interface PlaylistCardListProps {
|
||||
onDelete?: (playlist: Playlist) => void;
|
||||
/** Passed to each {@link PlaylistCard}. */
|
||||
privateBadgeOnly?: boolean;
|
||||
/**
|
||||
* Lays the cards out as tiles, cover first, several to a row, for a page
|
||||
* that is a list of playlists and nothing else: the playlists page.
|
||||
*/
|
||||
grid?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders `playlists` as a `.dump-feed` of {@link PlaylistCard}, a card being
|
||||
* its owner's when the playlist is the signed-in user's.
|
||||
* Renders `playlists` as a `.dump-feed` of {@link PlaylistCard}, one to a
|
||||
* row, or as a `.playlist-grid` of them, a card being its owner's when the
|
||||
* playlist is the signed-in user's.
|
||||
*/
|
||||
export function PlaylistCardList(
|
||||
{ playlists, onDelete, privateBadgeOnly }: PlaylistCardListProps,
|
||||
{ playlists, onDelete, privateBadgeOnly, grid }: PlaylistCardListProps,
|
||||
): React.JSX.Element {
|
||||
const { user } = useAuth();
|
||||
return (
|
||||
<ul className="dump-feed">
|
||||
<ul className={grid ? "playlist-grid" : "dump-feed"}>
|
||||
{playlists.map((p) => (
|
||||
<PlaylistCard
|
||||
key={p.id}
|
||||
@@ -34,6 +40,7 @@ export function PlaylistCardList(
|
||||
isOwner={!!user && user.id === p.userId}
|
||||
onDelete={onDelete && (() => onDelete(p))}
|
||||
privateBadgeOnly={privateBadgeOnly}
|
||||
grid={grid}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -8,11 +8,22 @@
|
||||
import { Link } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import type { PlaylistWithDumps } from "../../../shared/playlist.ts";
|
||||
import { playlistImageUrl } from "../../utils/shared/urls.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 { IMAGE_SIZES } from "../../config/imageSizes.ts";
|
||||
import { relativeTime } from "../../utils/shared/relativeTime.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { Markdown } from "../editor/Markdown.tsx";
|
||||
import { InlineEdit } from "../form/InlineEdit.tsx";
|
||||
import { FollowPlaylistButton } from "../social/FollowButton.tsx";
|
||||
import { Tooltip } from "../shared/Tooltip.tsx";
|
||||
import { CategoryChips } from "../category/CategoryChips.tsx";
|
||||
@@ -21,30 +32,63 @@ import { userPath } from "../../../shared/paths.ts";
|
||||
/** Passed to {@link PlaylistHeader} by `PlaylistDetail`. */
|
||||
export interface PlaylistHeaderProps {
|
||||
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;
|
||||
/**
|
||||
* 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
|
||||
* 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(
|
||||
{ playlist, onEdit }: PlaylistHeaderProps,
|
||||
{ playlist, onEdit, onSaved }: PlaylistHeaderProps,
|
||||
): 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 (
|
||||
<>
|
||||
{playlist.imageMime && (
|
||||
<img
|
||||
src={playlistImageUrl(playlist, mediaToken)}
|
||||
{...imageProps(
|
||||
playlistImageUrl(playlist, mediaToken),
|
||||
IMAGE_SIZES.playlistBanner,
|
||||
)}
|
||||
alt=""
|
||||
className="playlist-detail-img"
|
||||
/>
|
||||
)}
|
||||
<div className="playlist-detail-content">
|
||||
<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 && (
|
||||
<FollowPlaylistButton
|
||||
targetPlaylistId={playlist.id}
|
||||
@@ -62,10 +106,29 @@ export function PlaylistHeader(
|
||||
)}
|
||||
</div>
|
||||
|
||||
{playlist.description && (
|
||||
<Markdown className="playlist-detail-description">
|
||||
{playlist.description}
|
||||
</Markdown>
|
||||
{(playlist.description || onSaved) && (
|
||||
<InlineEdit
|
||||
value={playlist.description ?? ""}
|
||||
editable={!!onSaved}
|
||||
label={t`Edit description`}
|
||||
onSave={(description) => save({ description: description || null })}
|
||||
kind="markdown"
|
||||
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
|
||||
placeholder={t`Add a description…`}
|
||||
|
||||
>
|
||||
{playlist.description
|
||||
? (
|
||||
<Markdown className="playlist-detail-description">
|
||||
{playlist.description}
|
||||
</Markdown>
|
||||
)
|
||||
: (
|
||||
<p className="inline-edit-empty">
|
||||
<Trans>Add a description…</Trans>
|
||||
</p>
|
||||
)}
|
||||
</InlineEdit>
|
||||
)}
|
||||
|
||||
<div className="playlist-detail-meta">
|
||||
|
||||
50
src/components/playlist/PlaylistSortTabs.tsx
Normal file
50
src/components/playlist/PlaylistSortTabs.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Renders the tabs that put a list of playlists in another order, on the
|
||||
* playlists page and on a profile's playlists tab, and the playlists page's
|
||||
* tab of the viewer's own.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import type { PlaylistsTab } from "../../../shared/paths.ts";
|
||||
import { TabBar } from "../shared/TabBar.tsx";
|
||||
|
||||
/**
|
||||
* Passed to {@link PlaylistSortTabs} by `ProfilePlaylistsTab`, whose tabs
|
||||
* are `PLAYLIST_SORTS`, and by `Playlists`, whose `PLAYLISTS_TABS` add the
|
||||
* viewer's own playlists for a signed-in user. Both keep the active one in
|
||||
* their URL's query.
|
||||
*/
|
||||
export interface PlaylistSortTabsProps<T extends PlaylistsTab> {
|
||||
/** The tabs offered, in order. */
|
||||
tabs: readonly T[];
|
||||
/** The active tab. */
|
||||
sort: T;
|
||||
onChange: (sort: T) => void;
|
||||
/** Goes before the tabs, as the playlists page's category menu does. */
|
||||
leading?: ReactNode;
|
||||
}
|
||||
|
||||
const LABELS: Record<PlaylistsTab, ReactNode> = {
|
||||
updated: <Trans>Updated</Trans>,
|
||||
new: <Trans>New</Trans>,
|
||||
dumps: <Trans>Most dumps</Trans>,
|
||||
mine: <Trans>Mine</Trans>,
|
||||
};
|
||||
|
||||
/** Renders one tab per entry of `tabs`, `sort` being the active one. */
|
||||
export function PlaylistSortTabs<T extends PlaylistsTab>(
|
||||
{ tabs, sort, onChange, leading }: PlaylistSortTabsProps<T>,
|
||||
): React.JSX.Element {
|
||||
return (
|
||||
<TabBar
|
||||
tabs={tabs.map((key) => ({ key, label: LABELS[key] }))}
|
||||
activeTab={sort}
|
||||
onChange={onChange}
|
||||
leading={leading}
|
||||
className="playlist-sort-tabs"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { t } from "@lingui/core/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 { DescriptionEditor } from "./DescriptionEditor.tsx";
|
||||
|
||||
/** Passed to {@link ProfileDescription} by `UserPublicProfile`. */
|
||||
export interface ProfileDescriptionProps {
|
||||
@@ -22,70 +26,53 @@ export interface ProfileDescriptionProps {
|
||||
|
||||
/**
|
||||
* 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
|
||||
* or Enter swaps in {@link DescriptionEditor}.
|
||||
* their own profile, users get a "Who am I?" placeholder instead, and edit it
|
||||
* in place, saved to `PATCH /api/me`. Other browsers see the change through
|
||||
* the socket's `user_updated` event.
|
||||
*/
|
||||
export function ProfileDescription(
|
||||
{ description, isOwnProfile, onSaved }: ProfileDescriptionProps,
|
||||
): React.JSX.Element | null {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const ref = useHeightTransition<HTMLDivElement>(editing);
|
||||
const { authFetch } = useAuth();
|
||||
|
||||
if (!description && !isOwnProfile) return null;
|
||||
|
||||
return (
|
||||
<div className="profile-description" ref={ref}>
|
||||
{editing
|
||||
? (
|
||||
<DescriptionEditor
|
||||
initialDescription={description ?? ""}
|
||||
onCancel={() => setEditing(false)}
|
||||
onSaved={(saved) => {
|
||||
onSaved(saved);
|
||||
setEditing(false);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div
|
||||
className={`profile-description-view${
|
||||
isOwnProfile ? " profile-description-view--editable" : ""
|
||||
}`}
|
||||
onClick={isOwnProfile ? () => setEditing(true) : undefined}
|
||||
// Focusable rather than a button, so the Markdown inside keeps its
|
||||
// own structure and links.
|
||||
tabIndex={isOwnProfile ? 0 : undefined}
|
||||
title={isOwnProfile ? t`Edit description` : undefined}
|
||||
onKeyDown={isOwnProfile
|
||||
? (e) => {
|
||||
if (
|
||||
(e.key === "Enter" || e.key === " ") &&
|
||||
e.target === e.currentTarget
|
||||
) {
|
||||
e.preventDefault();
|
||||
setEditing(true);
|
||||
}
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
{description
|
||||
? (
|
||||
<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 className="profile-description">
|
||||
<InlineEdit
|
||||
value={description ?? ""}
|
||||
editable={isOwnProfile}
|
||||
label={t`Edit description`}
|
||||
onSave={async (saved) => {
|
||||
// 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: saved } satisfies UpdateUserRequest,
|
||||
),
|
||||
}),
|
||||
);
|
||||
onSaved(saved || undefined);
|
||||
}}
|
||||
kind="markdown"
|
||||
maxLength={VALIDATION.USER_DESCRIPTION_MAX}
|
||||
placeholder={t`Who am I?`}
|
||||
>
|
||||
{description
|
||||
? (
|
||||
<Markdown className="profile-description-text">
|
||||
{description}
|
||||
</Markdown>
|
||||
)
|
||||
: (
|
||||
<p className="inline-edit-empty">
|
||||
<Trans>Who am I?</Trans>
|
||||
</p>
|
||||
)}
|
||||
</InlineEdit>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,7 +106,6 @@ function FollowedPlaylists({ userId }: { userId: string }) {
|
||||
usePlaylistListSync(
|
||||
state.status === "loaded" ? state.data.items : [],
|
||||
setItems,
|
||||
{ noNewEntries: true },
|
||||
);
|
||||
return (
|
||||
<ListState
|
||||
|
||||
@@ -11,15 +11,18 @@ import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import type { Wire } from "../../../shared/api.ts";
|
||||
import type { PublicUser, SelfUser } from "../../../shared/user.ts";
|
||||
import { parseAPIResponse } from "../../utils/shared/api.ts";
|
||||
import type {
|
||||
PublicUser,
|
||||
SelfUser,
|
||||
UpdateUserRequest,
|
||||
} from "../../../shared/user.ts";
|
||||
import { expectOk, parseAPIResponse } from "../../utils/shared/api.ts";
|
||||
import { deserializeSelfUser } from "../../utils/shared/deserialize.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { useHeightTransition } from "../../hooks/shared/useHeightTransition.ts";
|
||||
import { Avatar } from "../shared/Avatar.tsx";
|
||||
import { ErrorCard } from "../shared/ErrorCard.tsx";
|
||||
import { FollowUserButton } from "../social/FollowButton.tsx";
|
||||
import { EmailEditor } from "./EmailEditor.tsx";
|
||||
import { InlineEdit } from "../form/InlineEdit.tsx";
|
||||
import { ProfileOwnActions } from "./ProfileOwnActions.tsx";
|
||||
import { RoleChip } from "./RoleSwitcher.tsx";
|
||||
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
|
||||
* 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
|
||||
* 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
|
||||
* header's avatar menu.
|
||||
*/
|
||||
@@ -49,8 +52,6 @@ export function ProfileHeader(
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [avatarError, setAvatarError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [emailEditing, setEmailEditing] = useState(false);
|
||||
const infoRef = useHeightTransition<HTMLDivElement>(emailEditing);
|
||||
const { name, host } = splitHandle(user.username);
|
||||
// A user from another instance changes their avatar and email there.
|
||||
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 } });
|
||||
setEmailEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -112,7 +119,7 @@ export function ProfileHeader(
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="profile-info" ref={infoRef}>
|
||||
<div className="profile-info">
|
||||
<div className="profile-username-row">
|
||||
<h1 className="profile-username">
|
||||
{name}
|
||||
@@ -148,34 +155,19 @@ export function ProfileHeader(
|
||||
</p>
|
||||
)}
|
||||
{editable && (
|
||||
emailEditing
|
||||
? (
|
||||
<EmailEditor
|
||||
initialEmail={me?.email ?? ""}
|
||||
onCancel={() => setEmailEditing(false)}
|
||||
onSaved={handleEmailSaved}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<p
|
||||
className="profile-email-display"
|
||||
onClick={() => setEmailEditing(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setEmailEditing(true);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
title={t`Edit email`}
|
||||
>
|
||||
{me?.email ?? t`Add email…`}
|
||||
<span className="profile-description-edit-btn" aria-hidden>
|
||||
✎
|
||||
</span>
|
||||
</p>
|
||||
)
|
||||
<InlineEdit
|
||||
value={me?.email ?? ""}
|
||||
editable
|
||||
label={t`Edit email`}
|
||||
onSave={handleEmailSave}
|
||||
kind="email"
|
||||
required
|
||||
className="profile-email"
|
||||
>
|
||||
<span className="profile-email-text">
|
||||
{me?.email ?? t`Add email…`}
|
||||
</span>
|
||||
</InlineEdit>
|
||||
)}
|
||||
{avatarError && (
|
||||
<ErrorCard
|
||||
|
||||
@@ -11,7 +11,13 @@ import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import { userApiUrl } from "../../utils/shared/urls.ts";
|
||||
import type { Playlist } from "../../../shared/playlist.ts";
|
||||
import {
|
||||
parsePlaylistSort,
|
||||
type Playlist,
|
||||
PLAYLIST_SORTS,
|
||||
type PlaylistSort,
|
||||
} from "../../../shared/playlist.ts";
|
||||
import { playlistSortQuery } from "../../../shared/paths.ts";
|
||||
import type { PublicUser } from "../../../shared/user.ts";
|
||||
import {
|
||||
canLoadMore,
|
||||
@@ -39,6 +45,7 @@ import { useCategoryFilter } from "../../hooks/category/useCategoryFilter.ts";
|
||||
import { CategorySwitcher } from "../category/CategorySwitcher.tsx";
|
||||
import { NewPlaylistButton } from "../playlist/NewPlaylistButton.tsx";
|
||||
import { PlaylistCardList } from "../playlist/PlaylistCardList.tsx";
|
||||
import { PlaylistSortTabs } from "../playlist/PlaylistSortTabs.tsx";
|
||||
import { FeedFooter } from "../feed/FeedFooter.tsx";
|
||||
import { ConfirmModal } from "../shared/ConfirmModal.tsx";
|
||||
import { ErrorCard } from "../shared/ErrorCard.tsx";
|
||||
@@ -53,19 +60,22 @@ export interface ProfilePlaylistsTabProps {
|
||||
|
||||
/**
|
||||
* Renders the tab's list for the playlist category the URL's `?category=`
|
||||
* picks, or every playlist without one. Each category's list is another,
|
||||
* which the tab mounts afresh.
|
||||
* picks, or every playlist without one, in the order its `?sort=` names.
|
||||
* Each category's list in each order is another, which the tab mounts
|
||||
* afresh.
|
||||
*/
|
||||
export function ProfilePlaylistsTab(
|
||||
props: ProfilePlaylistsTabProps,
|
||||
): React.JSX.Element {
|
||||
const [params] = useSearchParams();
|
||||
const categorySlug = params.get("category") || undefined;
|
||||
const sort = parsePlaylistSort(params.get("sort"));
|
||||
return (
|
||||
<PlaylistList
|
||||
key={categorySlug ?? ""}
|
||||
key={`${categorySlug ?? ""}:${sort}`}
|
||||
{...props}
|
||||
categorySlug={categorySlug}
|
||||
sort={sort}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -74,26 +84,28 @@ export function ProfilePlaylistsTab(
|
||||
* Renders the playlists `user` made, from `GET /api/users/:userId/playlists`,
|
||||
* the private ones to their owner only, a page at a time as the list scrolls;
|
||||
* with `categorySlug`, only those in that playlist category, which the menu in
|
||||
* the title row switches. The socket keeps them live, and going back restores
|
||||
* the list and the scroll. The owner can make one, which goes first, and
|
||||
* delete one.
|
||||
* the title row switches, before the tabs that change the order. The socket
|
||||
* keeps them live, and going back restores the list and the scroll. The owner
|
||||
* can make one, which goes first, and delete one.
|
||||
*/
|
||||
function PlaylistList(
|
||||
{ user, isOwnProfile, categorySlug }: ProfilePlaylistsTabProps & {
|
||||
{ user, isOwnProfile, categorySlug, sort }: ProfilePlaylistsTabProps & {
|
||||
categorySlug?: string;
|
||||
sort: PlaylistSort;
|
||||
},
|
||||
): React.JSX.Element {
|
||||
const [, setParams] = useSearchParams();
|
||||
const { user: me, authFetch } = useAuth();
|
||||
const { categories } = useCategories("playlist");
|
||||
const { cached, saveState } = useFeedCache<Playlist>(
|
||||
`feed:profile-playlists:${user.id}${
|
||||
categorySlug ? `:${categorySlug}` : ""
|
||||
}`,
|
||||
`feed:profile-playlists:${user.id}:${categorySlug ?? ""}:${sort}`,
|
||||
hydratePlaylist,
|
||||
);
|
||||
const url = `${userApiUrl(user, "playlists")}${
|
||||
categorySlug ? `?category=${encodeURIComponent(categorySlug)}` : ""
|
||||
playlistSortQuery(
|
||||
sort,
|
||||
new URLSearchParams(categorySlug ? { category: categorySlug } : {}),
|
||||
)
|
||||
}`;
|
||||
const { state, setItems: setAllItems, loadMore, resync } = usePagedList<
|
||||
Playlist
|
||||
@@ -116,6 +128,7 @@ function PlaylistList(
|
||||
usePlaylistListSync(loaded?.items ?? [], setItems, {
|
||||
isOwner: isOwnProfile,
|
||||
ownerId: user.id,
|
||||
sort,
|
||||
});
|
||||
useOnReconnect(resync);
|
||||
|
||||
@@ -149,15 +162,25 @@ function PlaylistList(
|
||||
title={<Trans>Playlists</Trans>}
|
||||
actions={
|
||||
<div className="profile-section-actions">
|
||||
<CategorySwitcher
|
||||
kind="playlist"
|
||||
categorySlug={categorySlug}
|
||||
onSelect={(slug) =>
|
||||
setParams((p) => {
|
||||
if (slug) p.set("category", slug);
|
||||
else p.delete("category");
|
||||
return p;
|
||||
})}
|
||||
<PlaylistSortTabs
|
||||
tabs={PLAYLIST_SORTS}
|
||||
sort={sort}
|
||||
// Another order of the same list, as a tab is: it takes the
|
||||
// current history entry's place.
|
||||
onChange={(s) =>
|
||||
setParams((p) => playlistSortQuery(s, p), { replace: true })}
|
||||
leading={
|
||||
<CategorySwitcher
|
||||
kind="playlist"
|
||||
categorySlug={categorySlug}
|
||||
onSelect={(slug) =>
|
||||
setParams((p) => {
|
||||
if (slug) p.set("category", slug);
|
||||
else p.delete("category");
|
||||
return p;
|
||||
})}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{isOwnProfile && (
|
||||
<NewPlaylistButton
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { useContext, useState } from "react";
|
||||
import { userApiUrl } from "../../utils/shared/urls.ts";
|
||||
import { imageProps, userApiUrl } from "../../utils/shared/urls.ts";
|
||||
import { AvatarOverrideContext } from "../../contexts/ws/AvatarOverrideContext.ts";
|
||||
|
||||
/**
|
||||
@@ -25,8 +25,9 @@ export interface AvatarProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the image from `GET /api/users/:userId/avatar`, or the user's initial
|
||||
* when they have none or it fails to load. An avatar added, changed or removed
|
||||
* Renders the image from `GET /api/users/:userId/avatar`, scaled by the server
|
||||
* to the size it is shown at, or the user's initial when they have none or it
|
||||
* fails to load. An avatar added, changed or removed
|
||||
* since the caller's data was fetched shows at once, through the
|
||||
* `user_updated` messages that `WSProvider` puts in `AvatarOverrideContext`.
|
||||
*/
|
||||
@@ -55,7 +56,7 @@ export function Avatar(
|
||||
: userApiUrl({ id: userId }, "avatar");
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
{...imageProps(src, size)}
|
||||
alt={username}
|
||||
title={username}
|
||||
style={sizeStyle}
|
||||
|
||||
@@ -27,9 +27,9 @@ export interface TabBarProps<T extends string> {
|
||||
/** An extra class for the row. */
|
||||
innerClassName?: string;
|
||||
/**
|
||||
* Optional content rendered inside the row, before the tabs (e.g. a scope
|
||||
* selector). Sits in the same flex line, aligned with them, and stays pinned
|
||||
* at the row's start while they scroll under it.
|
||||
* Optional content rendered before the tabs (e.g. a scope selector). Sits
|
||||
* in the same line, aligned with them, and stays where it is while they
|
||||
* scroll: it is outside the row that scrolls, which clips them at its edge.
|
||||
*/
|
||||
leading?: ReactNode;
|
||||
}
|
||||
@@ -43,7 +43,6 @@ export function TabBar<T extends string>(
|
||||
TabBarProps<T>,
|
||||
): React.JSX.Element {
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
const leadingRef = useRef<HTMLDivElement>(null);
|
||||
const [overflowing, setOverflowing] = useState(false);
|
||||
// Tabs hidden past either edge, which the stylesheet fades only then, so a
|
||||
// fade never dims a tab that is fully in view.
|
||||
@@ -69,19 +68,17 @@ export function TabBar<T extends string>(
|
||||
};
|
||||
}, [tabs.length]);
|
||||
|
||||
// When they don't all fit, keep the active tab in view, between the leading
|
||||
// content pinned over the row's left edge and the fade over its right edge,
|
||||
// scrolling the row alone, never the page.
|
||||
// When they don't all fit, keep the active tab in view, clear of the fade
|
||||
// over the row's right edge, scrolling the row alone, never the page.
|
||||
useLayoutEffect(() => {
|
||||
const row = rowRef.current;
|
||||
const active = row?.querySelector<HTMLElement>(".feed-sort-btn.active");
|
||||
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 =
|
||||
parseFloat(getComputedStyle(document.documentElement).fontSize) * 2;
|
||||
const r = row.getBoundingClientRect(), b = active.getBoundingClientRect();
|
||||
const left = r.left + (leadingRef.current?.offsetWidth ?? 0);
|
||||
if (b.left < left) row.scrollLeft -= left - b.left;
|
||||
if (b.left < r.left) row.scrollLeft -= r.left - b.left;
|
||||
else if (b.right > r.right - fade) {
|
||||
row.scrollLeft += b.right - r.right + fade;
|
||||
}
|
||||
@@ -95,13 +92,11 @@ export function TabBar<T extends string>(
|
||||
className ? ` ${className}` : ""
|
||||
}`}
|
||||
>
|
||||
{leading && <div className="feed-sort-leading">{leading}</div>}
|
||||
<div
|
||||
ref={rowRef}
|
||||
className={`feed-sort${innerClassName ? ` ${innerClassName}` : ""}`}
|
||||
>
|
||||
{leading && (
|
||||
<div ref={leadingRef} className="feed-sort-leading">{leading}</div>
|
||||
)}
|
||||
{tabs.map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { canProxyImage, proxyImageUrl } from "../../utils/shared/proxyImage.ts";
|
||||
import { imageProps, isApiImageUrl } from "../../utils/shared/urls.ts";
|
||||
import {
|
||||
ThumbnailPlaceholder,
|
||||
type ThumbnailPlaceholderProps,
|
||||
@@ -23,6 +24,12 @@ export interface ThumbnailProps {
|
||||
/** Applies to the image only. */
|
||||
className?: string;
|
||||
loading?: "lazy" | "eager";
|
||||
/**
|
||||
* The width the image is laid out at, as `imageProps` takes it. An image
|
||||
* the API serves, the site's own or a proxied one, then loads no larger
|
||||
* than that; another site's loads as that site sends it.
|
||||
*/
|
||||
sizes: number | string;
|
||||
/** Shown when there's no `src` or it fails to load. */
|
||||
placeholder: Omit<ThumbnailPlaceholderProps, "className">;
|
||||
placeholderClassName?: string;
|
||||
@@ -42,6 +49,7 @@ export function Thumbnail(
|
||||
alt = "",
|
||||
className,
|
||||
loading = "lazy",
|
||||
sizes,
|
||||
placeholder,
|
||||
placeholderClassName,
|
||||
}: ThumbnailProps,
|
||||
@@ -64,11 +72,10 @@ export function Thumbnail(
|
||||
);
|
||||
}
|
||||
|
||||
const url = proxyImageUrl(src, { force: attempt === "proxied" });
|
||||
return (
|
||||
<img
|
||||
src={attempt === "proxied"
|
||||
? proxyImageUrl(src, { force: true })
|
||||
: proxyImageUrl(src)}
|
||||
{...(isApiImageUrl(url) ? imageProps(url, sizes) : { src: url })}
|
||||
alt={alt}
|
||||
className={className}
|
||||
loading={loading}
|
||||
|
||||
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. */
|
||||
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. */
|
||||
export const SEARCH_URL = `${API_URL}/api/search`;
|
||||
/** How many items a page of a feed or list asks the API for. */
|
||||
|
||||
44
src/config/imageSizes.ts
Normal file
44
src/config/imageSizes.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* States the width each of the site's images is laid out at, which App.css
|
||||
* sets, so that `imageProps` asks the server for a copy no larger. A number
|
||||
* is a fixed width in CSS pixels, a string a `sizes` attribute for a width
|
||||
* that follows the viewport's. The components that show a thumbnail, a cover
|
||||
* or a player's artwork take theirs from here; an avatar's is its `size`.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { JournalShape } from "../utils/feed/journalLayout.ts";
|
||||
|
||||
/** A tile of the journal's grid spanning one of its columns. */
|
||||
const JOURNAL_COLUMN =
|
||||
"(max-width: 460px) 100vw, (max-width: 680px) 50vw, (max-width: 980px) 33vw, 290px";
|
||||
/** A tile of the journal's grid spanning two of its columns. */
|
||||
const JOURNAL_TWO_COLUMNS =
|
||||
"(max-width: 680px) 100vw, (max-width: 980px) 66vw, 590px";
|
||||
|
||||
/** The width of each image, by the place it is shown. */
|
||||
export const IMAGE_SIZES = {
|
||||
/**
|
||||
* A compact thumbnail at its widest, in a dump card's preview; the rows
|
||||
* that show it smaller take the same copy.
|
||||
*/
|
||||
compactThumbnail: 128,
|
||||
/** A link's thumbnail beside its text, above it on a narrow screen. */
|
||||
richThumbnail: "(max-width: 512px) 100vw, 180px",
|
||||
/** A journal tile's image, by the tile's shape. */
|
||||
journal: {
|
||||
feature: JOURNAL_TWO_COLUMNS,
|
||||
wide: JOURNAL_TWO_COLUMNS,
|
||||
tall: JOURNAL_COLUMN,
|
||||
square: JOURNAL_COLUMN,
|
||||
} satisfies Record<JournalShape, string>,
|
||||
/** A playlist's cover in a row of a list. */
|
||||
playlistThumb: 80,
|
||||
/** A playlist's cover as a tile of the grid, two to a row when narrow. */
|
||||
playlistTile: "(max-width: 512px) 50vw, 250px",
|
||||
/** A playlist's cover as the banner of its page. */
|
||||
playlistBanner: "(max-width: 860px) 100vw, 860px",
|
||||
/** The artwork in the global player's header. */
|
||||
playerArtwork: 36,
|
||||
} as const;
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -50,8 +50,11 @@ export interface PlaylistEvent {
|
||||
* order.
|
||||
*/
|
||||
dumpIds?: string[];
|
||||
/** Set when the dumps changed. It counts private dumps too. */
|
||||
dumpCount?: number;
|
||||
/**
|
||||
* With `dumpIds`, when the latest of them was added; undefined when there
|
||||
* is none.
|
||||
*/
|
||||
lastDumpAt?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,6 +82,16 @@ export interface DumpEvent {
|
||||
dump: Dump;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the related dumps on a dump's page live. `WSProvider` emits it as a
|
||||
* `related` event on each `dump_related_changed`, and the `Dump` page fetches
|
||||
* its related dumps again when `dumpIds` names its dump.
|
||||
*/
|
||||
export interface RelatedEvent {
|
||||
/** The dumps this user may see whose related dumps changed. */
|
||||
dumpIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps a user's profile, and their name on what they posted, up to date.
|
||||
* `WSProvider` emits it as a `user` event on each `user_updated`, which the
|
||||
@@ -98,6 +111,7 @@ export interface UserEvent {
|
||||
export interface WSEvents {
|
||||
vote: VoteEvent;
|
||||
dump: DumpEvent;
|
||||
related: RelatedEvent;
|
||||
playlist: PlaylistEvent;
|
||||
comment: CommentEvent;
|
||||
user: UserEvent;
|
||||
|
||||
@@ -538,6 +538,10 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
break;
|
||||
}
|
||||
|
||||
case "dump_related_changed":
|
||||
events.emit("related", { dumpIds: msg.dumpIds });
|
||||
break;
|
||||
|
||||
case "vote_ack": {
|
||||
const { dumpId, action, voteCount } = msg;
|
||||
// With more clicks in flight, the last ack tells how it ended.
|
||||
@@ -595,12 +599,12 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
}
|
||||
|
||||
case "playlist_dumps_updated": {
|
||||
const { playlistId, dumpIds, dumpCount } = msg;
|
||||
const { playlistId, dumpIds, lastDumpAt } = msg;
|
||||
events.emit("playlist", {
|
||||
type: "dumps_updated",
|
||||
playlistId,
|
||||
dumpIds,
|
||||
dumpCount,
|
||||
lastDumpAt: lastDumpAt ? new Date(lastDumpAt) : undefined,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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-family: 'Instrument Serif';
|
||||
font-style: normal;
|
||||
@@ -99,6 +140,96 @@
|
||||
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: '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 */
|
||||
@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;
|
||||
}
|
||||
|
||||
/* 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-family: 'Fraunces Variable';
|
||||
font-family: 'Bricolage Grotesque Variable';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 100 900;
|
||||
src: url(@fontsource-variable/fraunces/files/fraunces-latin-ext-full-normal.woff2)
|
||||
font-weight: 200 800;
|
||||
src: url(@fontsource-variable/bricolage-grotesque/files/bricolage-grotesque-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: 'Fraunces Variable';
|
||||
font-family: 'Bricolage Grotesque Variable';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 100 900;
|
||||
src: url(@fontsource-variable/fraunces/files/fraunces-latin-full-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)
|
||||
font-weight: 200 800;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { AuthContext } from "../../contexts/auth/AuthContext.ts";
|
||||
import type { AuthResponse, SelfUser } from "../../../shared/user.ts";
|
||||
import type { UserSettings } from "../../../shared/settings.ts";
|
||||
import type { Session } from "../../utils/auth/storage.ts";
|
||||
import { takeEarly } from "../../utils/shared/earlyFetch.ts";
|
||||
import {
|
||||
forgetStoredSession,
|
||||
isTokenExpired,
|
||||
@@ -113,7 +114,13 @@ export function useAuth(): UseAuthResult {
|
||||
|
||||
const isFormData = init.body instanceof FormData;
|
||||
|
||||
const res = await fetch(input, {
|
||||
// The page a visit lands on finds its first request already sent, as the
|
||||
// app booted (utils/shared/earlyFetch.ts).
|
||||
const early = typeof input === "string" &&
|
||||
(init.method ?? "GET") === "GET"
|
||||
? takeEarly(input, token)
|
||||
: undefined;
|
||||
const res = await (early ?? fetch(input, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.headers ?? {}),
|
||||
@@ -121,7 +128,7 @@ export function useAuth(): UseAuthResult {
|
||||
// The browser sets FormData's Content-Type, with its boundary.
|
||||
...(isFormData ? {} : { "Content-Type": "application/json" }),
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
if (res.status === 401) {
|
||||
logout();
|
||||
|
||||
@@ -1,90 +1,21 @@
|
||||
/**
|
||||
* Gives a dump's card, which is a clickable `<li>` and not a link, a link's
|
||||
* behaviour: a middle or modified click opens the dump in a new tab.
|
||||
* Makes a dump's card open the dump as a link would, and mark it visited.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { MouseEvent } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import type { Dump } from "../../../shared/dump.ts";
|
||||
import { dumpUrl } from "../../utils/shared/urls.ts";
|
||||
import { markDumpVisited } from "../../utils/shared/visited.ts";
|
||||
|
||||
/** Checks whether `e` happened over an anchor, which keeps its own target. */
|
||||
function isOverLink(e: MouseEvent) {
|
||||
return (e.target as Element).closest("a") !== null;
|
||||
}
|
||||
|
||||
function wantsNewTab(e: MouseEvent) {
|
||||
return e.ctrlKey || e.metaKey || e.shiftKey;
|
||||
}
|
||||
import { useCardLink, type UseCardLinkResult } from "../shared/useCardLink.ts";
|
||||
|
||||
/**
|
||||
* Returned by {@link useDumpCardLink} to `DumpCard` and `JournalCard`. Every
|
||||
* way it opens the dump marks it visited, which clears its unread dot.
|
||||
*/
|
||||
export interface UseDumpCardLinkResult {
|
||||
/** Opens the dump in this tab, for a handler that would rather not play. */
|
||||
go: () => void;
|
||||
/**
|
||||
* Opens the dump in a new tab and returns true when `e` is a ctrl, cmd or
|
||||
* shift click away from any anchor. A click handler that plays media calls
|
||||
* it first, so that such a click opens the dump instead.
|
||||
*/
|
||||
handledAsNewTab: (e: MouseEvent) => boolean;
|
||||
/** Spread onto the card element. */
|
||||
cardProps: {
|
||||
onClick: (e: MouseEvent) => void;
|
||||
onMouseDown: (e: MouseEvent) => void;
|
||||
onAuxClick: (e: MouseEvent) => void;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the handlers that make the card of `dump` open it: in this tab on a
|
||||
* click, in a new tab on a middle click or a ctrl, cmd or shift click. Anchors
|
||||
* inside the card, such as the title or a category, keep their own target.
|
||||
* Returns `useCardLink`'s handlers for the card of `dump`, for `DumpCard` and
|
||||
* `JournalCard`: every way they open the dump marks it visited, which clears
|
||||
* its unread dot.
|
||||
*/
|
||||
export function useDumpCardLink(
|
||||
dump: Pick<Dump, "id" | "slug" | "ownerUsername">,
|
||||
): UseDumpCardLinkResult {
|
||||
const navigate = useNavigate();
|
||||
|
||||
function openInNewTab() {
|
||||
markDumpVisited(dump.id);
|
||||
globalThis.open(dumpUrl(dump), "_blank", "noopener");
|
||||
}
|
||||
|
||||
function go() {
|
||||
markDumpVisited(dump.id);
|
||||
navigate(dumpUrl(dump));
|
||||
}
|
||||
|
||||
return {
|
||||
go,
|
||||
handledAsNewTab(e: MouseEvent) {
|
||||
if (!wantsNewTab(e) || isOverLink(e)) return false;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openInNewTab();
|
||||
return true;
|
||||
},
|
||||
cardProps: {
|
||||
onClick(e: MouseEvent) {
|
||||
if (wantsNewTab(e)) openInNewTab();
|
||||
else go();
|
||||
},
|
||||
// Windows and Linux browsers start autoscroll on a middle press, which
|
||||
// eats the auxclick that follows.
|
||||
onMouseDown(e: MouseEvent) {
|
||||
if (e.button === 1 && !isOverLink(e)) e.preventDefault();
|
||||
},
|
||||
onAuxClick(e: MouseEvent) {
|
||||
if (e.button !== 1 || isOverLink(e)) return;
|
||||
e.preventDefault();
|
||||
openInNewTab();
|
||||
},
|
||||
},
|
||||
};
|
||||
): UseCardLinkResult {
|
||||
return useCardLink(dumpUrl(dump), () => markDumpVisited(dump.id));
|
||||
}
|
||||
|
||||
@@ -7,10 +7,14 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
import type { Playlist } from "../../../shared/playlist.ts";
|
||||
import {
|
||||
comparePlaylists,
|
||||
type Playlist,
|
||||
type PlaylistSort,
|
||||
} from "../../../shared/playlist.ts";
|
||||
import { useWS, useWSEvent } from "../ws/useWS.ts";
|
||||
import { insertAt, usePositionMemory } from "../feed/usePositionMemory.ts";
|
||||
import { withByDate } from "../../utils/feed/pagedList.ts";
|
||||
import { withInOrder } from "../../utils/feed/pagedList.ts";
|
||||
|
||||
/** Passed to {@link usePlaylistListSync} by each page, to fit its list. */
|
||||
export interface PlaylistListSyncOptions {
|
||||
@@ -22,20 +26,27 @@ export interface PlaylistListSyncOptions {
|
||||
*/
|
||||
ownerId?: string;
|
||||
/**
|
||||
* Adds no new playlist, for a list of followed playlists, since the socket
|
||||
* doesn't say what the user follows, or of search results. Playlists
|
||||
* already listed still change, go, and come back where they were.
|
||||
* The order the list is in, for one the API sorts: the playlists page's and
|
||||
* a profile's. A list in an order of its own leaves it out, the followed
|
||||
* playlists being by the date they were followed and a search's results
|
||||
* the search's: it takes no new playlist, since the socket says neither
|
||||
* what the user follows nor what a search matches, and moves none.
|
||||
* Playlists already listed still change, go, and come back where they
|
||||
* were.
|
||||
*/
|
||||
noNewEntries?: boolean;
|
||||
sort?: PlaylistSort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the socket's playlist events to `items`, the loaded list, newest
|
||||
* first, which `setPlaylists` updates. A created playlist goes where its date
|
||||
* puts it, first when new, an updated one changes in place or leaves when it
|
||||
* turns private (unless `isOwner`), one that left comes back where it was
|
||||
* when it returns, a deleted one leaves, and a playlist's `dumpCount` follows
|
||||
* its dumps. A renamed user's playlists take their new name.
|
||||
* Applies the socket's playlist events to `items`, the loaded list, which
|
||||
* `setPlaylists` updates. A created playlist goes where the list's order puts
|
||||
* it, an updated one changes in place or leaves when it turns private (unless
|
||||
* `isOwner`), one that left comes back where it was when it returns, a
|
||||
* deleted one leaves, and a playlist's `dumpCount` and `lastDumpAt` follow
|
||||
* its dumps. One that a change brings up the order moves up to its place, as
|
||||
* a playlist does in the `updated` order when it takes a dump; one that falls
|
||||
* stays where it is, its place being perhaps on a page not loaded. A renamed
|
||||
* user's playlists take their new name.
|
||||
*/
|
||||
export function usePlaylistListSync(
|
||||
items: Playlist[],
|
||||
@@ -53,7 +64,14 @@ export function usePlaylistListSync(
|
||||
});
|
||||
|
||||
useWSEvent("playlist", (ev) => {
|
||||
const { isOwner, ownerId, noNewEntries } = options ?? {};
|
||||
const { isOwner, ownerId, sort } = options ?? {};
|
||||
const compare = sort && comparePlaylists(sort);
|
||||
// Returns `list` with the playlist at `idx` before the ones it now comes
|
||||
// before, if it rose.
|
||||
const risen = (list: Playlist[], idx: number) =>
|
||||
compare && idx > 0 && compare(list[idx], list[idx - 1]) < 0
|
||||
? withInOrder(list.toSpliced(idx, 1), list[idx], compare)
|
||||
: list;
|
||||
const shown = ev.playlist &&
|
||||
(ev.playlist.isPublic || isOwner) &&
|
||||
(!ownerId || ev.playlist.userId === ownerId);
|
||||
@@ -66,15 +84,13 @@ export function usePlaylistListSync(
|
||||
const idx = prev.findIndex((p) => p.id === playlist.id);
|
||||
if (idx !== -1) {
|
||||
if (!shown) return prev.filter((p) => p.id !== playlist.id);
|
||||
const next = [...prev];
|
||||
next[idx] = playlist;
|
||||
return next;
|
||||
return risen(prev.with(idx, playlist), idx);
|
||||
}
|
||||
if (!shown) return prev;
|
||||
if (oldIdx !== undefined) return insertAt(prev, playlist, oldIdx);
|
||||
// An update never adds a playlist the list doesn't hold.
|
||||
if (ev.type === "updated" || noNewEntries) return prev;
|
||||
return withByDate(prev, playlist);
|
||||
if (ev.type === "updated" || !compare) return prev;
|
||||
return withInOrder(prev, playlist, compare);
|
||||
}
|
||||
|
||||
if (ev.type === "deleted") {
|
||||
@@ -84,12 +100,13 @@ export function usePlaylistListSync(
|
||||
if (ev.type === "dumps_updated" && ev.dumpIds) {
|
||||
const idx = prev.findIndex((p) => p.id === ev.playlistId);
|
||||
if (idx === -1) return prev;
|
||||
const next = [...prev];
|
||||
next[idx] = {
|
||||
...next[idx],
|
||||
dumpCount: ev.dumpCount ?? ev.dumpIds.length,
|
||||
// The dumps this user may see, which are those the playlist counts.
|
||||
const playlist = {
|
||||
...prev[idx],
|
||||
dumpCount: ev.dumpIds.length,
|
||||
lastDumpAt: ev.lastDumpAt,
|
||||
};
|
||||
return next;
|
||||
return risen(prev.with(idx, playlist), idx);
|
||||
}
|
||||
|
||||
return prev;
|
||||
|
||||
91
src/hooks/shared/useCardLink.ts
Normal file
91
src/hooks/shared/useCardLink.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Gives a dump's or a playlist's card, which is a clickable `<li>` and not a
|
||||
* link, a link's behaviour: a middle or modified click opens its page in a
|
||||
* new tab.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { MouseEvent } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
/** Checks whether `e` happened over an anchor, which keeps its own target. */
|
||||
function isOverLink(e: MouseEvent) {
|
||||
return (e.target as Element).closest("a") !== null;
|
||||
}
|
||||
|
||||
function wantsNewTab(e: MouseEvent) {
|
||||
return e.ctrlKey || e.metaKey || e.shiftKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returned by {@link useCardLink} to `PlaylistCard`, and through
|
||||
* `useDumpCardLink` to `DumpCard` and `JournalCard`.
|
||||
*/
|
||||
export interface UseCardLinkResult {
|
||||
/** Opens the page in this tab, for a handler that would rather not play. */
|
||||
go: () => void;
|
||||
/**
|
||||
* Opens the page in a new tab and returns true when `e` is a ctrl, cmd or
|
||||
* shift click away from any anchor. A click handler that plays media calls
|
||||
* it first, so that such a click opens the page instead.
|
||||
*/
|
||||
handledAsNewTab: (e: MouseEvent) => boolean;
|
||||
/** Spread onto the card element. */
|
||||
cardProps: {
|
||||
onClick: (e: MouseEvent) => void;
|
||||
onMouseDown: (e: MouseEvent) => void;
|
||||
onAuxClick: (e: MouseEvent) => void;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the handlers that make a card open the page at `url`: in this tab
|
||||
* on a click, in a new tab on a middle click or a ctrl, cmd or shift click.
|
||||
* Every way it opens the page calls `onOpen` first, which marks the item
|
||||
* visited. Anchors inside the card, such as the title or a category, keep
|
||||
* their own target.
|
||||
*/
|
||||
export function useCardLink(
|
||||
url: string,
|
||||
onOpen: () => void,
|
||||
): UseCardLinkResult {
|
||||
const navigate = useNavigate();
|
||||
|
||||
function openInNewTab() {
|
||||
onOpen();
|
||||
globalThis.open(url, "_blank", "noopener");
|
||||
}
|
||||
|
||||
function go() {
|
||||
onOpen();
|
||||
navigate(url);
|
||||
}
|
||||
|
||||
return {
|
||||
go,
|
||||
handledAsNewTab(e: MouseEvent) {
|
||||
if (!wantsNewTab(e) || isOverLink(e)) return false;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openInNewTab();
|
||||
return true;
|
||||
},
|
||||
cardProps: {
|
||||
onClick(e: MouseEvent) {
|
||||
if (wantsNewTab(e)) openInNewTab();
|
||||
else go();
|
||||
},
|
||||
// Windows and Linux browsers start autoscroll on a middle press, which
|
||||
// eats the auxclick that follows.
|
||||
onMouseDown(e: MouseEvent) {
|
||||
if (e.button === 1 && !isOverLink(e)) e.preventDefault();
|
||||
},
|
||||
onAuxClick(e: MouseEvent) {
|
||||
if (e.button !== 1 || isOverLink(e)) return;
|
||||
e.preventDefault();
|
||||
openInNewTab();
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -51,7 +51,8 @@ export function useRoomBeside(
|
||||
|
||||
// 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
|
||||
// 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 mutation = new MutationObserver(observe);
|
||||
function observe() {
|
||||
@@ -60,8 +61,13 @@ export function useRoomBeside(
|
||||
resize.observe(container!);
|
||||
const kept = container!.querySelector<HTMLElement>(beside!);
|
||||
if (kept) {
|
||||
for (const child of kept.children) resize.observe(child);
|
||||
mutation.observe(kept, { childList: true });
|
||||
for (const child of kept.children) {
|
||||
resize.observe(child);
|
||||
if (scrolls(child)) {
|
||||
for (const inner of child.children) resize.observe(inner);
|
||||
}
|
||||
}
|
||||
mutation.observe(kept, { childList: true, subtree: true });
|
||||
}
|
||||
measure();
|
||||
}
|
||||
@@ -75,17 +81,26 @@ export function useRoomBeside(
|
||||
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,
|
||||
* 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
|
||||
* 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;
|
||||
if (!first || !last) return 0;
|
||||
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) +
|
||||
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;
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import { isLocale, type Locale } from "../shared/locale.ts";
|
||||
|
||||
/**
|
||||
* Returns the stored locale, else French for a French browser, else English.
|
||||
* The built page asks for that locale's messages ahead of this module, by the
|
||||
* same rule (`preloadCatalog` in vite.config.ts): the two must agree.
|
||||
*/
|
||||
function detectLocale(): Locale {
|
||||
const stored = localStorage.getItem("locale");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Starts the frontend. It loads the stylesheets and the interface's language,
|
||||
* then mounts `App` on index.html's `#root`.
|
||||
* sends the first request of the page the visit lands on, then mounts `App`
|
||||
* on index.html's `#root`.
|
||||
*
|
||||
* A deploy renames every chunk, so a tab opened before it fails to load the
|
||||
* lazy chunks it hasn't fetched yet. It then reloads to pick up the new build,
|
||||
@@ -17,6 +18,7 @@ import { I18nProvider } from "@lingui/react";
|
||||
|
||||
import { App } from "./App.tsx";
|
||||
import { loadCatalog } from "./i18n.ts";
|
||||
import { startLandingRequest } from "./utils/shared/earlyFetch.ts";
|
||||
|
||||
import "./fonts.css";
|
||||
import "./tokens.css";
|
||||
@@ -42,6 +44,10 @@ globalThis.addEventListener("vite:preloadError", (event) => {
|
||||
location.reload();
|
||||
});
|
||||
|
||||
// The page this visit lands on is loaded with its route, and asks the API for
|
||||
// its content once loaded: that request leaves now, with the page's code.
|
||||
startLandingRequest(location.pathname);
|
||||
|
||||
// Before the first render, or it would show untranslated message ids.
|
||||
await loadCatalog();
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user