Compare commits
8 Commits
a2ec163ee5
...
de223baaa4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de223baaa4 | ||
|
|
561cdf98b0 | ||
|
|
350e945eb1 | ||
|
|
dde0232acb | ||
|
|
7dd00068df | ||
|
|
28f910b00e | ||
|
|
ffc071af4c | ||
|
|
1d56c323a4 |
20
API.md
20
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`)
|
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
|
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=`,
|
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 kind that list holds. The order of a list of playlists is `?sort=`, the
|
||||||
of the current history entry; picking a category, or opening another page or an
|
default order being the bare path, and the playlists page's list of the viewer's
|
||||||
editor, adds one.
|
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
|
**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
|
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` |
|
| `/` | `/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` |
|
| `/<category>` | `/<category>/hot`, `/new`, `/journal`, `/follow`, `/follow/playlists` | anyone, `/follow` signed in | `Index` |
|
||||||
| `/@<name>` | the overview: dumps beside upvoted | anyone | `UserPublicProfile` |
|
| `/@<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>/follow` | `/@<name>/follow/playlists`; the bare tab lists users | anyone | `UserPublicProfile` |
|
||||||
| `/@<name>/manage` | | `user:manage`, on others' profiles | `UserPublicProfile` |
|
| `/@<name>/manage` | | `user:manage`, on others' profiles | `UserPublicProfile` |
|
||||||
| `/@<name>/dumps/<slug>` | | anyone; a private dump, its poster | `Dump` |
|
| `/@<name>/dumps/<slug>` | | anyone; a private dump, its poster | `Dump` |
|
||||||
| `/@<name>/dumps/<slug>/edit` | | its poster, `dump:moderate` | `DumpEdit` |
|
| `/@<name>/dumps/<slug>/edit` | | its poster, `dump:moderate` | `DumpEdit` |
|
||||||
| `/@<name>/playlists/<slug>` | | anyone; a private playlist, its owner | `PlaylistDetail` |
|
| `/@<name>/playlists/<slug>` | | anyone; a private playlist, its owner | `PlaylistDetail` |
|
||||||
| `/@<name>/playlists/<slug>/edit` | | its owner, `playlist:moderate` | `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` |
|
| `/search?q=` | `/search/users?q=`, `/search/playlists?q=`; `?category=` narrows the dumps or the playlists | anyone | `Search` |
|
||||||
| `/notifications` | | signed in | `Notifications` |
|
| `/notifications` | | signed in | `Notifications` |
|
||||||
| `/community` | `/community/monthly`; the bare path is the week | signed in | `Community` |
|
| `/community` | `/community/monthly`; the bare path is the week | signed in | `Community` |
|
||||||
@@ -135,7 +137,7 @@ there: those endpoints answer it 400.
|
|||||||
- `GET /api/users/:key/avatar` — anyone. The avatar.
|
- `GET /api/users/:key/avatar` — anyone. The avatar.
|
||||||
- `GET /api/users/:key/dumps`, `/upvoted`, `/playlists` — anyone; private items
|
- `GET /api/users/:key/dumps`, `/upvoted`, `/playlists` — anyone; private items
|
||||||
to their owner only. Their lists; `?category=` narrows one to a category of
|
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
|
- `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.
|
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
|
- `GET /api/users/:key/follow/users`, `/follow/playlists` — anyone. What they
|
||||||
@@ -174,8 +176,10 @@ there: those endpoints answer it 400.
|
|||||||
|
|
||||||
- `POST /api/playlists` — signed in. Creates a playlist, filed under the
|
- `POST /api/playlists` — signed in. Creates a playlist, filed under the
|
||||||
playlist categories it names.
|
playlist categories it names.
|
||||||
- `GET /api/playlists` — anyone. Every public playlist, newest first;
|
- `GET /api/playlists` — anyone. Every public playlist; `?category=<slug>`
|
||||||
`?category=<slug>` narrows it to a playlist category.
|
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.
|
- `GET /api/playlists/:id` — anyone who may see it. A playlist and its dumps.
|
||||||
- `PUT`, `DELETE /api/playlists/:id` — its owner, `playlist:moderate`. Title,
|
- `PUT`, `DELETE /api/playlists/:id` — its owner, `playlist:moderate`. Title,
|
||||||
slug, description, visibility, categories; deletion.
|
slug, description, visibility, categories; deletion.
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Decides which of a playlist's dumps a viewer may see. The playlist service
|
* 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
|
* filters its listings with it, the WebSocket service filters each client's
|
||||||
* client's `playlist_dumps_updated` message the same way.
|
* `playlist_dumps_updated` message the same way, and the queries that list
|
||||||
|
* playlists count their dumps by the same rule.
|
||||||
*
|
*
|
||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
@@ -34,3 +35,53 @@ export function isMemberVisibleTo(
|
|||||||
return !member.isPrivate ||
|
return !member.isPrivate ||
|
||||||
(member.userId === viewerId && viewerId === playlist.userId);
|
(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import type { Playlist } from "../../shared/playlist.ts";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returned by the playlist and follow services' `SELECT`s on `playlists`,
|
* 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
|
* which join the owner's name as `owner_username`. A query may add the
|
||||||
* `dump_count` column, which {@link playlistRowToApi} carries over when
|
* `dump_count` and `last_dump_at` columns (`VISIBLE_DUMP_FIGURES`), which
|
||||||
* present.
|
* {@link playlistRowToApi} carries over when present.
|
||||||
*/
|
*/
|
||||||
export interface PlaylistRow {
|
export interface PlaylistRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -62,6 +62,9 @@ export function playlistRowToApi(row: PlaylistRow): Playlist {
|
|||||||
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
|
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
|
||||||
imageMime: row.image_mime ?? undefined,
|
imageMime: row.image_mime ?? undefined,
|
||||||
dumpCount: typeof row.dump_count === "number" ? row.dump_count : 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,
|
ownerUsername: row.owner_username,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
* playlist's id; its page reads it by its path, through
|
* playlist's id; its page reads it by its path, through
|
||||||
* `GET /api/users/:key/playlists/:slug`.
|
* `GET /api/users/:key/playlists/:slug`.
|
||||||
*
|
*
|
||||||
* - `GET /api/playlists?category=&page=&limit=` — public. Answers a page of
|
* - `GET /api/playlists?category=&sort=&page=&limit=` — public. Answers a
|
||||||
* every public playlist, newest first, or only those in the playlist
|
* page of every public playlist, in the order `sort` names
|
||||||
* category at `category`, which 404s when there is none.
|
* (`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
|
* - `POST /api/playlists` — signed in. Creates a playlist, filed under the
|
||||||
* playlist categories it names.
|
* playlist categories it names.
|
||||||
* - `GET /api/playlists/:playlistId` — optional sign-in. Answers a playlist
|
* - `GET /api/playlists/:playlistId` — optional sign-in. Answers a playlist
|
||||||
@@ -44,7 +46,11 @@ import {
|
|||||||
isReorderPlaylistRequest,
|
isReorderPlaylistRequest,
|
||||||
isUpdatePlaylistRequest,
|
isUpdatePlaylistRequest,
|
||||||
} from "../validators/playlist.ts";
|
} 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 { authMiddleware, type AuthState } from "../middleware/auth.ts";
|
||||||
import { can } from "../lib/permissions.ts";
|
import { can } from "../lib/permissions.ts";
|
||||||
import {
|
import {
|
||||||
@@ -77,12 +83,19 @@ const router: Router<AuthState> = new Router<AuthState>({
|
|||||||
prefix: "/api/playlists",
|
prefix: "/api/playlists",
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/", (ctx) => {
|
router.get("/", async (ctx) => {
|
||||||
|
const requestingUserId = await parseOptionalAuth(ctx);
|
||||||
const params = ctx.request.url.searchParams;
|
const params = ctx.request.url.searchParams;
|
||||||
const { page, limit } = parsePagination(params);
|
const { page, limit } = parsePagination(params);
|
||||||
const slug = params.get("category");
|
const slug = params.get("category");
|
||||||
const categoryId = slug ? getCategoryBySlug("playlist", slug).id : undefined;
|
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>> = {
|
const responseBody: APIResponse<PaginatedData<Playlist>> = {
|
||||||
success: true,
|
success: true,
|
||||||
data: { items, hasMore },
|
data: { items, hasMore },
|
||||||
|
|||||||
@@ -19,9 +19,10 @@
|
|||||||
* - `GET /api/users/:key/upvoted?page=&limit=&category=` — optional sign-in.
|
* - `GET /api/users/:key/upvoted?page=&limit=&category=` — optional sign-in.
|
||||||
* Answers a page of the dumps they upvoted, private ones only to
|
* Answers a page of the dumps they upvoted, private ones only to
|
||||||
* themselves, optionally in one category.
|
* 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
|
* 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
|
* - `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
|
* 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.
|
* the caller may see; a private one 404s but for its owner.
|
||||||
@@ -56,7 +57,11 @@ import {
|
|||||||
type UserSuggestion,
|
type UserSuggestion,
|
||||||
} from "../../shared/user.ts";
|
} from "../../shared/user.ts";
|
||||||
import type { Dump } from "../../shared/dump.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 {
|
import {
|
||||||
fieldOf,
|
fieldOf,
|
||||||
isRegisterUserRequest,
|
isRegisterUserRequest,
|
||||||
@@ -210,7 +215,14 @@ router.get("/:key/playlists", async (ctx) => {
|
|||||||
const categoryId = resolveCategoryFilter("playlist", params);
|
const categoryId = resolveCategoryFilter("playlist", params);
|
||||||
const { items, hasMore } = categoryId === "missing"
|
const { items, hasMore } = categoryId === "missing"
|
||||||
? { items: [], hasMore: false }
|
? { 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>> = {
|
const responseBody: APIResponse<PaginatedData<Playlist>> = {
|
||||||
success: true,
|
success: true,
|
||||||
data: { items, hasMore },
|
data: { items, hasMore },
|
||||||
@@ -224,7 +236,7 @@ router.get("/:key/playlists/:slug", async (ctx) => {
|
|||||||
const responseBody: APIResponse<PlaylistWithDumps> = {
|
const responseBody: APIResponse<PlaylistWithDumps> = {
|
||||||
success: true,
|
success: true,
|
||||||
data: getPlaylist(
|
data: getPlaylist(
|
||||||
getPlaylistByPath(user.id, ctx.params.slug),
|
getPlaylistByPath(user.id, ctx.params.slug, requestingUserId),
|
||||||
requestingUserId,
|
requestingUserId,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
@@ -245,10 +257,16 @@ router.get("/:key/follow/users", (ctx) => {
|
|||||||
ctx.response.body = responseBody;
|
ctx.response.body = responseBody;
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/:key/follow/playlists", (ctx) => {
|
router.get("/:key/follow/playlists", async (ctx) => {
|
||||||
const user = resolveUserKey(ctx.params.key);
|
const user = resolveUserKey(ctx.params.key);
|
||||||
|
const requestingUserId = await parseOptionalAuth(ctx);
|
||||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
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>> = {
|
const responseBody: APIResponse<PaginatedData<Playlist>> = {
|
||||||
success: true,
|
success: true,
|
||||||
data: { items, hasMore },
|
data: { items, hasMore },
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Records which dumps a dump's note and comments link to, and finds the dumps
|
* 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
|
* 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
|
* @module
|
||||||
*/
|
*/
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
import { parsePagePath } from "../../shared/paths.ts";
|
import { parsePagePath } from "../../shared/paths.ts";
|
||||||
import { canonicalizeUrl } from "../lib/canonical-url.ts";
|
import { canonicalizeUrl } from "../lib/canonical-url.ts";
|
||||||
import { parseRows } from "../model/rows.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
|
* 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;
|
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
|
* 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
|
* 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
|
* when it is the URL a dump was posted with, compared by `canonicalizeUrl` as
|
||||||
* reposts are. The dump and
|
* reposts are. The dump and
|
||||||
* comment services call it on every save, and with an empty `text` when a
|
* 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(
|
export function relinkBacklinks(
|
||||||
sourceType: "dump_comment" | "comment",
|
sourceType: "dump_comment" | "comment",
|
||||||
@@ -90,7 +129,11 @@ export function relinkBacklinks(
|
|||||||
|
|
||||||
targetIds.delete(fromDumpId);
|
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(
|
db.prepare(
|
||||||
`DELETE FROM dump_backlinks WHERE source_type = ? AND source_id = ?;`,
|
`DELETE FROM dump_backlinks WHERE source_type = ? AND source_id = ?;`,
|
||||||
).run(sourceType, sourceId);
|
).run(sourceType, sourceId);
|
||||||
@@ -102,7 +145,13 @@ export function relinkBacklinks(
|
|||||||
for (const toDumpId of targetIds) {
|
for (const toDumpId of targetIds) {
|
||||||
insert.run(sourceType, sourceId, fromDumpId, toDumpId, createdAt);
|
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),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ import {
|
|||||||
VALIDATION,
|
VALIDATION,
|
||||||
} from "../../shared/validation.ts";
|
} from "../../shared/validation.ts";
|
||||||
import { linkAttachments } from "./attachment-service.ts";
|
import { linkAttachments } from "./attachment-service.ts";
|
||||||
import { relinkBacklinks } from "./backlink-service.ts";
|
import { announceLinksNowPublic, relinkBacklinks } from "./backlink-service.ts";
|
||||||
import {
|
import {
|
||||||
assertCategoriesExist,
|
assertCategoriesExist,
|
||||||
attachCategoryIds,
|
attachCategoryIds,
|
||||||
@@ -96,7 +96,8 @@ function assertFileDumpText(title?: string, comment?: string): void {
|
|||||||
* `broadcastDumpChange` for the dump itself, and, when it was deleted or
|
* `broadcastDumpChange` for the dump itself, and, when it was deleted or
|
||||||
* changed visibility, the dumps of each playlist holding it, which moved for
|
* changed visibility, the dumps of each playlist holding it, which moved for
|
||||||
* their viewers. A deletion passes the `holders` it read before the cascade
|
* 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(
|
function announceDumpChange(
|
||||||
before: Dump | null,
|
before: Dump | null,
|
||||||
@@ -106,6 +107,7 @@ function announceDumpChange(
|
|||||||
broadcastDumpChange(before, after);
|
broadcastDumpChange(before, after);
|
||||||
if (!before || (after && after.isPrivate === before.isPrivate)) return;
|
if (!before || (after && after.isPrivate === before.isPrivate)) return;
|
||||||
broadcastHoldersDumps(holders ?? playlistsHolding(before.id));
|
broadcastHoldersDumps(holders ?? playlistsHolding(before.id));
|
||||||
|
if (after && !after.isPrivate) announceLinksNowPublic(after.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from "../model/dump.ts";
|
} from "../model/dump.ts";
|
||||||
import { isFollowRow } from "../model/follow.ts";
|
import { isFollowRow } from "../model/follow.ts";
|
||||||
import { isPlaylistRow, playlistRowToApi } from "../model/playlist.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 { type Page, pageOf } from "../lib/pagination.ts";
|
||||||
import { attachCategoryIds, categoryJoin } from "./category-service.ts";
|
import { attachCategoryIds, categoryJoin } from "./category-service.ts";
|
||||||
import { sendToUser } from "./ws-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
|
* 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.
|
* @throws {APIException} 500 when a row doesn't parse as a playlist.
|
||||||
*/
|
*/
|
||||||
export function getFollowedPlaylistsByUser(
|
export function getFollowedPlaylistsByUser(
|
||||||
userId: string,
|
userId: string,
|
||||||
|
requestingUserId: string | null,
|
||||||
page: number,
|
page: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
): Page<Playlist> {
|
): Page<Playlist> {
|
||||||
@@ -245,7 +248,7 @@ export function getFollowedPlaylistsByUser(
|
|||||||
|
|
||||||
const rawRows = db.prepare(
|
const rawRows = db.prepare(
|
||||||
`SELECT p.*, u.username as owner_username,
|
`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
|
FROM playlists p
|
||||||
LEFT JOIN users u ON u.id = p.user_id
|
LEFT JOIN users u ON u.id = p.user_id
|
||||||
INNER JOIN follows f ON f.followed_playlist_id = p.id
|
INNER JOIN follows f ON f.followed_playlist_id = p.id
|
||||||
@@ -253,7 +256,7 @@ export function getFollowedPlaylistsByUser(
|
|||||||
AND p.is_public = 1
|
AND p.is_public = 1
|
||||||
ORDER BY f.created_at DESC
|
ORDER BY f.created_at DESC
|
||||||
LIMIT ? OFFSET ?;`,
|
LIMIT ? OFFSET ?;`,
|
||||||
).all(userId, limit + 1, offset);
|
).all(requestingUserId ?? "", userId, limit + 1, offset);
|
||||||
|
|
||||||
if (!rawRows.every(isPlaylistRow)) {
|
if (!rawRows.every(isPlaylistRow)) {
|
||||||
throw new APIException(500, "Malformed playlist data");
|
throw new APIException(500, "Malformed playlist data");
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
CreatePlaylistRequest,
|
CreatePlaylistRequest,
|
||||||
Playlist,
|
Playlist,
|
||||||
PlaylistMembership,
|
PlaylistMembership,
|
||||||
|
PlaylistSort,
|
||||||
PlaylistWithDumps,
|
PlaylistWithDumps,
|
||||||
UpdatePlaylistRequest,
|
UpdatePlaylistRequest,
|
||||||
} from "../../shared/playlist.ts";
|
} from "../../shared/playlist.ts";
|
||||||
@@ -42,21 +43,41 @@ import {
|
|||||||
setCategories,
|
setCategories,
|
||||||
} from "./category-service.ts";
|
} from "./category-service.ts";
|
||||||
import {
|
import {
|
||||||
|
type DatedPlaylistMember,
|
||||||
isMemberVisibleTo,
|
isMemberVisibleTo,
|
||||||
type PlaylistMember,
|
VISIBLE_DUMP_FIGURES,
|
||||||
} from "../lib/playlist-members.ts";
|
} from "../lib/playlist-members.ts";
|
||||||
import { LIKE, likePattern } from "../lib/like.ts";
|
import { LIKE, likePattern } from "../lib/like.ts";
|
||||||
import { parseRows } from "../model/rows.ts";
|
import { parseRows } from "../model/rows.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The select list and `FROM` clause of a playlist query, adding the owner's
|
* 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
|
* name, the count of the dumps the viewer may see and when the latest was
|
||||||
* dumps the viewer can't see.
|
* 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,
|
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`;
|
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
|
* Converts the rows of a {@link PLAYLIST_SELECT} to playlists, their
|
||||||
* categories filled in.
|
* categories filled in.
|
||||||
@@ -69,13 +90,18 @@ function playlistsOf(rows: unknown[]): Playlist[] {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns playlist `playlistId`, private or not. A caller that shows it to
|
* 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.
|
* @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 = ?;`)
|
const row = db.prepare(`SELECT ${PLAYLIST_SELECT} WHERE p.id = ?;`)
|
||||||
.get(playlistId);
|
.get(viewerParam(viewerId), playlistId);
|
||||||
if (!row || !isPlaylistRow(row)) {
|
if (!row || !isPlaylistRow(row)) {
|
||||||
throw new APIException(404, "Playlist not found");
|
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
|
* 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
|
* not, or throws a 404, as {@link getPlaylistById} does, counting its dumps
|
||||||
* slug, it comes with its current one.
|
* 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);
|
const playlistId = resolveSlug("playlist", userId, slug);
|
||||||
if (!playlistId) throw new APIException(404, "Playlist not found");
|
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);
|
linkAttachments(req.description, "playlist", id);
|
||||||
}
|
}
|
||||||
broadcastPlaylistChange(null, playlist);
|
broadcastPlaylistChange(null, playlist, []);
|
||||||
return playlist;
|
return playlist;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +175,8 @@ export function createPlaylist(
|
|||||||
* Returns a playlist with the dumps `requestingUserId` may see in it, in
|
* Returns a playlist with the dumps `requestingUserId` may see in it, in
|
||||||
* order, for `GET /api/playlists/:playlistId`, and for
|
* order, for `GET /api/playlists/:playlistId`, and for
|
||||||
* `GET /api/users/:key/playlists/:slug` once it has found the playlist by its
|
* `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
|
* @throws {APIException} 404 when there is no such playlist, or it is private
|
||||||
* and not theirs.
|
* and not theirs.
|
||||||
@@ -154,7 +186,7 @@ export function getPlaylist(
|
|||||||
requestingUserId: string | null,
|
requestingUserId: string | null,
|
||||||
): PlaylistWithDumps {
|
): PlaylistWithDumps {
|
||||||
const playlist = typeof playlistId === "string"
|
const playlist = typeof playlistId === "string"
|
||||||
? getPlaylistById(playlistId)
|
? getPlaylistById(playlistId, requestingUserId)
|
||||||
: playlistId;
|
: playlistId;
|
||||||
|
|
||||||
if (!playlist.isPublic && requestingUserId !== playlist.userId) {
|
if (!playlist.isPublic && requestingUserId !== playlist.userId) {
|
||||||
@@ -175,7 +207,7 @@ export function getPlaylist(
|
|||||||
isMemberVisibleTo(d, playlist, requestingUserId)
|
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
|
const rows = requestingUserId
|
||||||
? db.prepare(
|
? db.prepare(
|
||||||
`SELECT ${PLAYLIST_SELECT} ${catJoin} WHERE (p.is_public = 1 OR p.user_id = ?) AND ${searchClause} ORDER BY p.created_at DESC LIMIT ?;`,
|
`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(
|
: db.prepare(
|
||||||
`SELECT ${PLAYLIST_SELECT} ${catJoin} WHERE p.is_public = 1 AND ${searchClause} ORDER BY p.created_at DESC LIMIT ?;`,
|
`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);
|
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
|
* `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(
|
export function listPublicPlaylists(
|
||||||
|
requestingUserId: string | null,
|
||||||
page: number,
|
page: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
|
sort: PlaylistSort,
|
||||||
categoryId?: string,
|
categoryId?: string,
|
||||||
): Page<Playlist> {
|
): Page<Playlist> {
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
@@ -221,27 +264,28 @@ export function listPublicPlaylists(
|
|||||||
const rows = db.prepare(
|
const rows = db.prepare(
|
||||||
`SELECT ${PLAYLIST_SELECT} ${catJoin}
|
`SELECT ${PLAYLIST_SELECT} ${catJoin}
|
||||||
WHERE p.is_public = 1
|
WHERE p.is_public = 1
|
||||||
ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`,
|
ORDER BY ${PLAYLIST_ORDER[sort]} LIMIT ? OFFSET ?;`,
|
||||||
).all(...catParams, limit + 1, offset);
|
).all(viewerParam(requestingUserId), ...catParams, limit + 1, offset);
|
||||||
return pageOf(playlistsOf(rows), limit);
|
return pageOf(playlistsOf(rows), limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the public playlists created from `from` to `to`, the fullest first
|
* 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
|
* 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[] {
|
export function listNewPublicPlaylists(from: Date, to: Date): Playlist[] {
|
||||||
const rows = db.prepare(
|
const rows = db.prepare(
|
||||||
`SELECT ${PLAYLIST_SELECT}
|
`SELECT ${PLAYLIST_SELECT}
|
||||||
WHERE p.is_public = 1 AND p.created_at >= ? AND p.created_at < ?
|
WHERE p.is_public = 1 AND p.created_at >= ? AND p.created_at < ?
|
||||||
ORDER BY dump_count DESC, p.created_at DESC;`,
|
ORDER BY ${PLAYLIST_ORDER.dumps};`,
|
||||||
).all(from.toISOString(), to.toISOString());
|
).all(viewerParam(null), from.toISOString(), to.toISOString());
|
||||||
return playlistsOf(rows);
|
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
|
* `GET /api/users/:key/playlists`, optionally only those in the playlist
|
||||||
* category `categoryId`. The private ones are listed only when
|
* category `categoryId`. The private ones are listed only when
|
||||||
* `requestingUserId` is `userId`.
|
* `requestingUserId` is `userId`.
|
||||||
@@ -251,6 +295,7 @@ export function listPlaylistsByUser(
|
|||||||
requestingUserId: string | null,
|
requestingUserId: string | null,
|
||||||
page: number,
|
page: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
|
sort: PlaylistSort,
|
||||||
categoryId?: string,
|
categoryId?: string,
|
||||||
): Page<Playlist> {
|
): Page<Playlist> {
|
||||||
const isOwner = requestingUserId === userId;
|
const isOwner = requestingUserId === userId;
|
||||||
@@ -261,8 +306,14 @@ export function listPlaylistsByUser(
|
|||||||
const rows = db.prepare(
|
const rows = db.prepare(
|
||||||
`SELECT ${PLAYLIST_SELECT} ${catJoin}
|
`SELECT ${PLAYLIST_SELECT} ${catJoin}
|
||||||
WHERE p.user_id = ?${privacyFilter}
|
WHERE p.user_id = ?${privacyFilter}
|
||||||
ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`,
|
ORDER BY ${PLAYLIST_ORDER[sort]} LIMIT ? OFFSET ?;`,
|
||||||
).all(...catParams, userId, limit + 1, offset);
|
).all(
|
||||||
|
viewerParam(requestingUserId),
|
||||||
|
...catParams,
|
||||||
|
userId,
|
||||||
|
limit + 1,
|
||||||
|
offset,
|
||||||
|
);
|
||||||
return pageOf(playlistsOf(rows), limit);
|
return pageOf(playlistsOf(rows), limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +338,7 @@ export function updatePlaylist(
|
|||||||
requestingUserId: string,
|
requestingUserId: string,
|
||||||
canModerate: boolean,
|
canModerate: boolean,
|
||||||
): Playlist {
|
): Playlist {
|
||||||
const playlist = getPlaylistById(playlistId);
|
const playlist = getPlaylistById(playlistId, requestingUserId);
|
||||||
|
|
||||||
if (playlist.userId !== requestingUserId && !canModerate) {
|
if (playlist.userId !== requestingUserId && !canModerate) {
|
||||||
throw new APIException(403, "Only its owner or a moderator may do that");
|
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);
|
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.
|
// Its followers weren't told of the dumps added while it was private.
|
||||||
if (!playlist.isPublic && newIsPublic) {
|
if (!playlist.isPublic && newIsPublic) {
|
||||||
notifyPlaylistFollowersNowPublic({ playlistId: playlist.id });
|
notifyPlaylistFollowersNowPublic({ playlistId: playlist.id });
|
||||||
@@ -374,7 +425,7 @@ export function deletePlaylist(
|
|||||||
}
|
}
|
||||||
|
|
||||||
db.prepare(`DELETE FROM playlists WHERE id = ?;`).run(playlist.id);
|
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,
|
requestingUserId: string,
|
||||||
canModerate: boolean,
|
canModerate: boolean,
|
||||||
): Playlist {
|
): Playlist {
|
||||||
const playlist = getPlaylistById(playlistId);
|
const playlist = getPlaylistById(playlistId, requestingUserId);
|
||||||
if (playlist.userId !== requestingUserId && !canModerate) {
|
if (playlist.userId !== requestingUserId && !canModerate) {
|
||||||
throw new APIException(403, "Only its owner or a moderator may do that");
|
throw new APIException(403, "Only its owner or a moderator may do that");
|
||||||
}
|
}
|
||||||
@@ -421,8 +472,8 @@ export function setPlaylistImage(
|
|||||||
db.prepare(
|
db.prepare(
|
||||||
`UPDATE playlists SET image_mime = ?, updated_at = ? WHERE id = ?;`,
|
`UPDATE playlists SET image_mime = ?, updated_at = ? WHERE id = ?;`,
|
||||||
).run(imageMime, new Date().toISOString(), playlist.id);
|
).run(imageMime, new Date().toISOString(), playlist.id);
|
||||||
const updated = getPlaylistById(playlist.id);
|
const updated = getPlaylistById(playlist.id, requestingUserId);
|
||||||
broadcastPlaylistChange(playlist, updated);
|
broadcastPlaylistChange(playlist, updated, getCurrentMembers(playlist.id));
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,13 +641,13 @@ export function getPlaylistMembershipsForDump(
|
|||||||
): PlaylistMembership[] {
|
): PlaylistMembership[] {
|
||||||
const rows = db.prepare(
|
const rows = db.prepare(
|
||||||
`SELECT p.*, u.username as owner_username,
|
`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
|
EXISTS (SELECT 1 FROM playlist_dumps pd
|
||||||
WHERE pd.playlist_id = p.id AND pd.dump_id = ?) as has_dump
|
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
|
FROM playlists p JOIN users u ON u.id = p.user_id
|
||||||
WHERE p.user_id = ?
|
WHERE p.user_id = ?
|
||||||
ORDER BY p.created_at DESC;`,
|
ORDER BY p.created_at DESC;`,
|
||||||
).all(dumpId, userId);
|
).all(userId, dumpId, userId);
|
||||||
|
|
||||||
const memberships = rows.map((row) => {
|
const memberships = rows.map((row) => {
|
||||||
if (!isPlaylistRow(row)) {
|
if (!isPlaylistRow(row)) {
|
||||||
@@ -626,7 +677,7 @@ export function getPlaylistsForDump(
|
|||||||
INNER JOIN playlist_dumps pd ON pd.playlist_id = p.id
|
INNER JOIN playlist_dumps pd ON pd.playlist_id = p.id
|
||||||
WHERE pd.dump_id = ? AND (p.is_public = 1 OR p.user_id = ?)
|
WHERE pd.dump_id = ? AND (p.is_public = 1 OR p.user_id = ?)
|
||||||
ORDER BY p.created_at DESC LIMIT 20;`,
|
ORDER BY p.created_at DESC LIMIT 20;`,
|
||||||
).all(dumpId, requestingUserId ?? "");
|
).all(viewerParam(requestingUserId), dumpId, requestingUserId ?? "");
|
||||||
|
|
||||||
return playlistsOf(rows);
|
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
|
* Returns a playlist's dumps in order, all of them, for the callers to filter
|
||||||
* per viewer.
|
* per viewer.
|
||||||
*/
|
*/
|
||||||
function getCurrentMembers(playlistId: string): PlaylistMember[] {
|
function getCurrentMembers(playlistId: string): DatedPlaylistMember[] {
|
||||||
const rows = db.prepare(
|
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
|
FROM playlist_dumps pd INNER JOIN dumps d ON d.id = pd.dump_id
|
||||||
WHERE pd.playlist_id = ? ORDER BY pd.position ASC;`,
|
WHERE pd.playlist_id = ? ORDER BY pd.position ASC;`,
|
||||||
).all(playlistId) as Array<
|
).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) => ({
|
return rows.map((r) => ({
|
||||||
id: r.dump_id,
|
id: r.dump_id,
|
||||||
isPrivate: Boolean(r.is_private),
|
isPrivate: Boolean(r.is_private),
|
||||||
userId: r.user_id,
|
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
|
* 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 {
|
export function broadcastHoldersDumps(holders: PlaylistHolder[]): void {
|
||||||
for (const holder of holders) {
|
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 { Playlist } from "../../shared/playlist.ts";
|
||||||
import type { PublicUser, Role } from "../../shared/user.ts";
|
import type { PublicUser, Role } from "../../shared/user.ts";
|
||||||
import {
|
import {
|
||||||
|
type DatedPlaylistMember,
|
||||||
|
dumpFiguresFor,
|
||||||
isMemberVisibleTo,
|
isMemberVisibleTo,
|
||||||
type PlaylistMember,
|
|
||||||
} from "../lib/playlist-members.ts";
|
} from "../lib/playlist-members.ts";
|
||||||
import type { Job } from "../lib/scheduler.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
|
* `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
|
* 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,
|
* 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 }>(
|
function broadcastChange<T extends { userId: string }>(
|
||||||
before: T | null,
|
before: T | null,
|
||||||
after: T | null,
|
after: T | null,
|
||||||
isPrivate: (item: T) => boolean,
|
isPrivate: (item: T) => boolean,
|
||||||
messages: {
|
messages: {
|
||||||
created: (item: T) => ServerToClientMessage;
|
created: (item: T, toOwner: boolean) => ServerToClientMessage;
|
||||||
updated: (item: T) => ServerToClientMessage;
|
updated: (item: T, toOwner: boolean) => ServerToClientMessage;
|
||||||
deleted: (item: T) => ServerToClientMessage;
|
deleted: (item: T) => ServerToClientMessage;
|
||||||
},
|
},
|
||||||
): void {
|
): void {
|
||||||
const item = after ?? before;
|
const item = after ?? before;
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
const toOwner = (client: WsClient) => client.userId === item.userId;
|
const wasPublic = !!before && !isPrivate(before);
|
||||||
const toOthers = (client: WsClient) => client.userId !== item.userId;
|
const isPublic = !!after && !isPrivate(after);
|
||||||
|
|
||||||
if (!after) {
|
broadcast(
|
||||||
broadcast(messages.deleted(item), isPrivate(item) ? toOwner : undefined);
|
!after
|
||||||
} else if (!before) {
|
? messages.deleted(item)
|
||||||
broadcast(messages.created(after), isPrivate(after) ? toOwner : undefined);
|
: before
|
||||||
} else if (isPrivate(after)) {
|
? messages.updated(after, true)
|
||||||
broadcast(messages.updated(after), toOwner);
|
: messages.created(after, true),
|
||||||
if (!isPrivate(before)) broadcast(messages.deleted(after), toOthers);
|
(client) => client.userId === item.userId,
|
||||||
} else if (isPrivate(before)) {
|
);
|
||||||
broadcast(messages.updated(after), toOwner);
|
const toOthers = (client: WsClient) => client.userId !== item.userId;
|
||||||
broadcast(messages.created(after), toOthers);
|
if (after && isPublic) {
|
||||||
} else {
|
broadcast(
|
||||||
broadcast(messages.updated(after));
|
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
|
* Sends `votes_update` after the route stored a vote, to everyone, or with
|
||||||
* `onlyUserId` to that user's sockets alone (for a private dump).
|
* `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
|
* Tells the clients that may see a playlist how it changed, as
|
||||||
* {@link broadcastChange} says, for the playlist service after every write to
|
* {@link broadcastChange} says, for the playlist service after every write to
|
||||||
* its details. A playlist turned private thus leaves the others' views without
|
* 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(
|
export function broadcastPlaylistChange(
|
||||||
before: Playlist | null,
|
before: Playlist | null,
|
||||||
after: Playlist | null,
|
after: Playlist | null,
|
||||||
|
members: DatedPlaylistMember[],
|
||||||
): void {
|
): void {
|
||||||
|
const counted = (playlist: Playlist, toOwner: boolean): Playlist => ({
|
||||||
|
...playlist,
|
||||||
|
...dumpFiguresFor(members, playlist, toOwner ? playlist.userId : null),
|
||||||
|
});
|
||||||
broadcastChange(before, after, (playlist) => !playlist.isPublic, {
|
broadcastChange(before, after, (playlist) => !playlist.isPublic, {
|
||||||
created: (playlist) => ({ type: "playlist_created", playlist }),
|
created: (playlist, toOwner) => ({
|
||||||
updated: (playlist) => ({ type: "playlist_updated", playlist }),
|
type: "playlist_created",
|
||||||
|
playlist: counted(playlist, toOwner),
|
||||||
|
}),
|
||||||
|
updated: (playlist, toOwner) => ({
|
||||||
|
type: "playlist_updated",
|
||||||
|
playlist: counted(playlist, toOwner),
|
||||||
|
}),
|
||||||
deleted: (p) => ({
|
deleted: (p) => ({
|
||||||
type: "playlist_deleted",
|
type: "playlist_deleted",
|
||||||
playlistId: p.id,
|
playlistId: p.id,
|
||||||
@@ -285,11 +330,12 @@ export function broadcastPlaylistChange(
|
|||||||
/**
|
/**
|
||||||
* Sends `playlist_dumps_updated` to the playlist's audience, each socket with
|
* 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
|
* 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(
|
export function broadcastPlaylistDumpsUpdated(
|
||||||
playlist: Pick<Playlist, "id" | "isPublic" | "userId">,
|
playlist: Pick<Playlist, "id" | "isPublic" | "userId">,
|
||||||
members: PlaylistMember[],
|
members: DatedPlaylistMember[],
|
||||||
): void {
|
): void {
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
if (!playlist.isPublic && client.userId !== playlist.userId) continue;
|
if (!playlist.isPublic && client.userId !== playlist.userId) continue;
|
||||||
@@ -299,7 +345,7 @@ export function broadcastPlaylistDumpsUpdated(
|
|||||||
dumpIds: members
|
dumpIds: members
|
||||||
.filter((m) => isMemberVisibleTo(m, playlist, client.userId))
|
.filter((m) => isMemberVisibleTo(m, playlist, client.userId))
|
||||||
.map((m) => m.id),
|
.map((m) => m.id),
|
||||||
dumpCount: members.length,
|
lastDumpAt: dumpFiguresFor(members, playlist, client.userId).lastDumpAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ import {
|
|||||||
TEST_DIR,
|
TEST_DIR,
|
||||||
} from "../helpers.ts";
|
} from "../helpers.ts";
|
||||||
import type { PaginatedData } from "../../../shared/api.ts";
|
import type { PaginatedData } from "../../../shared/api.ts";
|
||||||
import type {
|
import {
|
||||||
Playlist,
|
comparePlaylists,
|
||||||
PlaylistMembership,
|
type Playlist,
|
||||||
PlaylistWithDumps,
|
PLAYLIST_SORTS,
|
||||||
|
type PlaylistMembership,
|
||||||
|
type PlaylistWithDumps,
|
||||||
} from "../../../shared/playlist.ts";
|
} from "../../../shared/playlist.ts";
|
||||||
import { assert, assertEquals, assertMatch } from "@std/assert";
|
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 () => {
|
test("reorder: must name exactly the current members", async () => {
|
||||||
const owner = await createUser();
|
const owner = await createUser();
|
||||||
const { id } = insertPlaylist({ userId: owner.id });
|
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, []);
|
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 owner = await createUser();
|
||||||
const other = await createUser();
|
const other = await createUser();
|
||||||
const mixes = insertCategory({ slug: "mixes", kind: "playlist" });
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -654,6 +654,78 @@ wsTest("REST writes broadcast to the right audience", async (connect) => {
|
|||||||
assertEquals(x.all("playlist_dumps_updated"), []);
|
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) => {
|
wsTest("a password change closes the user's sockets", async (connect) => {
|
||||||
const user = await createUser();
|
const user = await createUser();
|
||||||
const c = await connect(user);
|
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);
|
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(
|
wsTest(
|
||||||
"playlist_dumps_updated lists only what each viewer can see",
|
"playlist_dumps_updated lists only what each viewer can see",
|
||||||
async (connect) => {
|
async (connect) => {
|
||||||
@@ -709,8 +822,32 @@ wsTest(
|
|||||||
for (const c of [a, x, anon]) {
|
for (const c of [a, x, anon]) {
|
||||||
const msg = await c.next("playlist_dumps_updated");
|
const msg = await c.next("playlist_dumps_updated");
|
||||||
assertEquals(msg.dumpIds, [added.id, pub.id]);
|
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");
|
const hidden = await x.next("playlist_dumps_updated");
|
||||||
assertEquals(hidden.dumpIds, [keep.id, gone.id]);
|
assertEquals(hidden.dumpIds, [keep.id, gone.id]);
|
||||||
assertEquals(hidden.dumpCount, 3);
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
(await o.next("playlist_dumps_updated")).dumpIds,
|
(await o.next("playlist_dumps_updated")).dumpIds,
|
||||||
[keep.id, flip.id, gone.id],
|
[keep.id, flip.id, gone.id],
|
||||||
@@ -835,7 +971,7 @@ wsTest(
|
|||||||
await del(`/api/dumps/${gone.id}`, { auth: owner.auth });
|
await del(`/api/dumps/${gone.id}`, { auth: owner.auth });
|
||||||
const after = await x.next(
|
const after = await x.next(
|
||||||
"playlist_dumps_updated",
|
"playlist_dumps_updated",
|
||||||
(m) => m.dumpCount === 2,
|
(m) => m.dumpIds.length === 2,
|
||||||
);
|
);
|
||||||
assertEquals(after.dumpIds, [keep.id, flip.id]);
|
assertEquals(after.dumpIds, [keep.id, flip.id]);
|
||||||
|
|
||||||
|
|||||||
@@ -43,9 +43,25 @@ test("communityStatsOf: the period's top public dumps, members, public playlists
|
|||||||
insertPlaylist({ userId: ann.id, title: "Small" });
|
insertPlaylist({ userId: ann.id, title: "Small" });
|
||||||
const full = insertPlaylist({ userId: bob.id, title: "Full" });
|
const full = insertPlaylist({ userId: bob.id, title: "Full" });
|
||||||
insertPlaylist({ userId: bob.id, title: "Hidden", isPublic: false });
|
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(
|
db.prepare(
|
||||||
`INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at) VALUES (?, ?, 0, ?), (?, ?, 1, ?);`,
|
`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));
|
).run(
|
||||||
|
full.id,
|
||||||
|
hit.id,
|
||||||
|
ago(0),
|
||||||
|
full.id,
|
||||||
|
tie.id,
|
||||||
|
ago(0),
|
||||||
|
full.id,
|
||||||
|
secret.id,
|
||||||
|
ago(0),
|
||||||
|
);
|
||||||
|
|
||||||
const stats = communityStatsOf(...week());
|
const stats = communityStatsOf(...week());
|
||||||
assertEquals(stats.top.map((d) => d.title), ["Hit", "Newer", "Tie"]);
|
assertEquals(stats.top.map((d) => d.title), ["Hit", "Newer", "Tie"]);
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
171
locales/en.po
171
locales/en.po
@@ -24,7 +24,7 @@ msgid "{0, plural, one {# comment} other {# comments}}"
|
|||||||
msgstr "{0, plural, one {# comment} other {# comments}}"
|
msgstr "{0, plural, one {# comment} other {# comments}}"
|
||||||
|
|
||||||
#. placeholder {0}: playlist.dumpCount
|
#. placeholder {0}: playlist.dumpCount
|
||||||
#: src/components/playlist/PlaylistCard.tsx:120
|
#: src/components/playlist/PlaylistCard.tsx:131
|
||||||
msgid "{0, plural, one {# dump} other {# dumps}}"
|
msgid "{0, plural, one {# dump} other {# dumps}}"
|
||||||
msgstr "{0, plural, one {# dump} other {# dumps}}"
|
msgstr "{0, plural, one {# dump} other {# dumps}}"
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ msgstr "{0}–{1} characters: letters, numbers, or underscores"
|
|||||||
msgid "email.digest.totals"
|
msgid "email.digest.totals"
|
||||||
msgstr "{dumps, plural, one {# dump} other {# dumps}}, {upvotes, plural, one {# upvote} other {# upvotes}} and {comments, plural, one {# comment} other {# comments}}."
|
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})"
|
msgid "{label} ({count})"
|
||||||
msgstr "{label} ({count})"
|
msgstr "{label} ({count})"
|
||||||
|
|
||||||
@@ -207,13 +207,13 @@ msgstr "# Welcome to {site}!\n"
|
|||||||
|
|
||||||
#: src/components/dump/create/DumpCreateModal.tsx:736
|
#: src/components/dump/create/DumpCreateModal.tsx:736
|
||||||
#: src/pages/PlaylistDetail.tsx:98
|
#: src/pages/PlaylistDetail.tsx:98
|
||||||
#: src/pages/UserPublicProfile.tsx:125
|
#: src/pages/UserPublicProfile.tsx:129
|
||||||
msgid "← Back"
|
msgid "← Back"
|
||||||
msgstr "← Back"
|
msgstr "← Back"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:353
|
#: src/pages/Dump.tsx:375
|
||||||
#: src/pages/Dump.tsx:378
|
#: src/pages/Dump.tsx:400
|
||||||
#: src/pages/Dump.tsx:605
|
#: src/pages/Dump.tsx:627
|
||||||
#: src/pages/DumpEdit.tsx:234
|
#: src/pages/DumpEdit.tsx:234
|
||||||
msgid "← Back to all dumps"
|
msgid "← Back to all dumps"
|
||||||
msgstr "← Back to all dumps"
|
msgstr "← Back to all dumps"
|
||||||
@@ -226,7 +226,7 @@ msgstr "+ Invite someone"
|
|||||||
msgid "+ New playlist"
|
msgid "+ New playlist"
|
||||||
msgstr "+ New playlist"
|
msgstr "+ New playlist"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:452
|
#: src/pages/Dump.tsx:474
|
||||||
msgid "+ Playlist"
|
msgid "+ Playlist"
|
||||||
msgstr "+ Playlist"
|
msgstr "+ Playlist"
|
||||||
|
|
||||||
@@ -345,10 +345,6 @@ msgstr "Admin"
|
|||||||
msgid "All"
|
msgid "All"
|
||||||
msgstr "All"
|
msgstr "All"
|
||||||
|
|
||||||
#: src/components/layout/UserMenu.tsx:108
|
|
||||||
msgid "All playlists"
|
|
||||||
msgstr "All playlists"
|
|
||||||
|
|
||||||
#. placeholder {0}: first.ownerUsername
|
#. placeholder {0}: first.ownerUsername
|
||||||
#. placeholder {1}: relativeTime(first.createdAt)
|
#. placeholder {1}: relativeTime(first.createdAt)
|
||||||
#: src/components/dump/create/DuplicateNotice.tsx:39
|
#: src/components/dump/create/DuplicateNotice.tsx:39
|
||||||
@@ -419,8 +415,8 @@ msgstr "by @{username}"
|
|||||||
msgid "email.digest.playlistMeta"
|
msgid "email.digest.playlistMeta"
|
||||||
msgstr "by @{username} · {n, plural, one {# dump} other {# dumps}}"
|
msgstr "by @{username} · {n, plural, one {# dump} other {# dumps}}"
|
||||||
|
|
||||||
#: src/contexts/ws/WSProvider.tsx:376
|
#: src/contexts/ws/WSProvider.tsx:377
|
||||||
#: src/contexts/ws/WSProvider.tsx:765
|
#: src/contexts/ws/WSProvider.tsx:770
|
||||||
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
|
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
|
||||||
msgstr "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
|
msgstr "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
|
||||||
|
|
||||||
@@ -440,7 +436,7 @@ msgstr "Can't sign in there"
|
|||||||
#: src/components/profile/EmailEditor.tsx:83
|
#: src/components/profile/EmailEditor.tsx:83
|
||||||
#: src/components/shared/ConfirmModal.tsx:63
|
#: src/components/shared/ConfirmModal.tsx:63
|
||||||
#: src/pages/Authorize.tsx:158
|
#: src/pages/Authorize.tsx:158
|
||||||
#: src/pages/Dump.tsx:494
|
#: src/pages/Dump.tsx:516
|
||||||
#: src/pages/DumpEdit.tsx:561
|
#: src/pages/DumpEdit.tsx:561
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "Cancel"
|
msgstr "Cancel"
|
||||||
@@ -454,7 +450,7 @@ msgstr "Cancel removal"
|
|||||||
msgid "Cancel reply"
|
msgid "Cancel reply"
|
||||||
msgstr "Cancel reply"
|
msgstr "Cancel reply"
|
||||||
|
|
||||||
#: src/components/layout/AppHeader.tsx:182
|
#: src/components/layout/AppHeader.tsx:181
|
||||||
msgid "Cancel search"
|
msgid "Cancel search"
|
||||||
msgstr "Cancel search"
|
msgstr "Cancel search"
|
||||||
|
|
||||||
@@ -555,7 +551,7 @@ msgstr "Confirm new password"
|
|||||||
msgid "Connecting…"
|
msgid "Connecting…"
|
||||||
msgstr "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"
|
msgid "Connection lost — this message may not have been sent"
|
||||||
msgstr "Connection lost — this message may not have been sent"
|
msgstr "Connection lost — this message may not have been sent"
|
||||||
|
|
||||||
@@ -583,7 +579,7 @@ msgstr "Could not change password"
|
|||||||
msgid "Could not connect to the server. Check your connection and try again."
|
msgid "Could not connect to the server. Check your connection and try again."
|
||||||
msgstr "Could not connect to the server. Check your connection and try again."
|
msgstr "Could not connect to the server. Check your connection and try again."
|
||||||
|
|
||||||
#: src/components/profile/ProfilePlaylistsTab.tsx:171
|
#: src/components/profile/ProfilePlaylistsTab.tsx:194
|
||||||
#: src/pages/DumpEdit.tsx:555
|
#: src/pages/DumpEdit.tsx:555
|
||||||
msgid "Could not delete"
|
msgid "Could not delete"
|
||||||
msgstr "Could not delete"
|
msgstr "Could not delete"
|
||||||
@@ -632,6 +628,10 @@ msgstr "Create"
|
|||||||
msgid "Create & Add"
|
msgid "Create & Add"
|
||||||
msgstr "Create & Add"
|
msgstr "Create & Add"
|
||||||
|
|
||||||
|
#: src/components/playlist/PlaylistCard.tsx:142
|
||||||
|
msgid "created {ago}"
|
||||||
|
msgstr "created {ago}"
|
||||||
|
|
||||||
#: src/components/playlist/PlaylistCreateForm.tsx:121
|
#: src/components/playlist/PlaylistCreateForm.tsx:121
|
||||||
msgid "Creating…"
|
msgid "Creating…"
|
||||||
msgstr "Creating…"
|
msgstr "Creating…"
|
||||||
@@ -679,9 +679,9 @@ msgstr "Delete dump"
|
|||||||
msgid "Delete failed ({0})"
|
msgid "Delete failed ({0})"
|
||||||
msgstr "Delete failed ({0})"
|
msgstr "Delete failed ({0})"
|
||||||
|
|
||||||
#: src/components/playlist/PlaylistCard.tsx:149
|
#: src/components/playlist/PlaylistCard.tsx:162
|
||||||
#: src/components/playlist/PlaylistEditForm.tsx:213
|
#: src/components/playlist/PlaylistEditForm.tsx:213
|
||||||
#: src/components/profile/ProfilePlaylistsTab.tsx:201
|
#: src/components/profile/ProfilePlaylistsTab.tsx:224
|
||||||
#: src/pages/PlaylistDetail.tsx:172
|
#: src/pages/PlaylistDetail.tsx:172
|
||||||
msgid "Delete playlist"
|
msgid "Delete playlist"
|
||||||
msgstr "Delete playlist"
|
msgstr "Delete playlist"
|
||||||
@@ -698,7 +698,7 @@ msgstr "Delete this dump? This cannot be undone."
|
|||||||
msgid "Delete this message?"
|
msgid "Delete this message?"
|
||||||
msgstr "Delete this message?"
|
msgstr "Delete this message?"
|
||||||
|
|
||||||
#: src/components/profile/ProfilePlaylistsTab.tsx:200
|
#: src/components/profile/ProfilePlaylistsTab.tsx:223
|
||||||
#: src/pages/PlaylistDetail.tsx:171
|
#: src/pages/PlaylistDetail.tsx:171
|
||||||
msgid "Delete this playlist? This cannot be undone."
|
msgid "Delete this playlist? This cannot be undone."
|
||||||
msgstr "Delete this playlist? This cannot be undone."
|
msgstr "Delete this playlist? This cannot be undone."
|
||||||
@@ -753,7 +753,7 @@ msgstr "Drop a file here, or paste one"
|
|||||||
msgid "Drop a replacement here"
|
msgid "Drop a replacement here"
|
||||||
msgstr "Drop a replacement here"
|
msgstr "Drop a replacement here"
|
||||||
|
|
||||||
#: src/components/layout/AppHeader.tsx:201
|
#: src/components/layout/AppHeader.tsx:200
|
||||||
msgid "Dump"
|
msgid "Dump"
|
||||||
msgstr "Dump"
|
msgstr "Dump"
|
||||||
|
|
||||||
@@ -770,8 +770,8 @@ msgid "Dumped!"
|
|||||||
msgstr "Dumped!"
|
msgstr "Dumped!"
|
||||||
|
|
||||||
#: src/components/profile/ProfileDumpsTab.tsx:46
|
#: src/components/profile/ProfileDumpsTab.tsx:46
|
||||||
#: src/pages/Search.tsx:174
|
#: src/pages/Search.tsx:211
|
||||||
#: src/pages/UserPublicProfile.tsx:160
|
#: src/pages/UserPublicProfile.tsx:164
|
||||||
msgid "Dumps"
|
msgid "Dumps"
|
||||||
msgstr "Dumps"
|
msgstr "Dumps"
|
||||||
|
|
||||||
@@ -789,7 +789,7 @@ msgstr "Earlier"
|
|||||||
#: src/components/chat/PendingItem.tsx:93
|
#: src/components/chat/PendingItem.tsx:93
|
||||||
#: src/components/comment/CommentNode.tsx:256
|
#: src/components/comment/CommentNode.tsx:256
|
||||||
#: src/components/playlist/PlaylistHeader.tsx:60
|
#: src/components/playlist/PlaylistHeader.tsx:60
|
||||||
#: src/pages/Dump.tsx:601
|
#: src/pages/Dump.tsx:623
|
||||||
msgid "Edit"
|
msgid "Edit"
|
||||||
msgstr "Edit"
|
msgstr "Edit"
|
||||||
|
|
||||||
@@ -807,7 +807,7 @@ msgid "Edit email"
|
|||||||
msgstr "Edit email"
|
msgstr "Edit email"
|
||||||
|
|
||||||
#: src/components/dump/create/TitleField.tsx:69
|
#: src/components/dump/create/TitleField.tsx:69
|
||||||
#: src/pages/Dump.tsx:511
|
#: src/pages/Dump.tsx:533
|
||||||
msgid "Edit title"
|
msgid "Edit title"
|
||||||
msgstr "Edit title"
|
msgstr "Edit title"
|
||||||
|
|
||||||
@@ -821,7 +821,7 @@ msgstr "edited"
|
|||||||
#: src/components/chat/ChatMessageItem.tsx:132
|
#: src/components/chat/ChatMessageItem.tsx:132
|
||||||
#: src/components/comment/CommentNode.tsx:205
|
#: src/components/comment/CommentNode.tsx:205
|
||||||
#: src/components/playlist/PlaylistHeader.tsx:95
|
#: src/components/playlist/PlaylistHeader.tsx:95
|
||||||
#: src/pages/Dump.tsx:554
|
#: src/pages/Dump.tsx:576
|
||||||
msgid "edited {0}"
|
msgid "edited {0}"
|
||||||
msgstr "edited {0}"
|
msgstr "edited {0}"
|
||||||
|
|
||||||
@@ -831,7 +831,7 @@ msgstr "edited {0}"
|
|||||||
#: src/components/chat/ChatMessageItem.tsx:128
|
#: src/components/chat/ChatMessageItem.tsx:128
|
||||||
#: src/components/comment/CommentNode.tsx:203
|
#: src/components/comment/CommentNode.tsx:203
|
||||||
#: src/components/playlist/PlaylistHeader.tsx:93
|
#: src/components/playlist/PlaylistHeader.tsx:93
|
||||||
#: src/pages/Dump.tsx:552
|
#: src/pages/Dump.tsx:574
|
||||||
msgid "Edited {0}"
|
msgid "Edited {0}"
|
||||||
msgstr "Edited {0}"
|
msgstr "Edited {0}"
|
||||||
|
|
||||||
@@ -852,7 +852,7 @@ msgstr "Email"
|
|||||||
msgid "Email address"
|
msgid "Email address"
|
||||||
msgstr "Email address"
|
msgstr "Email address"
|
||||||
|
|
||||||
#: src/pages/Search.tsx:230
|
#: src/pages/Search.tsx:267
|
||||||
msgid "Enter a query to search."
|
msgid "Enter a query to search."
|
||||||
msgstr "Enter a query to search."
|
msgstr "Enter a query to search."
|
||||||
|
|
||||||
@@ -952,9 +952,9 @@ msgid "Follow some users to see their dumps here."
|
|||||||
msgstr "Follow some users to see their dumps here."
|
msgstr "Follow some users to see their dumps here."
|
||||||
|
|
||||||
#: src/components/feed/FeedTabBar.tsx:61
|
#: 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/Settings.tsx:57
|
||||||
#: src/pages/UserPublicProfile.tsx:163
|
#: src/pages/UserPublicProfile.tsx:167
|
||||||
msgid "Followed"
|
msgid "Followed"
|
||||||
msgstr "Followed"
|
msgstr "Followed"
|
||||||
|
|
||||||
@@ -1025,7 +1025,7 @@ msgstr "If you didn't ask for this, ignore this email. Your password stays the s
|
|||||||
msgid "image"
|
msgid "image"
|
||||||
msgstr "image"
|
msgstr "image"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:621
|
#: src/pages/Dump.tsx:643
|
||||||
msgid "In collections"
|
msgid "In collections"
|
||||||
msgstr "In collections"
|
msgstr "In collections"
|
||||||
|
|
||||||
@@ -1071,7 +1071,7 @@ msgid "digest.invitedByYou"
|
|||||||
msgstr "invited by you"
|
msgstr "invited by you"
|
||||||
|
|
||||||
#: src/components/profile/ProfileInviteesTab.tsx:77
|
#: src/components/profile/ProfileInviteesTab.tsx:77
|
||||||
#: src/pages/UserPublicProfile.tsx:164
|
#: src/pages/UserPublicProfile.tsx:168
|
||||||
msgid "Invitees"
|
msgid "Invitees"
|
||||||
msgstr "Invitees"
|
msgstr "Invitees"
|
||||||
|
|
||||||
@@ -1110,11 +1110,11 @@ msgstr "Link"
|
|||||||
msgid "Link or file"
|
msgid "Link or file"
|
||||||
msgstr "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…"
|
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||||
msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
|
msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||||
|
|
||||||
#: src/components/layout/AppHeader.tsx:234
|
#: src/components/layout/AppHeader.tsx:233
|
||||||
msgid "Live updates unavailable."
|
msgid "Live updates unavailable."
|
||||||
msgstr "Live updates unavailable."
|
msgstr "Live updates unavailable."
|
||||||
|
|
||||||
@@ -1126,7 +1126,7 @@ msgstr "Load more"
|
|||||||
msgid "Load older messages"
|
msgid "Load older messages"
|
||||||
msgstr "Load older messages"
|
msgstr "Load older messages"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:337
|
#: src/pages/Dump.tsx:359
|
||||||
#: src/pages/DumpEdit.tsx:210
|
#: src/pages/DumpEdit.tsx:210
|
||||||
msgid "Loading dump…"
|
msgid "Loading dump…"
|
||||||
msgstr "Loading dump…"
|
msgstr "Loading dump…"
|
||||||
@@ -1143,7 +1143,7 @@ msgstr "Loading more…"
|
|||||||
msgid "Loading playlist…"
|
msgid "Loading playlist…"
|
||||||
msgstr "Loading playlist…"
|
msgstr "Loading playlist…"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:108
|
#: src/pages/UserPublicProfile.tsx:112
|
||||||
msgid "Loading profile…"
|
msgid "Loading profile…"
|
||||||
msgstr "Loading profile…"
|
msgstr "Loading profile…"
|
||||||
|
|
||||||
@@ -1159,7 +1159,7 @@ msgstr "Loading profile…"
|
|||||||
msgid "Loading…"
|
msgid "Loading…"
|
||||||
msgstr "Loading…"
|
msgstr "Loading…"
|
||||||
|
|
||||||
#: src/components/layout/AppHeader.tsx:208
|
#: src/components/layout/AppHeader.tsx:207
|
||||||
#: src/pages/FederationCallback.tsx:32
|
#: src/pages/FederationCallback.tsx:32
|
||||||
#: src/pages/UserLogin.tsx:41
|
#: src/pages/UserLogin.tsx:41
|
||||||
#: src/pages/UserLogin.tsx:66
|
#: src/pages/UserLogin.tsx:66
|
||||||
@@ -1180,8 +1180,8 @@ msgstr "Log in to vote"
|
|||||||
msgid "Log in with {0}"
|
msgid "Log in with {0}"
|
||||||
msgstr "Log in with {0}"
|
msgstr "Log in with {0}"
|
||||||
|
|
||||||
#: src/components/layout/UserMenu.tsx:139
|
#: src/components/layout/UserMenu.tsx:132
|
||||||
#: src/pages/UserPublicProfile.tsx:133
|
#: src/pages/UserPublicProfile.tsx:137
|
||||||
msgid "Log out"
|
msgid "Log out"
|
||||||
msgstr "Log out"
|
msgstr "Log out"
|
||||||
|
|
||||||
@@ -1198,10 +1198,10 @@ msgstr "Login failed"
|
|||||||
msgid "Lowercase letters, numbers and single hyphens only"
|
msgid "Lowercase letters, numbers and single hyphens only"
|
||||||
msgstr "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:32
|
||||||
#: src/pages/Manage.tsx:40
|
#: src/pages/Manage.tsx:40
|
||||||
#: src/pages/UserPublicProfile.tsx:166
|
#: src/pages/UserPublicProfile.tsx:170
|
||||||
msgid "Manage"
|
msgid "Manage"
|
||||||
msgstr "Manage"
|
msgstr "Manage"
|
||||||
|
|
||||||
@@ -1209,6 +1209,10 @@ msgstr "Manage"
|
|||||||
msgid "Max {limit}"
|
msgid "Max {limit}"
|
||||||
msgstr "Max {limit}"
|
msgstr "Max {limit}"
|
||||||
|
|
||||||
|
#: src/components/playlist/PlaylistSortTabs.tsx:34
|
||||||
|
msgid "Mine"
|
||||||
|
msgstr "Mine"
|
||||||
|
|
||||||
#: src/components/profile/RoleSwitcher.tsx:23
|
#: src/components/profile/RoleSwitcher.tsx:23
|
||||||
msgid "Moderator"
|
msgid "Moderator"
|
||||||
msgstr "Moderator"
|
msgstr "Moderator"
|
||||||
@@ -1218,6 +1222,10 @@ msgstr "Moderator"
|
|||||||
msgid "digest.monthly"
|
msgid "digest.monthly"
|
||||||
msgstr "Monthly recap"
|
msgstr "Monthly recap"
|
||||||
|
|
||||||
|
#: src/components/playlist/PlaylistSortTabs.tsx:33
|
||||||
|
msgid "Most dumps"
|
||||||
|
msgstr "Most dumps"
|
||||||
|
|
||||||
#: src/components/player/MediaPlayer.tsx:299
|
#: src/components/player/MediaPlayer.tsx:299
|
||||||
msgid "Mute"
|
msgid "Mute"
|
||||||
msgstr "Mute"
|
msgstr "Mute"
|
||||||
@@ -1229,6 +1237,7 @@ msgstr "Name"
|
|||||||
|
|
||||||
#: src/components/chat/ChatModal.tsx:692
|
#: src/components/chat/ChatModal.tsx:692
|
||||||
#: src/components/feed/FeedTabBar.tsx:58
|
#: src/components/feed/FeedTabBar.tsx:58
|
||||||
|
#: src/components/playlist/PlaylistSortTabs.tsx:32
|
||||||
#: src/pages/Settings.tsx:55
|
#: src/pages/Settings.tsx:55
|
||||||
msgid "New"
|
msgid "New"
|
||||||
msgstr "New"
|
msgstr "New"
|
||||||
@@ -1272,7 +1281,7 @@ msgstr "Next track"
|
|||||||
msgid "No dumps in this playlist yet."
|
msgid "No dumps in this playlist yet."
|
||||||
msgstr "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}\"."
|
msgid "No dumps match \"{q}\"."
|
||||||
msgstr "No dumps match \"{q}\"."
|
msgstr "No dumps match \"{q}\"."
|
||||||
|
|
||||||
@@ -1285,7 +1294,7 @@ msgstr "No dumps yet. Be the first!"
|
|||||||
msgid "No emoji found."
|
msgid "No emoji found."
|
||||||
msgstr "No emoji found."
|
msgstr "No emoji found."
|
||||||
|
|
||||||
#: src/components/profile/ProfileFollowTab.tsx:117
|
#: src/components/profile/ProfileFollowTab.tsx:116
|
||||||
msgid "No followed playlists yet."
|
msgid "No followed playlists yet."
|
||||||
msgstr "No followed playlists yet."
|
msgstr "No followed playlists yet."
|
||||||
|
|
||||||
@@ -1301,21 +1310,21 @@ msgstr "No messages yet. Say hello!"
|
|||||||
msgid "No one yet."
|
msgid "No one yet."
|
||||||
msgstr "No one yet."
|
msgstr "No one yet."
|
||||||
|
|
||||||
#: src/pages/Search.tsx:287
|
#: src/pages/Search.tsx:330
|
||||||
msgid "No playlists match \"{q}\"."
|
msgid "No playlists match \"{q}\"."
|
||||||
msgstr "No playlists match \"{q}\"."
|
msgstr "No playlists match \"{q}\"."
|
||||||
|
|
||||||
#: src/components/playlist/PlaylistMembershipPanel.tsx:51
|
#: src/components/playlist/PlaylistMembershipPanel.tsx:51
|
||||||
#: src/components/profile/ProfilePlaylistsTab.tsx:178
|
#: src/components/profile/ProfilePlaylistsTab.tsx:201
|
||||||
#: src/pages/Playlists.tsx:126
|
#: src/pages/Playlists.tsx:180
|
||||||
msgid "No playlists yet."
|
msgid "No playlists yet."
|
||||||
msgstr "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"
|
msgid "No reply from the server — this message may not have been sent"
|
||||||
msgstr "No reply from the server — this message may not have been sent"
|
msgstr "No reply from the server — this message may not have been sent"
|
||||||
|
|
||||||
#: src/pages/Search.tsx:261
|
#: src/pages/Search.tsx:298
|
||||||
msgid "No users match \"{q}\"."
|
msgid "No users match \"{q}\"."
|
||||||
msgstr "No users match \"{q}\"."
|
msgstr "No users match \"{q}\"."
|
||||||
|
|
||||||
@@ -1408,7 +1417,7 @@ msgstr "Or log in with your account on another gerbeur"
|
|||||||
msgid "Other instances"
|
msgid "Other instances"
|
||||||
msgstr "Other instances"
|
msgstr "Other instances"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:159
|
#: src/pages/UserPublicProfile.tsx:163
|
||||||
msgid "Overview"
|
msgid "Overview"
|
||||||
msgstr "Overview"
|
msgstr "Overview"
|
||||||
|
|
||||||
@@ -1468,14 +1477,14 @@ msgid "Playlist categories"
|
|||||||
msgstr "Playlist categories"
|
msgstr "Playlist categories"
|
||||||
|
|
||||||
#: src/components/dump/create/StepIndicator.tsx:21
|
#: src/components/dump/create/StepIndicator.tsx:21
|
||||||
#: src/components/layout/AppHeader.tsx:172
|
#: src/components/layout/AppHeader.tsx:173
|
||||||
#: src/components/layout/UserMenu.tsx:92
|
#: src/components/layout/UserMenu.tsx:93
|
||||||
#: src/components/profile/ProfileFollowTab.tsx:148
|
#: src/components/profile/ProfileFollowTab.tsx:147
|
||||||
#: src/components/profile/ProfilePlaylistsTab.tsx:149
|
#: src/components/profile/ProfilePlaylistsTab.tsx:162
|
||||||
#: src/pages/Playlists.tsx:53
|
#: src/pages/Playlists.tsx:74
|
||||||
#: src/pages/Playlists.tsx:114
|
#: src/pages/Playlists.tsx:160
|
||||||
#: src/pages/Search.tsx:177
|
#: src/pages/Search.tsx:214
|
||||||
#: src/pages/UserPublicProfile.tsx:162
|
#: src/pages/UserPublicProfile.tsx:166
|
||||||
msgid "Playlists"
|
msgid "Playlists"
|
||||||
msgstr "Playlists"
|
msgstr "Playlists"
|
||||||
|
|
||||||
@@ -1499,10 +1508,10 @@ msgstr "Previous track"
|
|||||||
|
|
||||||
#: src/components/dump/DumpCard.tsx:144
|
#: src/components/dump/DumpCard.tsx:144
|
||||||
#: src/components/dump/JournalCard.tsx:145
|
#: src/components/dump/JournalCard.tsx:145
|
||||||
#: src/components/playlist/PlaylistCard.tsx:106
|
#: src/components/playlist/PlaylistCard.tsx:117
|
||||||
#: src/components/playlist/PlaylistHeader.tsx:77
|
#: src/components/playlist/PlaylistHeader.tsx:77
|
||||||
#: src/components/playlist/PlaylistMembershipPanel.tsx:81
|
#: src/components/playlist/PlaylistMembershipPanel.tsx:81
|
||||||
#: src/pages/Dump.tsx:560
|
#: src/pages/Dump.tsx:582
|
||||||
msgid "private"
|
msgid "private"
|
||||||
msgstr "private"
|
msgstr "private"
|
||||||
|
|
||||||
@@ -1510,7 +1519,7 @@ msgstr "private"
|
|||||||
msgid "Private"
|
msgid "Private"
|
||||||
msgstr "Private"
|
msgstr "Private"
|
||||||
|
|
||||||
#: src/components/playlist/PlaylistCard.tsx:105
|
#: src/components/playlist/PlaylistCard.tsx:116
|
||||||
#: src/components/playlist/PlaylistHeader.tsx:77
|
#: src/components/playlist/PlaylistHeader.tsx:77
|
||||||
msgid "public"
|
msgid "public"
|
||||||
msgstr "public"
|
msgstr "public"
|
||||||
@@ -1545,7 +1554,7 @@ msgstr "Registering…"
|
|||||||
msgid "Registration failed"
|
msgid "Registration failed"
|
||||||
msgstr "Registration failed"
|
msgstr "Registration failed"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:612
|
#: src/pages/Dump.tsx:634
|
||||||
msgid "Related"
|
msgid "Related"
|
||||||
msgstr "Related"
|
msgstr "Related"
|
||||||
|
|
||||||
@@ -1632,7 +1641,7 @@ msgid "email.reset.title"
|
|||||||
msgstr "Reset your password"
|
msgstr "Reset your password"
|
||||||
|
|
||||||
#: src/components/chat/PendingItem.tsx:86
|
#: src/components/chat/PendingItem.tsx:86
|
||||||
#: src/pages/Dump.tsx:371
|
#: src/pages/Dump.tsx:393
|
||||||
#: src/pages/DumpEdit.tsx:227
|
#: src/pages/DumpEdit.tsx:227
|
||||||
msgid "Retry"
|
msgid "Retry"
|
||||||
msgstr "Retry"
|
msgstr "Retry"
|
||||||
@@ -1647,7 +1656,7 @@ msgstr "Role"
|
|||||||
#: src/components/playlist/PlaylistEditForm.tsx:229
|
#: src/components/playlist/PlaylistEditForm.tsx:229
|
||||||
#: src/components/profile/DescriptionEditor.tsx:89
|
#: src/components/profile/DescriptionEditor.tsx:89
|
||||||
#: src/components/profile/EmailEditor.tsx:75
|
#: src/components/profile/EmailEditor.tsx:75
|
||||||
#: src/pages/Dump.tsx:486
|
#: src/pages/Dump.tsx:508
|
||||||
#: src/pages/DumpEdit.tsx:564
|
#: src/pages/DumpEdit.tsx:564
|
||||||
msgid "Save"
|
msgid "Save"
|
||||||
msgstr "Save"
|
msgstr "Save"
|
||||||
@@ -1657,14 +1666,14 @@ msgstr "Save"
|
|||||||
#: src/components/profile/ChangePasswordModal.tsx:119
|
#: src/components/profile/ChangePasswordModal.tsx:119
|
||||||
#: src/components/profile/DescriptionEditor.tsx:86
|
#: src/components/profile/DescriptionEditor.tsx:86
|
||||||
#: src/components/profile/EmailEditor.tsx:72
|
#: src/components/profile/EmailEditor.tsx:72
|
||||||
#: src/pages/Dump.tsx:485
|
#: src/pages/Dump.tsx:507
|
||||||
#: src/pages/ResetPassword.tsx:191
|
#: src/pages/ResetPassword.tsx:191
|
||||||
msgid "Saving…"
|
msgid "Saving…"
|
||||||
msgstr "Saving…"
|
msgstr "Saving…"
|
||||||
|
|
||||||
#: src/components/layout/AppHeader.tsx:182
|
#: src/components/layout/AppHeader.tsx:181
|
||||||
#: src/components/layout/SearchBar.tsx:83
|
#: src/components/layout/SearchBar.tsx:83
|
||||||
#: src/pages/Search.tsx:90
|
#: src/pages/Search.tsx:106
|
||||||
msgid "Search"
|
msgid "Search"
|
||||||
msgstr "Search"
|
msgstr "Search"
|
||||||
|
|
||||||
@@ -1680,11 +1689,11 @@ msgstr "Search emoji"
|
|||||||
msgid "Search emoji…"
|
msgid "Search emoji…"
|
||||||
msgstr "Search emoji…"
|
msgstr "Search emoji…"
|
||||||
|
|
||||||
#: src/pages/Search.tsx:241
|
#: src/pages/Search.tsx:278
|
||||||
msgid "Search failed"
|
msgid "Search failed"
|
||||||
msgstr "Search failed"
|
msgstr "Search failed"
|
||||||
|
|
||||||
#: src/pages/Search.tsx:236
|
#: src/pages/Search.tsx:273
|
||||||
msgid "Searching…"
|
msgid "Searching…"
|
||||||
msgstr "Searching…"
|
msgstr "Searching…"
|
||||||
|
|
||||||
@@ -1710,7 +1719,7 @@ msgstr "Send reset link"
|
|||||||
msgid "Sending…"
|
msgid "Sending…"
|
||||||
msgstr "Sending…"
|
msgstr "Sending…"
|
||||||
|
|
||||||
#: src/components/layout/AppHeader.tsx:198
|
#: src/components/layout/AppHeader.tsx:197
|
||||||
msgid "Server unreachable"
|
msgid "Server unreachable"
|
||||||
msgstr "Server unreachable"
|
msgstr "Server unreachable"
|
||||||
|
|
||||||
@@ -1724,7 +1733,7 @@ msgstr "Set new password"
|
|||||||
msgid "email.settings"
|
msgid "email.settings"
|
||||||
msgstr "Settings"
|
msgstr "Settings"
|
||||||
|
|
||||||
#: src/components/layout/UserMenu.tsx:116
|
#: src/components/layout/UserMenu.tsx:109
|
||||||
#: src/pages/Settings.tsx:75
|
#: src/pages/Settings.tsx:75
|
||||||
#: src/pages/Settings.tsx:88
|
#: src/pages/Settings.tsx:88
|
||||||
msgid "Settings"
|
msgid "Settings"
|
||||||
@@ -1766,7 +1775,7 @@ msgstr "Someone asked to reset the password of your {site} account. The button b
|
|||||||
|
|
||||||
#: src/components/form/FormError.tsx:31
|
#: src/components/form/FormError.tsx:31
|
||||||
#: src/components/layout/PageError.tsx:29
|
#: src/components/layout/PageError.tsx:29
|
||||||
#: src/contexts/ws/WSProvider.tsx:733
|
#: src/contexts/ws/WSProvider.tsx:738
|
||||||
#: src/pages/PlaylistDetail.tsx:155
|
#: src/pages/PlaylistDetail.tsx:155
|
||||||
msgid "Something went wrong"
|
msgid "Something went wrong"
|
||||||
msgstr "Something went wrong"
|
msgstr "Something went wrong"
|
||||||
@@ -1831,7 +1840,7 @@ msgstr "The new order couldn't be saved. Try again."
|
|||||||
msgid "This comment couldn't be deleted. Try again."
|
msgid "This comment couldn't be deleted. Try again."
|
||||||
msgstr "This comment couldn't be deleted. Try again."
|
msgstr "This comment couldn't be deleted. Try again."
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:346
|
#: src/pages/Dump.tsx:368
|
||||||
msgid "This dump was deleted, or made private."
|
msgid "This dump was deleted, or made private."
|
||||||
msgstr "This dump was deleted, or made private."
|
msgstr "This dump was deleted, or made private."
|
||||||
|
|
||||||
@@ -1972,6 +1981,14 @@ msgstr "Unsubscribe"
|
|||||||
msgid "Unsubscribed"
|
msgid "Unsubscribed"
|
||||||
msgstr "Unsubscribed"
|
msgstr "Unsubscribed"
|
||||||
|
|
||||||
|
#: src/components/playlist/PlaylistSortTabs.tsx:31
|
||||||
|
msgid "Updated"
|
||||||
|
msgstr "Updated"
|
||||||
|
|
||||||
|
#: src/components/playlist/PlaylistCard.tsx:141
|
||||||
|
msgid "updated {ago}"
|
||||||
|
msgstr "updated {ago}"
|
||||||
|
|
||||||
#. js-lingui-explicit-id
|
#. js-lingui-explicit-id
|
||||||
#: api/mail/digest.ts:237
|
#: api/mail/digest.ts:237
|
||||||
msgid "email.digest.upload"
|
msgid "email.digest.upload"
|
||||||
@@ -1999,7 +2016,7 @@ msgid "Upvote"
|
|||||||
msgstr "Upvote"
|
msgstr "Upvote"
|
||||||
|
|
||||||
#: src/components/profile/ProfileDumpsTab.tsx:46
|
#: src/components/profile/ProfileDumpsTab.tsx:46
|
||||||
#: src/pages/UserPublicProfile.tsx:161
|
#: src/pages/UserPublicProfile.tsx:165
|
||||||
msgid "Upvoted"
|
msgid "Upvoted"
|
||||||
msgstr "Upvoted"
|
msgstr "Upvoted"
|
||||||
|
|
||||||
@@ -2028,7 +2045,7 @@ msgstr "User"
|
|||||||
msgid "User management"
|
msgid "User management"
|
||||||
msgstr "User management"
|
msgstr "User management"
|
||||||
|
|
||||||
#: src/components/layout/UserMenu.tsx:67
|
#: src/components/layout/UserMenu.tsx:68
|
||||||
msgid "User menu"
|
msgid "User menu"
|
||||||
msgstr "User menu"
|
msgstr "User menu"
|
||||||
|
|
||||||
@@ -2040,8 +2057,8 @@ msgstr "User menu"
|
|||||||
msgid "Username"
|
msgid "Username"
|
||||||
msgstr "Username"
|
msgstr "Username"
|
||||||
|
|
||||||
#: src/components/profile/ProfileFollowTab.tsx:147
|
#: src/components/profile/ProfileFollowTab.tsx:146
|
||||||
#: src/pages/Search.tsx:176
|
#: src/pages/Search.tsx:213
|
||||||
msgid "Users"
|
msgid "Users"
|
||||||
msgstr "Users"
|
msgstr "Users"
|
||||||
|
|
||||||
@@ -2151,7 +2168,7 @@ msgstr "You're getting this email because a password reset was requested for thi
|
|||||||
msgid "email.welcome.reason"
|
msgid "email.welcome.reason"
|
||||||
msgstr "You're getting this email because an account was created on {site} with this address."
|
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"
|
msgid "You're offline — not sent"
|
||||||
msgstr "You're offline — not sent"
|
msgstr "You're offline — not sent"
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
171
locales/fr.po
171
locales/fr.po
@@ -24,7 +24,7 @@ msgid "{0, plural, one {# comment} other {# comments}}"
|
|||||||
msgstr "{0, plural, one {# commentaire} other {# commentaires}}"
|
msgstr "{0, plural, one {# commentaire} other {# commentaires}}"
|
||||||
|
|
||||||
#. placeholder {0}: playlist.dumpCount
|
#. placeholder {0}: playlist.dumpCount
|
||||||
#: src/components/playlist/PlaylistCard.tsx:120
|
#: src/components/playlist/PlaylistCard.tsx:131
|
||||||
msgid "{0, plural, one {# dump} other {# dumps}}"
|
msgid "{0, plural, one {# dump} other {# dumps}}"
|
||||||
msgstr "{0, plural, one {# reco} other {# recos}}"
|
msgstr "{0, plural, one {# reco} other {# recos}}"
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ msgstr "{0}–{1} caractères : lettres, chiffres ou tirets bas"
|
|||||||
msgid "email.digest.totals"
|
msgid "email.digest.totals"
|
||||||
msgstr "{dumps, plural, one {# reco} other {# recos}}, {upvotes, plural, one {# vote} other {# votes}} et {comments, plural, one {# commentaire} other {# commentaires}}."
|
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})"
|
msgid "{label} ({count})"
|
||||||
msgstr "{label} ({count})"
|
msgstr "{label} ({count})"
|
||||||
|
|
||||||
@@ -207,13 +207,13 @@ msgstr "# Bienvenue sur {site} !\n"
|
|||||||
|
|
||||||
#: src/components/dump/create/DumpCreateModal.tsx:736
|
#: src/components/dump/create/DumpCreateModal.tsx:736
|
||||||
#: src/pages/PlaylistDetail.tsx:98
|
#: src/pages/PlaylistDetail.tsx:98
|
||||||
#: src/pages/UserPublicProfile.tsx:125
|
#: src/pages/UserPublicProfile.tsx:129
|
||||||
msgid "← Back"
|
msgid "← Back"
|
||||||
msgstr "← Retour"
|
msgstr "← Retour"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:353
|
#: src/pages/Dump.tsx:375
|
||||||
#: src/pages/Dump.tsx:378
|
#: src/pages/Dump.tsx:400
|
||||||
#: src/pages/Dump.tsx:605
|
#: src/pages/Dump.tsx:627
|
||||||
#: src/pages/DumpEdit.tsx:234
|
#: src/pages/DumpEdit.tsx:234
|
||||||
msgid "← Back to all dumps"
|
msgid "← Back to all dumps"
|
||||||
msgstr "← Retour à toutes les recos"
|
msgstr "← Retour à toutes les recos"
|
||||||
@@ -226,7 +226,7 @@ msgstr "+ Inviter quelqu'un"
|
|||||||
msgid "+ New playlist"
|
msgid "+ New playlist"
|
||||||
msgstr "+ Nouvelle collection"
|
msgstr "+ Nouvelle collection"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:452
|
#: src/pages/Dump.tsx:474
|
||||||
msgid "+ Playlist"
|
msgid "+ Playlist"
|
||||||
msgstr "+ Collection"
|
msgstr "+ Collection"
|
||||||
|
|
||||||
@@ -345,10 +345,6 @@ msgstr "Administrateur"
|
|||||||
msgid "All"
|
msgid "All"
|
||||||
msgstr "Tout"
|
msgstr "Tout"
|
||||||
|
|
||||||
#: src/components/layout/UserMenu.tsx:108
|
|
||||||
msgid "All playlists"
|
|
||||||
msgstr "Toutes les collections"
|
|
||||||
|
|
||||||
#. placeholder {0}: first.ownerUsername
|
#. placeholder {0}: first.ownerUsername
|
||||||
#. placeholder {1}: relativeTime(first.createdAt)
|
#. placeholder {1}: relativeTime(first.createdAt)
|
||||||
#: src/components/dump/create/DuplicateNotice.tsx:39
|
#: src/components/dump/create/DuplicateNotice.tsx:39
|
||||||
@@ -419,8 +415,8 @@ msgstr "par @{username}"
|
|||||||
msgid "email.digest.playlistMeta"
|
msgid "email.digest.playlistMeta"
|
||||||
msgstr "par @{username} · {n, plural, one {# reco} other {# recos}}"
|
msgstr "par @{username} · {n, plural, one {# reco} other {# recos}}"
|
||||||
|
|
||||||
#: src/contexts/ws/WSProvider.tsx:376
|
#: src/contexts/ws/WSProvider.tsx:377
|
||||||
#: src/contexts/ws/WSProvider.tsx:765
|
#: src/contexts/ws/WSProvider.tsx:770
|
||||||
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
|
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
|
||||||
msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion."
|
msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion."
|
||||||
|
|
||||||
@@ -440,7 +436,7 @@ msgstr "Connexion impossible"
|
|||||||
#: src/components/profile/EmailEditor.tsx:83
|
#: src/components/profile/EmailEditor.tsx:83
|
||||||
#: src/components/shared/ConfirmModal.tsx:63
|
#: src/components/shared/ConfirmModal.tsx:63
|
||||||
#: src/pages/Authorize.tsx:158
|
#: src/pages/Authorize.tsx:158
|
||||||
#: src/pages/Dump.tsx:494
|
#: src/pages/Dump.tsx:516
|
||||||
#: src/pages/DumpEdit.tsx:561
|
#: src/pages/DumpEdit.tsx:561
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "Annuler"
|
msgstr "Annuler"
|
||||||
@@ -454,7 +450,7 @@ msgstr "Annuler la suppression"
|
|||||||
msgid "Cancel reply"
|
msgid "Cancel reply"
|
||||||
msgstr "Annuler la réponse"
|
msgstr "Annuler la réponse"
|
||||||
|
|
||||||
#: src/components/layout/AppHeader.tsx:182
|
#: src/components/layout/AppHeader.tsx:181
|
||||||
msgid "Cancel search"
|
msgid "Cancel search"
|
||||||
msgstr "Annuler la recherche"
|
msgstr "Annuler la recherche"
|
||||||
|
|
||||||
@@ -555,7 +551,7 @@ msgstr "Confirmer le nouveau mot de passe"
|
|||||||
msgid "Connecting…"
|
msgid "Connecting…"
|
||||||
msgstr "Connexion…"
|
msgstr "Connexion…"
|
||||||
|
|
||||||
#: src/contexts/ws/WSProvider.tsx:758
|
#: src/contexts/ws/WSProvider.tsx:763
|
||||||
msgid "Connection lost — this message may not have been sent"
|
msgid "Connection lost — this message may not have been sent"
|
||||||
msgstr "Connexion perdue — ce message n'a peut-être pas été envoyé"
|
msgstr "Connexion perdue — ce message n'a peut-être pas été envoyé"
|
||||||
|
|
||||||
@@ -583,7 +579,7 @@ msgstr "Impossible de changer le mot de passe"
|
|||||||
msgid "Could not connect to the server. Check your connection and try again."
|
msgid "Could not connect to the server. Check your connection and try again."
|
||||||
msgstr "Impossible de joindre le serveur. Vérifiez votre connexion et réessayez."
|
msgstr "Impossible de joindre le serveur. Vérifiez votre connexion et réessayez."
|
||||||
|
|
||||||
#: src/components/profile/ProfilePlaylistsTab.tsx:171
|
#: src/components/profile/ProfilePlaylistsTab.tsx:194
|
||||||
#: src/pages/DumpEdit.tsx:555
|
#: src/pages/DumpEdit.tsx:555
|
||||||
msgid "Could not delete"
|
msgid "Could not delete"
|
||||||
msgstr "Suppression impossible"
|
msgstr "Suppression impossible"
|
||||||
@@ -632,6 +628,10 @@ msgstr "Créer"
|
|||||||
msgid "Create & Add"
|
msgid "Create & Add"
|
||||||
msgstr "Créer et ajouter"
|
msgstr "Créer et ajouter"
|
||||||
|
|
||||||
|
#: src/components/playlist/PlaylistCard.tsx:142
|
||||||
|
msgid "created {ago}"
|
||||||
|
msgstr "créée {ago}"
|
||||||
|
|
||||||
#: src/components/playlist/PlaylistCreateForm.tsx:121
|
#: src/components/playlist/PlaylistCreateForm.tsx:121
|
||||||
msgid "Creating…"
|
msgid "Creating…"
|
||||||
msgstr "Création…"
|
msgstr "Création…"
|
||||||
@@ -679,9 +679,9 @@ msgstr "Supprimer la reco"
|
|||||||
msgid "Delete failed ({0})"
|
msgid "Delete failed ({0})"
|
||||||
msgstr "Échec de la suppression ({0})"
|
msgstr "Échec de la suppression ({0})"
|
||||||
|
|
||||||
#: src/components/playlist/PlaylistCard.tsx:149
|
#: src/components/playlist/PlaylistCard.tsx:162
|
||||||
#: src/components/playlist/PlaylistEditForm.tsx:213
|
#: src/components/playlist/PlaylistEditForm.tsx:213
|
||||||
#: src/components/profile/ProfilePlaylistsTab.tsx:201
|
#: src/components/profile/ProfilePlaylistsTab.tsx:224
|
||||||
#: src/pages/PlaylistDetail.tsx:172
|
#: src/pages/PlaylistDetail.tsx:172
|
||||||
msgid "Delete playlist"
|
msgid "Delete playlist"
|
||||||
msgstr "Supprimer la collection"
|
msgstr "Supprimer la collection"
|
||||||
@@ -698,7 +698,7 @@ msgstr "Supprimer cette reco ? Cette action est irréversible."
|
|||||||
msgid "Delete this message?"
|
msgid "Delete this message?"
|
||||||
msgstr "Supprimer ce message ?"
|
msgstr "Supprimer ce message ?"
|
||||||
|
|
||||||
#: src/components/profile/ProfilePlaylistsTab.tsx:200
|
#: src/components/profile/ProfilePlaylistsTab.tsx:223
|
||||||
#: src/pages/PlaylistDetail.tsx:171
|
#: src/pages/PlaylistDetail.tsx:171
|
||||||
msgid "Delete this playlist? This cannot be undone."
|
msgid "Delete this playlist? This cannot be undone."
|
||||||
msgstr "Supprimer cette collection ? Cette action est irréversible."
|
msgstr "Supprimer cette collection ? Cette action est irréversible."
|
||||||
@@ -753,7 +753,7 @@ msgstr "Déposez un fichier ici, ou collez-en un"
|
|||||||
msgid "Drop a replacement here"
|
msgid "Drop a replacement here"
|
||||||
msgstr "Déposez un fichier de remplacement ici"
|
msgstr "Déposez un fichier de remplacement ici"
|
||||||
|
|
||||||
#: src/components/layout/AppHeader.tsx:201
|
#: src/components/layout/AppHeader.tsx:200
|
||||||
msgid "Dump"
|
msgid "Dump"
|
||||||
msgstr "Reco"
|
msgstr "Reco"
|
||||||
|
|
||||||
@@ -770,8 +770,8 @@ msgid "Dumped!"
|
|||||||
msgstr "Recommandé !"
|
msgstr "Recommandé !"
|
||||||
|
|
||||||
#: src/components/profile/ProfileDumpsTab.tsx:46
|
#: src/components/profile/ProfileDumpsTab.tsx:46
|
||||||
#: src/pages/Search.tsx:174
|
#: src/pages/Search.tsx:211
|
||||||
#: src/pages/UserPublicProfile.tsx:160
|
#: src/pages/UserPublicProfile.tsx:164
|
||||||
msgid "Dumps"
|
msgid "Dumps"
|
||||||
msgstr "Recos"
|
msgstr "Recos"
|
||||||
|
|
||||||
@@ -789,7 +789,7 @@ msgstr "Plus tôt"
|
|||||||
#: src/components/chat/PendingItem.tsx:93
|
#: src/components/chat/PendingItem.tsx:93
|
||||||
#: src/components/comment/CommentNode.tsx:256
|
#: src/components/comment/CommentNode.tsx:256
|
||||||
#: src/components/playlist/PlaylistHeader.tsx:60
|
#: src/components/playlist/PlaylistHeader.tsx:60
|
||||||
#: src/pages/Dump.tsx:601
|
#: src/pages/Dump.tsx:623
|
||||||
msgid "Edit"
|
msgid "Edit"
|
||||||
msgstr "Modifier"
|
msgstr "Modifier"
|
||||||
|
|
||||||
@@ -807,7 +807,7 @@ msgid "Edit email"
|
|||||||
msgstr "Modifier l'e-mail"
|
msgstr "Modifier l'e-mail"
|
||||||
|
|
||||||
#: src/components/dump/create/TitleField.tsx:69
|
#: src/components/dump/create/TitleField.tsx:69
|
||||||
#: src/pages/Dump.tsx:511
|
#: src/pages/Dump.tsx:533
|
||||||
msgid "Edit title"
|
msgid "Edit title"
|
||||||
msgstr "Modifier le titre"
|
msgstr "Modifier le titre"
|
||||||
|
|
||||||
@@ -821,7 +821,7 @@ msgstr "modifié"
|
|||||||
#: src/components/chat/ChatMessageItem.tsx:132
|
#: src/components/chat/ChatMessageItem.tsx:132
|
||||||
#: src/components/comment/CommentNode.tsx:205
|
#: src/components/comment/CommentNode.tsx:205
|
||||||
#: src/components/playlist/PlaylistHeader.tsx:95
|
#: src/components/playlist/PlaylistHeader.tsx:95
|
||||||
#: src/pages/Dump.tsx:554
|
#: src/pages/Dump.tsx:576
|
||||||
msgid "edited {0}"
|
msgid "edited {0}"
|
||||||
msgstr "modifié {0}"
|
msgstr "modifié {0}"
|
||||||
|
|
||||||
@@ -831,7 +831,7 @@ msgstr "modifié {0}"
|
|||||||
#: src/components/chat/ChatMessageItem.tsx:128
|
#: src/components/chat/ChatMessageItem.tsx:128
|
||||||
#: src/components/comment/CommentNode.tsx:203
|
#: src/components/comment/CommentNode.tsx:203
|
||||||
#: src/components/playlist/PlaylistHeader.tsx:93
|
#: src/components/playlist/PlaylistHeader.tsx:93
|
||||||
#: src/pages/Dump.tsx:552
|
#: src/pages/Dump.tsx:574
|
||||||
msgid "Edited {0}"
|
msgid "Edited {0}"
|
||||||
msgstr "Modifié le {0}"
|
msgstr "Modifié le {0}"
|
||||||
|
|
||||||
@@ -852,7 +852,7 @@ msgstr "E-mail"
|
|||||||
msgid "Email address"
|
msgid "Email address"
|
||||||
msgstr "Adresse e-mail"
|
msgstr "Adresse e-mail"
|
||||||
|
|
||||||
#: src/pages/Search.tsx:230
|
#: src/pages/Search.tsx:267
|
||||||
msgid "Enter a query to search."
|
msgid "Enter a query to search."
|
||||||
msgstr "Saisissez une recherche."
|
msgstr "Saisissez une recherche."
|
||||||
|
|
||||||
@@ -952,9 +952,9 @@ msgid "Follow some users to see their dumps here."
|
|||||||
msgstr "Suivez des utilisateurs pour voir leurs recos ici."
|
msgstr "Suivez des utilisateurs pour voir leurs recos ici."
|
||||||
|
|
||||||
#: src/components/feed/FeedTabBar.tsx:61
|
#: 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/Settings.tsx:57
|
||||||
#: src/pages/UserPublicProfile.tsx:163
|
#: src/pages/UserPublicProfile.tsx:167
|
||||||
msgid "Followed"
|
msgid "Followed"
|
||||||
msgstr "Suivi"
|
msgstr "Suivi"
|
||||||
|
|
||||||
@@ -1025,7 +1025,7 @@ msgstr "Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail. V
|
|||||||
msgid "image"
|
msgid "image"
|
||||||
msgstr "image"
|
msgstr "image"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:621
|
#: src/pages/Dump.tsx:643
|
||||||
msgid "In collections"
|
msgid "In collections"
|
||||||
msgstr "Dans les collections"
|
msgstr "Dans les collections"
|
||||||
|
|
||||||
@@ -1071,7 +1071,7 @@ msgid "digest.invitedByYou"
|
|||||||
msgstr "sur votre invitation"
|
msgstr "sur votre invitation"
|
||||||
|
|
||||||
#: src/components/profile/ProfileInviteesTab.tsx:77
|
#: src/components/profile/ProfileInviteesTab.tsx:77
|
||||||
#: src/pages/UserPublicProfile.tsx:164
|
#: src/pages/UserPublicProfile.tsx:168
|
||||||
msgid "Invitees"
|
msgid "Invitees"
|
||||||
msgstr "Invités"
|
msgstr "Invités"
|
||||||
|
|
||||||
@@ -1110,11 +1110,11 @@ msgstr "Lien"
|
|||||||
msgid "Link or file"
|
msgid "Link or file"
|
||||||
msgstr "Lien ou fichier"
|
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…"
|
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||||
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
|
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
|
||||||
|
|
||||||
#: src/components/layout/AppHeader.tsx:234
|
#: src/components/layout/AppHeader.tsx:233
|
||||||
msgid "Live updates unavailable."
|
msgid "Live updates unavailable."
|
||||||
msgstr "Mises à jour en direct indisponibles."
|
msgstr "Mises à jour en direct indisponibles."
|
||||||
|
|
||||||
@@ -1126,7 +1126,7 @@ msgstr "Charger plus"
|
|||||||
msgid "Load older messages"
|
msgid "Load older messages"
|
||||||
msgstr "Charger les messages plus anciens"
|
msgstr "Charger les messages plus anciens"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:337
|
#: src/pages/Dump.tsx:359
|
||||||
#: src/pages/DumpEdit.tsx:210
|
#: src/pages/DumpEdit.tsx:210
|
||||||
msgid "Loading dump…"
|
msgid "Loading dump…"
|
||||||
msgstr "Chargement de la reco…"
|
msgstr "Chargement de la reco…"
|
||||||
@@ -1143,7 +1143,7 @@ msgstr "Chargement…"
|
|||||||
msgid "Loading playlist…"
|
msgid "Loading playlist…"
|
||||||
msgstr "Chargement de la collection…"
|
msgstr "Chargement de la collection…"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:108
|
#: src/pages/UserPublicProfile.tsx:112
|
||||||
msgid "Loading profile…"
|
msgid "Loading profile…"
|
||||||
msgstr "Chargement du profil…"
|
msgstr "Chargement du profil…"
|
||||||
|
|
||||||
@@ -1159,7 +1159,7 @@ msgstr "Chargement du profil…"
|
|||||||
msgid "Loading…"
|
msgid "Loading…"
|
||||||
msgstr "Chargement…"
|
msgstr "Chargement…"
|
||||||
|
|
||||||
#: src/components/layout/AppHeader.tsx:208
|
#: src/components/layout/AppHeader.tsx:207
|
||||||
#: src/pages/FederationCallback.tsx:32
|
#: src/pages/FederationCallback.tsx:32
|
||||||
#: src/pages/UserLogin.tsx:41
|
#: src/pages/UserLogin.tsx:41
|
||||||
#: src/pages/UserLogin.tsx:66
|
#: src/pages/UserLogin.tsx:66
|
||||||
@@ -1180,8 +1180,8 @@ msgstr "Se connecter pour valider"
|
|||||||
msgid "Log in with {0}"
|
msgid "Log in with {0}"
|
||||||
msgstr "Se connecter avec {0}"
|
msgstr "Se connecter avec {0}"
|
||||||
|
|
||||||
#: src/components/layout/UserMenu.tsx:139
|
#: src/components/layout/UserMenu.tsx:132
|
||||||
#: src/pages/UserPublicProfile.tsx:133
|
#: src/pages/UserPublicProfile.tsx:137
|
||||||
msgid "Log out"
|
msgid "Log out"
|
||||||
msgstr "Se déconnecter"
|
msgstr "Se déconnecter"
|
||||||
|
|
||||||
@@ -1198,10 +1198,10 @@ msgstr "Connexion échouée"
|
|||||||
msgid "Lowercase letters, numbers and single hyphens only"
|
msgid "Lowercase letters, numbers and single hyphens only"
|
||||||
msgstr "Minuscules, chiffres et tirets simples seulement"
|
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:32
|
||||||
#: src/pages/Manage.tsx:40
|
#: src/pages/Manage.tsx:40
|
||||||
#: src/pages/UserPublicProfile.tsx:166
|
#: src/pages/UserPublicProfile.tsx:170
|
||||||
msgid "Manage"
|
msgid "Manage"
|
||||||
msgstr "Administration"
|
msgstr "Administration"
|
||||||
|
|
||||||
@@ -1209,6 +1209,10 @@ msgstr "Administration"
|
|||||||
msgid "Max {limit}"
|
msgid "Max {limit}"
|
||||||
msgstr "Max {limit}"
|
msgstr "Max {limit}"
|
||||||
|
|
||||||
|
#: src/components/playlist/PlaylistSortTabs.tsx:34
|
||||||
|
msgid "Mine"
|
||||||
|
msgstr "Les miennes"
|
||||||
|
|
||||||
#: src/components/profile/RoleSwitcher.tsx:23
|
#: src/components/profile/RoleSwitcher.tsx:23
|
||||||
msgid "Moderator"
|
msgid "Moderator"
|
||||||
msgstr "Modérateur"
|
msgstr "Modérateur"
|
||||||
@@ -1218,6 +1222,10 @@ msgstr "Modérateur"
|
|||||||
msgid "digest.monthly"
|
msgid "digest.monthly"
|
||||||
msgstr "Récap mensuel"
|
msgstr "Récap mensuel"
|
||||||
|
|
||||||
|
#: src/components/playlist/PlaylistSortTabs.tsx:33
|
||||||
|
msgid "Most dumps"
|
||||||
|
msgstr "Les plus fournies"
|
||||||
|
|
||||||
#: src/components/player/MediaPlayer.tsx:299
|
#: src/components/player/MediaPlayer.tsx:299
|
||||||
msgid "Mute"
|
msgid "Mute"
|
||||||
msgstr "Couper le son"
|
msgstr "Couper le son"
|
||||||
@@ -1229,6 +1237,7 @@ msgstr "Nom"
|
|||||||
|
|
||||||
#: src/components/chat/ChatModal.tsx:692
|
#: src/components/chat/ChatModal.tsx:692
|
||||||
#: src/components/feed/FeedTabBar.tsx:58
|
#: src/components/feed/FeedTabBar.tsx:58
|
||||||
|
#: src/components/playlist/PlaylistSortTabs.tsx:32
|
||||||
#: src/pages/Settings.tsx:55
|
#: src/pages/Settings.tsx:55
|
||||||
msgid "New"
|
msgid "New"
|
||||||
msgstr "Nouveau"
|
msgstr "Nouveau"
|
||||||
@@ -1272,7 +1281,7 @@ msgstr "Piste suivante"
|
|||||||
msgid "No dumps in this playlist yet."
|
msgid "No dumps in this playlist yet."
|
||||||
msgstr "Aucune reco dans cette collection pour l'instant."
|
msgstr "Aucune reco dans cette collection pour l'instant."
|
||||||
|
|
||||||
#: src/pages/Search.tsx:246
|
#: src/pages/Search.tsx:283
|
||||||
msgid "No dumps match \"{q}\"."
|
msgid "No dumps match \"{q}\"."
|
||||||
msgstr "Aucune reco ne correspond à « {q} »."
|
msgstr "Aucune reco ne correspond à « {q} »."
|
||||||
|
|
||||||
@@ -1285,7 +1294,7 @@ msgstr "Pas encore de recos. Soyez le premier !"
|
|||||||
msgid "No emoji found."
|
msgid "No emoji found."
|
||||||
msgstr "Aucun emoji trouvé."
|
msgstr "Aucun emoji trouvé."
|
||||||
|
|
||||||
#: src/components/profile/ProfileFollowTab.tsx:117
|
#: src/components/profile/ProfileFollowTab.tsx:116
|
||||||
msgid "No followed playlists yet."
|
msgid "No followed playlists yet."
|
||||||
msgstr "Pas encore de collections suivies."
|
msgstr "Pas encore de collections suivies."
|
||||||
|
|
||||||
@@ -1301,21 +1310,21 @@ msgstr "Aucun message pour l'instant. Dites bonjour !"
|
|||||||
msgid "No one yet."
|
msgid "No one yet."
|
||||||
msgstr "Personne pour le moment."
|
msgstr "Personne pour le moment."
|
||||||
|
|
||||||
#: src/pages/Search.tsx:287
|
#: src/pages/Search.tsx:330
|
||||||
msgid "No playlists match \"{q}\"."
|
msgid "No playlists match \"{q}\"."
|
||||||
msgstr "Aucune collection ne correspond à « {q} »."
|
msgstr "Aucune collection ne correspond à « {q} »."
|
||||||
|
|
||||||
#: src/components/playlist/PlaylistMembershipPanel.tsx:51
|
#: src/components/playlist/PlaylistMembershipPanel.tsx:51
|
||||||
#: src/components/profile/ProfilePlaylistsTab.tsx:178
|
#: src/components/profile/ProfilePlaylistsTab.tsx:201
|
||||||
#: src/pages/Playlists.tsx:126
|
#: src/pages/Playlists.tsx:180
|
||||||
msgid "No playlists yet."
|
msgid "No playlists yet."
|
||||||
msgstr "Pas encore de collections."
|
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"
|
msgid "No reply from the server — this message may not have been sent"
|
||||||
msgstr "Pas de réponse du serveur — ce message n'a peut-être pas été envoyé"
|
msgstr "Pas de réponse du serveur — ce message n'a peut-être pas été envoyé"
|
||||||
|
|
||||||
#: src/pages/Search.tsx:261
|
#: src/pages/Search.tsx:298
|
||||||
msgid "No users match \"{q}\"."
|
msgid "No users match \"{q}\"."
|
||||||
msgstr "Aucun utilisateur ne correspond à « {q} »."
|
msgstr "Aucun utilisateur ne correspond à « {q} »."
|
||||||
|
|
||||||
@@ -1408,7 +1417,7 @@ msgstr "Ou connectez-vous avec votre compte d'un autre gerbeur"
|
|||||||
msgid "Other instances"
|
msgid "Other instances"
|
||||||
msgstr "Autres instances"
|
msgstr "Autres instances"
|
||||||
|
|
||||||
#: src/pages/UserPublicProfile.tsx:159
|
#: src/pages/UserPublicProfile.tsx:163
|
||||||
msgid "Overview"
|
msgid "Overview"
|
||||||
msgstr "Aperçu"
|
msgstr "Aperçu"
|
||||||
|
|
||||||
@@ -1468,14 +1477,14 @@ msgid "Playlist categories"
|
|||||||
msgstr "Catégories de collections"
|
msgstr "Catégories de collections"
|
||||||
|
|
||||||
#: src/components/dump/create/StepIndicator.tsx:21
|
#: src/components/dump/create/StepIndicator.tsx:21
|
||||||
#: src/components/layout/AppHeader.tsx:172
|
#: src/components/layout/AppHeader.tsx:173
|
||||||
#: src/components/layout/UserMenu.tsx:92
|
#: src/components/layout/UserMenu.tsx:93
|
||||||
#: src/components/profile/ProfileFollowTab.tsx:148
|
#: src/components/profile/ProfileFollowTab.tsx:147
|
||||||
#: src/components/profile/ProfilePlaylistsTab.tsx:149
|
#: src/components/profile/ProfilePlaylistsTab.tsx:162
|
||||||
#: src/pages/Playlists.tsx:53
|
#: src/pages/Playlists.tsx:74
|
||||||
#: src/pages/Playlists.tsx:114
|
#: src/pages/Playlists.tsx:160
|
||||||
#: src/pages/Search.tsx:177
|
#: src/pages/Search.tsx:214
|
||||||
#: src/pages/UserPublicProfile.tsx:162
|
#: src/pages/UserPublicProfile.tsx:166
|
||||||
msgid "Playlists"
|
msgid "Playlists"
|
||||||
msgstr "Collections"
|
msgstr "Collections"
|
||||||
|
|
||||||
@@ -1499,10 +1508,10 @@ msgstr "Piste précédente"
|
|||||||
|
|
||||||
#: src/components/dump/DumpCard.tsx:144
|
#: src/components/dump/DumpCard.tsx:144
|
||||||
#: src/components/dump/JournalCard.tsx:145
|
#: src/components/dump/JournalCard.tsx:145
|
||||||
#: src/components/playlist/PlaylistCard.tsx:106
|
#: src/components/playlist/PlaylistCard.tsx:117
|
||||||
#: src/components/playlist/PlaylistHeader.tsx:77
|
#: src/components/playlist/PlaylistHeader.tsx:77
|
||||||
#: src/components/playlist/PlaylistMembershipPanel.tsx:81
|
#: src/components/playlist/PlaylistMembershipPanel.tsx:81
|
||||||
#: src/pages/Dump.tsx:560
|
#: src/pages/Dump.tsx:582
|
||||||
msgid "private"
|
msgid "private"
|
||||||
msgstr "privé"
|
msgstr "privé"
|
||||||
|
|
||||||
@@ -1510,7 +1519,7 @@ msgstr "privé"
|
|||||||
msgid "Private"
|
msgid "Private"
|
||||||
msgstr "Privé"
|
msgstr "Privé"
|
||||||
|
|
||||||
#: src/components/playlist/PlaylistCard.tsx:105
|
#: src/components/playlist/PlaylistCard.tsx:116
|
||||||
#: src/components/playlist/PlaylistHeader.tsx:77
|
#: src/components/playlist/PlaylistHeader.tsx:77
|
||||||
msgid "public"
|
msgid "public"
|
||||||
msgstr "public"
|
msgstr "public"
|
||||||
@@ -1545,7 +1554,7 @@ msgstr "Inscription…"
|
|||||||
msgid "Registration failed"
|
msgid "Registration failed"
|
||||||
msgstr "Inscription échouée"
|
msgstr "Inscription échouée"
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:612
|
#: src/pages/Dump.tsx:634
|
||||||
msgid "Related"
|
msgid "Related"
|
||||||
msgstr "Connexe"
|
msgstr "Connexe"
|
||||||
|
|
||||||
@@ -1632,7 +1641,7 @@ msgid "email.reset.title"
|
|||||||
msgstr "Réinitialiser votre mot de passe"
|
msgstr "Réinitialiser votre mot de passe"
|
||||||
|
|
||||||
#: src/components/chat/PendingItem.tsx:86
|
#: src/components/chat/PendingItem.tsx:86
|
||||||
#: src/pages/Dump.tsx:371
|
#: src/pages/Dump.tsx:393
|
||||||
#: src/pages/DumpEdit.tsx:227
|
#: src/pages/DumpEdit.tsx:227
|
||||||
msgid "Retry"
|
msgid "Retry"
|
||||||
msgstr "Réessayer"
|
msgstr "Réessayer"
|
||||||
@@ -1647,7 +1656,7 @@ msgstr "Rôle"
|
|||||||
#: src/components/playlist/PlaylistEditForm.tsx:229
|
#: src/components/playlist/PlaylistEditForm.tsx:229
|
||||||
#: src/components/profile/DescriptionEditor.tsx:89
|
#: src/components/profile/DescriptionEditor.tsx:89
|
||||||
#: src/components/profile/EmailEditor.tsx:75
|
#: src/components/profile/EmailEditor.tsx:75
|
||||||
#: src/pages/Dump.tsx:486
|
#: src/pages/Dump.tsx:508
|
||||||
#: src/pages/DumpEdit.tsx:564
|
#: src/pages/DumpEdit.tsx:564
|
||||||
msgid "Save"
|
msgid "Save"
|
||||||
msgstr "Enregistrer"
|
msgstr "Enregistrer"
|
||||||
@@ -1657,14 +1666,14 @@ msgstr "Enregistrer"
|
|||||||
#: src/components/profile/ChangePasswordModal.tsx:119
|
#: src/components/profile/ChangePasswordModal.tsx:119
|
||||||
#: src/components/profile/DescriptionEditor.tsx:86
|
#: src/components/profile/DescriptionEditor.tsx:86
|
||||||
#: src/components/profile/EmailEditor.tsx:72
|
#: src/components/profile/EmailEditor.tsx:72
|
||||||
#: src/pages/Dump.tsx:485
|
#: src/pages/Dump.tsx:507
|
||||||
#: src/pages/ResetPassword.tsx:191
|
#: src/pages/ResetPassword.tsx:191
|
||||||
msgid "Saving…"
|
msgid "Saving…"
|
||||||
msgstr "Enregistrement…"
|
msgstr "Enregistrement…"
|
||||||
|
|
||||||
#: src/components/layout/AppHeader.tsx:182
|
#: src/components/layout/AppHeader.tsx:181
|
||||||
#: src/components/layout/SearchBar.tsx:83
|
#: src/components/layout/SearchBar.tsx:83
|
||||||
#: src/pages/Search.tsx:90
|
#: src/pages/Search.tsx:106
|
||||||
msgid "Search"
|
msgid "Search"
|
||||||
msgstr "Rechercher"
|
msgstr "Rechercher"
|
||||||
|
|
||||||
@@ -1680,11 +1689,11 @@ msgstr "Rechercher un emoji"
|
|||||||
msgid "Search emoji…"
|
msgid "Search emoji…"
|
||||||
msgstr "Rechercher un emoji…"
|
msgstr "Rechercher un emoji…"
|
||||||
|
|
||||||
#: src/pages/Search.tsx:241
|
#: src/pages/Search.tsx:278
|
||||||
msgid "Search failed"
|
msgid "Search failed"
|
||||||
msgstr "Recherche échouée"
|
msgstr "Recherche échouée"
|
||||||
|
|
||||||
#: src/pages/Search.tsx:236
|
#: src/pages/Search.tsx:273
|
||||||
msgid "Searching…"
|
msgid "Searching…"
|
||||||
msgstr "Recherche…"
|
msgstr "Recherche…"
|
||||||
|
|
||||||
@@ -1710,7 +1719,7 @@ msgstr "Envoyer le lien de réinitialisation"
|
|||||||
msgid "Sending…"
|
msgid "Sending…"
|
||||||
msgstr "Envoi…"
|
msgstr "Envoi…"
|
||||||
|
|
||||||
#: src/components/layout/AppHeader.tsx:198
|
#: src/components/layout/AppHeader.tsx:197
|
||||||
msgid "Server unreachable"
|
msgid "Server unreachable"
|
||||||
msgstr "Serveur inaccessible"
|
msgstr "Serveur inaccessible"
|
||||||
|
|
||||||
@@ -1724,7 +1733,7 @@ msgstr "Définir un nouveau mot de passe"
|
|||||||
msgid "email.settings"
|
msgid "email.settings"
|
||||||
msgstr "Paramètres"
|
msgstr "Paramètres"
|
||||||
|
|
||||||
#: src/components/layout/UserMenu.tsx:116
|
#: src/components/layout/UserMenu.tsx:109
|
||||||
#: src/pages/Settings.tsx:75
|
#: src/pages/Settings.tsx:75
|
||||||
#: src/pages/Settings.tsx:88
|
#: src/pages/Settings.tsx:88
|
||||||
msgid "Settings"
|
msgid "Settings"
|
||||||
@@ -1766,7 +1775,7 @@ msgstr "Quelqu'un a demandé à réinitialiser le mot de passe de votre compte {
|
|||||||
|
|
||||||
#: src/components/form/FormError.tsx:31
|
#: src/components/form/FormError.tsx:31
|
||||||
#: src/components/layout/PageError.tsx:29
|
#: src/components/layout/PageError.tsx:29
|
||||||
#: src/contexts/ws/WSProvider.tsx:733
|
#: src/contexts/ws/WSProvider.tsx:738
|
||||||
#: src/pages/PlaylistDetail.tsx:155
|
#: src/pages/PlaylistDetail.tsx:155
|
||||||
msgid "Something went wrong"
|
msgid "Something went wrong"
|
||||||
msgstr "Une erreur est survenue"
|
msgstr "Une erreur est survenue"
|
||||||
@@ -1831,7 +1840,7 @@ msgstr "Le nouvel ordre n'a pas pu être enregistré. Réessayez."
|
|||||||
msgid "This comment couldn't be deleted. Try again."
|
msgid "This comment couldn't be deleted. Try again."
|
||||||
msgstr "Ce commentaire n'a pas pu être supprimé. Réessayez."
|
msgstr "Ce commentaire n'a pas pu être supprimé. Réessayez."
|
||||||
|
|
||||||
#: src/pages/Dump.tsx:346
|
#: src/pages/Dump.tsx:368
|
||||||
msgid "This dump was deleted, or made private."
|
msgid "This dump was deleted, or made private."
|
||||||
msgstr "Cette reco a été supprimée, ou rendue privée."
|
msgstr "Cette reco a été supprimée, ou rendue privée."
|
||||||
|
|
||||||
@@ -1972,6 +1981,14 @@ msgstr "Se désabonner"
|
|||||||
msgid "Unsubscribed"
|
msgid "Unsubscribed"
|
||||||
msgstr "Désabonné"
|
msgstr "Désabonné"
|
||||||
|
|
||||||
|
#: src/components/playlist/PlaylistSortTabs.tsx:31
|
||||||
|
msgid "Updated"
|
||||||
|
msgstr "Mises à jour"
|
||||||
|
|
||||||
|
#: src/components/playlist/PlaylistCard.tsx:141
|
||||||
|
msgid "updated {ago}"
|
||||||
|
msgstr "mise à jour {ago}"
|
||||||
|
|
||||||
#. js-lingui-explicit-id
|
#. js-lingui-explicit-id
|
||||||
#: api/mail/digest.ts:237
|
#: api/mail/digest.ts:237
|
||||||
msgid "email.digest.upload"
|
msgid "email.digest.upload"
|
||||||
@@ -1999,7 +2016,7 @@ msgid "Upvote"
|
|||||||
msgstr "Valider"
|
msgstr "Valider"
|
||||||
|
|
||||||
#: src/components/profile/ProfileDumpsTab.tsx:46
|
#: src/components/profile/ProfileDumpsTab.tsx:46
|
||||||
#: src/pages/UserPublicProfile.tsx:161
|
#: src/pages/UserPublicProfile.tsx:165
|
||||||
msgid "Upvoted"
|
msgid "Upvoted"
|
||||||
msgstr "Validé"
|
msgstr "Validé"
|
||||||
|
|
||||||
@@ -2028,7 +2045,7 @@ msgstr "Utilisateur"
|
|||||||
msgid "User management"
|
msgid "User management"
|
||||||
msgstr "Gestion utilisateur"
|
msgstr "Gestion utilisateur"
|
||||||
|
|
||||||
#: src/components/layout/UserMenu.tsx:67
|
#: src/components/layout/UserMenu.tsx:68
|
||||||
msgid "User menu"
|
msgid "User menu"
|
||||||
msgstr "Menu utilisateur"
|
msgstr "Menu utilisateur"
|
||||||
|
|
||||||
@@ -2040,8 +2057,8 @@ msgstr "Menu utilisateur"
|
|||||||
msgid "Username"
|
msgid "Username"
|
||||||
msgstr "Nom d'utilisateur"
|
msgstr "Nom d'utilisateur"
|
||||||
|
|
||||||
#: src/components/profile/ProfileFollowTab.tsx:147
|
#: src/components/profile/ProfileFollowTab.tsx:146
|
||||||
#: src/pages/Search.tsx:176
|
#: src/pages/Search.tsx:213
|
||||||
msgid "Users"
|
msgid "Users"
|
||||||
msgstr "Utilisateurs"
|
msgstr "Utilisateurs"
|
||||||
|
|
||||||
@@ -2151,7 +2168,7 @@ msgstr "Vous recevez cet e-mail car une réinitialisation de mot de passe a ét
|
|||||||
msgid "email.welcome.reason"
|
msgid "email.welcome.reason"
|
||||||
msgstr "Vous recevez cet e-mail car un compte {site} a été créé avec cette adresse."
|
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"
|
msgid "You're offline — not sent"
|
||||||
msgstr "Hors ligne — message non envoyé"
|
msgstr "Hors ligne — message non envoyé"
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Flattens the Markdown users write into one line of plain text, where a
|
* 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
|
* quote of it can't render markup: the chat's reply previews, the digest
|
||||||
* email's quotes of a comment or a note.
|
* 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
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
* categories. Under a user each segment is a fixed tab name, and under `dumps`
|
* 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
|
* 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
|
* 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`
|
* 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
|
* wraps the dump and playlist ones), the emails prefix them with the
|
||||||
@@ -19,6 +21,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { CategoryKind } from "./category.ts";
|
import type { CategoryKind } from "./category.ts";
|
||||||
|
import {
|
||||||
|
parsePlaylistSort,
|
||||||
|
PLAYLIST_SORTS,
|
||||||
|
type PlaylistSort,
|
||||||
|
} from "./playlist.ts";
|
||||||
import { DIGEST_CADENCES, type DigestCadence } from "./settings.ts";
|
import { DIGEST_CADENCES, type DigestCadence } from "./settings.ts";
|
||||||
import { HANDLE_RE, splitHandle } from "./validation.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}. */
|
/** One of the {@link SEARCH_TABS}. */
|
||||||
export type SearchTab = (typeof SEARCH_TABS)[number];
|
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
|
* The views of the community page, one per digest cadence: the week at
|
||||||
* `/community`, the month at `/community/monthly`.
|
* `/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
|
* 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 {
|
export function playlistsPath(
|
||||||
return categorySlug ? `/playlists/${seg(categorySlug)}` : "/playlists";
|
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";
|
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
|
* Returned by the playlist routes, and by the search, users and dumps routes
|
||||||
* that list playlists. The `playlist_created` and `playlist_updated` WebSocket
|
* 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. */
|
/** It is undefined when the playlist has no cover. */
|
||||||
imageMime?: string;
|
imageMime?: string;
|
||||||
/**
|
/**
|
||||||
* It counts the dumps the viewer can't see too. It is undefined when the
|
* It counts the dumps the viewer may see in it, as its page lists them. It
|
||||||
* query didn't count them.
|
* is undefined when the query didn't count them.
|
||||||
*/
|
*/
|
||||||
dumpCount?: number;
|
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. */
|
/** The owner's current name, which the playlist's path starts with. */
|
||||||
ownerUsername: string;
|
ownerUsername: string;
|
||||||
/**
|
/**
|
||||||
@@ -53,6 +79,40 @@ export interface Playlist {
|
|||||||
categoryIds?: string[];
|
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
|
* Returned by `GET /api/users/:key/playlists/:slug` to `PlaylistDetail`, and
|
||||||
* by `GET /api/playlists/:playlistId`, with the dumps the caller may see, in
|
* by `GET /api/playlists/:playlistId`, with the dumps the caller may see, in
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import {
|
|||||||
dumpPath,
|
dumpPath,
|
||||||
feedPath,
|
feedPath,
|
||||||
parsePagePath,
|
parsePagePath,
|
||||||
|
parsePlaylistsTab,
|
||||||
playlistPath,
|
playlistPath,
|
||||||
|
playlistSortQuery,
|
||||||
|
playlistsPath,
|
||||||
searchPath,
|
searchPath,
|
||||||
userPath,
|
userPath,
|
||||||
} from "../paths.ts";
|
} from "../paths.ts";
|
||||||
@@ -37,6 +40,27 @@ Deno.test("builders: one hierarchy, default views bare", () => {
|
|||||||
assertEquals(searchPath(), "/search");
|
assertEquals(searchPath(), "/search");
|
||||||
assertEquals(searchPath("users", "a b"), "/search/users?q=a%20b");
|
assertEquals(searchPath("users", "a b"), "/search/users?q=a%20b");
|
||||||
assertEquals(searchPath("dumps", "x"), "/search?q=x");
|
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", () => {
|
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;
|
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
|
* 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
|
* 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,
|
* 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 {
|
export interface PlaylistUpdatedMessage {
|
||||||
type: "playlist_updated";
|
type: "playlist_updated";
|
||||||
@@ -325,8 +342,11 @@ export interface PlaylistDumpsUpdatedMessage {
|
|||||||
* private dump is left out.
|
* private dump is left out.
|
||||||
*/
|
*/
|
||||||
dumpIds: string[];
|
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
|
| DumpCreatedMessage
|
||||||
| DumpUpdatedMessage
|
| DumpUpdatedMessage
|
||||||
| DumpDeletedMessage
|
| DumpDeletedMessage
|
||||||
|
| DumpRelatedChangedMessage
|
||||||
| PlaylistCreatedMessage
|
| PlaylistCreatedMessage
|
||||||
| PlaylistUpdatedMessage
|
| PlaylistUpdatedMessage
|
||||||
| PlaylistDeletedMessage
|
| PlaylistDeletedMessage
|
||||||
|
|||||||
135
src/App.css
135
src/App.css
@@ -2289,6 +2289,14 @@ body.has-player .chat-fab {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
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 ── */
|
/* ── Profile (own) page ── */
|
||||||
@@ -2761,11 +2769,9 @@ body.has-player .chat-fab {
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A list of dump or playlist cards in a group, or right under a page's header
|
/* A list of dump or playlist cards in a group fills the column, its edges in
|
||||||
as on /playlists, fills the column, its edges in line with the label's rule
|
line with the label's rule. */
|
||||||
or the header. */
|
.page-group .dump-feed {
|
||||||
.page-group .dump-feed,
|
|
||||||
.page-content--narrow > .dump-feed {
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
@@ -3109,16 +3115,39 @@ body.has-player .chat-fab {
|
|||||||
--nav-icon-tint-strength: 100%;
|
--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 {
|
.nav-links {
|
||||||
display: none;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1150px) {
|
@media (max-width: 559px), (min-width: 860px) and (max-width: 1149px) {
|
||||||
.nav-links {
|
.app-header:not(.app-header--guest) .nav-links {
|
||||||
display: flex;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4064,9 +4093,8 @@ body.has-player .chat-fab {
|
|||||||
0.25);
|
0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Shared card preview icon ── */
|
/* ── Dump card preview icon ── */
|
||||||
.dump-card-preview-icon,
|
.dump-card-preview-icon {
|
||||||
.playlist-card-icon {
|
|
||||||
font-size: var(--text-3xl);
|
font-size: var(--text-3xl);
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -4355,6 +4383,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 {
|
.dump-card-vote {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
@@ -5723,7 +5827,7 @@ body.has-player .chat-fab {
|
|||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collections-section .playlist-card-icon {
|
.collections-section .playlist-card-preview .thumb-placeholder-initials {
|
||||||
font-size: var(--text-lg);
|
font-size: var(--text-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7718,6 +7822,9 @@ body.has-player .chat-fab {
|
|||||||
.community-figure {
|
.community-figure {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column-reverse;
|
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;
|
align-items: center;
|
||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -23,7 +23,8 @@
|
|||||||
* - `/community` and `/community/monthly`, the site's week and month
|
* - `/community` and `/community/monthly`, the site's week and month
|
||||||
* (`Community`), signed in only.
|
* (`Community`), signed in only.
|
||||||
* - `/playlists` is every public playlist, and `/playlists/:categorySlug` a
|
* - `/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,
|
* - `/@:username/*` is a user's (`UserRoutes`): their profile and its tabs,
|
||||||
* their dumps at `dumps/:slug` (`Dump`) and `dumps/:slug/edit` (`DumpEdit`,
|
* their dumps at `dumps/:slug` (`Dump`) and `dumps/:slug/edit` (`DumpEdit`,
|
||||||
* signed in only), and their playlists at `playlists/:slug`
|
* signed in only), and their playlists at `playlists/:slug`
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { UserMenu } from "./UserMenu.tsx";
|
|||||||
import { SearchBar } from "./SearchBar.tsx";
|
import { SearchBar } from "./SearchBar.tsx";
|
||||||
import { IconClose, IconSearch } from "./NavIcons.tsx";
|
import { IconClose, IconSearch } from "./NavIcons.tsx";
|
||||||
import { SITE_EMOJI, SITE_NAME } from "../../hooks/shared/useDocumentTitle.ts";
|
import { SITE_EMOJI, SITE_NAME } from "../../hooks/shared/useDocumentTitle.ts";
|
||||||
import { 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. */
|
/** Loaded on first open, so its editor stays out of every page's bundle. */
|
||||||
const DumpCreateModal = lazy(() =>
|
const DumpCreateModal = lazy(() =>
|
||||||
@@ -62,9 +62,10 @@ export interface AppHeaderProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the site's header. It holds the search, which stays open on the
|
* 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
|
* results page and slides open under the header on small screens, the link to
|
||||||
* signed-in user's links and buttons, and a banner while live updates are down.
|
* the playlists page, the signed-in user's menu and buttons or a guest's
|
||||||
* It also hosts the dump creation dialog that its "+ Dump" button opens.
|
* "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(
|
export function AppHeader(
|
||||||
{ centerSlot, disableNew, initialDumpUrl }: AppHeaderProps,
|
{ centerSlot, disableNew, initialDumpUrl }: AppHeaderProps,
|
||||||
@@ -142,7 +143,11 @@ export function AppHeader(
|
|||||||
|
|
||||||
return (
|
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">
|
<Link to="/" className="app-header-brand">
|
||||||
{SITE_EMOJI}
|
{SITE_EMOJI}
|
||||||
<span className="app-header-brand-name">
|
<span className="app-header-brand-name">
|
||||||
@@ -158,23 +163,17 @@ export function AppHeader(
|
|||||||
|
|
||||||
<nav className="app-header-nav">
|
<nav className="app-header-nav">
|
||||||
{
|
{
|
||||||
/* The user's own links lead the row, so the icons after them run
|
/* The links lead the row, so the icons after them run unbroken to
|
||||||
unbroken to the "+": Playlists on wide screens, then the
|
the "+": the playlists page, at every width for a guest and
|
||||||
avatar's menu, which leads to their profile, at every width. */
|
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={playlistsPath()} className="app-header-user">
|
||||||
<div className="nav-links">
|
<Trans>Playlists</Trans>
|
||||||
<Link
|
</Link>
|
||||||
to={userPath(user.username, "playlists")}
|
</div>
|
||||||
className="app-header-user"
|
{user && <UserMenu user={user} />}
|
||||||
>
|
|
||||||
<Trans>Playlists</Trans>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<UserMenu user={user} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
ref={searchToggleRef}
|
ref={searchToggleRef}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -17,14 +17,15 @@ export interface PageHeaderProps {
|
|||||||
title: ReactNode;
|
title: ReactNode;
|
||||||
/**
|
/**
|
||||||
* It goes at the end of the row, such as the notifications' unread count or
|
* 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;
|
children?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the page's h1 with its icon, then `children`, as the first block of
|
* 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(
|
export function PageHeader(
|
||||||
{ icon, title, children }: PageHeaderProps,
|
{ icon, title, children }: PageHeaderProps,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Renders the avatar menu that leads the header's icon row at every width: it
|
* 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),
|
* holds the profile link, the playlists page (the header's text link leads
|
||||||
* the community page, the settings, the site's tools, and logging out.
|
* there too, at the widths that have room for it), the community page, the
|
||||||
|
* settings, the site's tools, and logging out.
|
||||||
*
|
*
|
||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
@@ -29,7 +30,7 @@ export interface UserMenuProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the signed-in user's avatar, which opens a menu with links to their
|
* 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
|
* those who hold `category:manage` or `federation:manage`, and a Log out
|
||||||
* button. A click outside or Escape closes it.
|
* button. A click outside or Escape closes it.
|
||||||
*/
|
*/
|
||||||
@@ -84,7 +85,7 @@ export function UserMenu({ user }: UserMenuProps): React.JSX.Element {
|
|||||||
@{user.username}
|
@{user.username}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to={userPath(user.username, "playlists")}
|
to={playlistsPath()}
|
||||||
className="user-menu-item"
|
className="user-menu-item"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
@@ -99,14 +100,6 @@ export function UserMenu({ user }: UserMenuProps): React.JSX.Element {
|
|||||||
>
|
>
|
||||||
{i18n._(DIGEST_MESSAGES.community)}
|
{i18n._(DIGEST_MESSAGES.community)}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
|
||||||
to={playlistsPath()}
|
|
||||||
className="user-menu-item"
|
|
||||||
role="menuitem"
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
>
|
|
||||||
<Trans>All playlists</Trans>
|
|
||||||
</Link>
|
|
||||||
<Link
|
<Link
|
||||||
to="/settings"
|
to="/settings"
|
||||||
className="user-menu-item"
|
className="user-menu-item"
|
||||||
|
|||||||
@@ -5,21 +5,24 @@
|
|||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Link, useNavigate } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { Plural, Trans } from "@lingui/react/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 { relativeTime } from "../../utils/shared/relativeTime.ts";
|
||||||
import { playlistImageUrl, playlistUrl } from "../../utils/shared/urls.ts";
|
import { playlistImageUrl, playlistUrl } from "../../utils/shared/urls.ts";
|
||||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||||
|
import { useCardLink } from "../../hooks/shared/useCardLink.ts";
|
||||||
import {
|
import {
|
||||||
isPlaylistVisited,
|
isPlaylistVisited,
|
||||||
isRecent,
|
isRecent,
|
||||||
markPlaylistVisited,
|
markPlaylistVisited,
|
||||||
} from "../../utils/shared/visited.ts";
|
} from "../../utils/shared/visited.ts";
|
||||||
|
import { ThumbnailPlaceholder } from "../shared/ThumbnailPlaceholder.tsx";
|
||||||
import { Tooltip } from "../shared/Tooltip.tsx";
|
import { Tooltip } from "../shared/Tooltip.tsx";
|
||||||
import { CategoryChips } from "../category/CategoryChips.tsx";
|
import { CategoryChips } from "../category/CategoryChips.tsx";
|
||||||
import { userPath } from "../../../shared/paths.ts";
|
import { userPath } from "../../../shared/paths.ts";
|
||||||
|
import { markdownToPlainText } from "../../../shared/markdown.ts";
|
||||||
|
|
||||||
/** Passed to {@link PlaylistCard} by each list of playlists. */
|
/** Passed to {@link PlaylistCard} by each list of playlists. */
|
||||||
export interface PlaylistCardProps {
|
export interface PlaylistCardProps {
|
||||||
@@ -40,31 +43,34 @@ export interface PlaylistCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a playlist as a card that opens the playlist when clicked, with the
|
* Renders a playlist as a card that opens the playlist when clicked, in a new
|
||||||
* playlist categories it is filed under. Another user's recent playlist shows
|
* tab on a middle click (`useCardLink`), with its cover, or a tile of its own
|
||||||
* an unread dot until it is opened on this device. The card is an `<li>`, for
|
* colour without one, the playlist categories it is filed under and when it
|
||||||
* a `<ul>`.
|
* 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(
|
export function PlaylistCard(
|
||||||
{ playlist, onDelete, isOwner, privateBadgeOnly }: PlaylistCardProps,
|
{ playlist, onDelete, isOwner, privateBadgeOnly }: PlaylistCardProps,
|
||||||
): React.JSX.Element {
|
): React.JSX.Element {
|
||||||
const navigate = useNavigate();
|
|
||||||
const { mediaToken } = useAuth();
|
const { mediaToken } = useAuth();
|
||||||
|
const { cardProps } = useCardLink(
|
||||||
|
playlistUrl(playlist),
|
||||||
|
() => markPlaylistVisited(playlist.id),
|
||||||
|
);
|
||||||
const unread = !isOwner && isRecent(playlist.createdAt) &&
|
const unread = !isOwner && isRecent(playlist.createdAt) &&
|
||||||
!isPlaylistVisited(playlist.id);
|
!isPlaylistVisited(playlist.id);
|
||||||
const showBadge = !privateBadgeOnly || (!!isOwner && !playlist.isPublic);
|
const showBadge = !privateBadgeOnly || (!!isOwner && !playlist.isPublic);
|
||||||
|
// The playlist's page renders the description's Markdown; the card has
|
||||||
function handleNavigate() {
|
// room for its text only, and none at all when it is a lone image.
|
||||||
markPlaylistVisited(playlist.id);
|
const description = playlist.description &&
|
||||||
navigate(playlistUrl(playlist));
|
markdownToPlainText(playlist.description, "");
|
||||||
}
|
const activity = playlistActivity(playlist);
|
||||||
|
const ago = relativeTime(activity);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="playlist-card">
|
<li className="playlist-card">
|
||||||
<div
|
<div className="playlist-card-inner" {...cardProps}>
|
||||||
className="playlist-card-inner"
|
|
||||||
onClick={handleNavigate}
|
|
||||||
>
|
|
||||||
<div className="playlist-card-preview">
|
<div className="playlist-card-preview">
|
||||||
{playlist.imageMime
|
{playlist.imageMime
|
||||||
? (
|
? (
|
||||||
@@ -76,7 +82,12 @@ export function PlaylistCard(
|
|||||||
decoding="async"
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
: <span className="playlist-card-icon">📋</span>}
|
: (
|
||||||
|
<ThumbnailPlaceholder
|
||||||
|
seed={playlist.id}
|
||||||
|
siteName={playlist.title}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="playlist-card-body">
|
<div className="playlist-card-body">
|
||||||
<Link
|
<Link
|
||||||
@@ -91,8 +102,8 @@ export function PlaylistCard(
|
|||||||
{unread && <span className="unread-dot" aria-hidden="true" />}
|
{unread && <span className="unread-dot" aria-hidden="true" />}
|
||||||
{playlist.title}
|
{playlist.title}
|
||||||
</Link>
|
</Link>
|
||||||
{playlist.description && (
|
{description && (
|
||||||
<p className="playlist-card-description">{playlist.description}</p>
|
<p className="playlist-card-description">{description}</p>
|
||||||
)}
|
)}
|
||||||
<div className="playlist-card-meta">
|
<div className="playlist-card-meta">
|
||||||
{showBadge && (
|
{showBadge && (
|
||||||
@@ -124,9 +135,11 @@ export function PlaylistCard(
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<Tooltip text={playlist.createdAt.toLocaleString()}>
|
<Tooltip text={activity.toLocaleString()}>
|
||||||
<time dateTime={playlist.createdAt.toISOString()}>
|
<time dateTime={activity.toISOString()}>
|
||||||
{relativeTime(playlist.createdAt)}
|
{playlist.lastDumpAt
|
||||||
|
? <Trans>updated {ago}</Trans>
|
||||||
|
: <Trans>created {ago}</Trans>}
|
||||||
</time>
|
</time>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,18 +15,24 @@ export interface PlaylistCardListProps {
|
|||||||
onDelete?: (playlist: Playlist) => void;
|
onDelete?: (playlist: Playlist) => void;
|
||||||
/** Passed to each {@link PlaylistCard}. */
|
/** Passed to each {@link PlaylistCard}. */
|
||||||
privateBadgeOnly?: boolean;
|
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
|
* Renders `playlists` as a `.dump-feed` of {@link PlaylistCard}, one to a
|
||||||
* its owner's when the playlist is the signed-in user's.
|
* 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(
|
export function PlaylistCardList(
|
||||||
{ playlists, onDelete, privateBadgeOnly }: PlaylistCardListProps,
|
{ playlists, onDelete, privateBadgeOnly, grid }: PlaylistCardListProps,
|
||||||
): React.JSX.Element {
|
): React.JSX.Element {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
return (
|
return (
|
||||||
<ul className="dump-feed">
|
<ul className={grid ? "playlist-grid" : "dump-feed"}>
|
||||||
{playlists.map((p) => (
|
{playlists.map((p) => (
|
||||||
<PlaylistCard
|
<PlaylistCard
|
||||||
key={p.id}
|
key={p.id}
|
||||||
|
|||||||
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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -106,7 +106,6 @@ function FollowedPlaylists({ userId }: { userId: string }) {
|
|||||||
usePlaylistListSync(
|
usePlaylistListSync(
|
||||||
state.status === "loaded" ? state.data.items : [],
|
state.status === "loaded" ? state.data.items : [],
|
||||||
setItems,
|
setItems,
|
||||||
{ noNewEntries: true },
|
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<ListState
|
<ListState
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ import { t } from "@lingui/core/macro";
|
|||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { API_URL } from "../../config/api.ts";
|
import { API_URL } from "../../config/api.ts";
|
||||||
import { userApiUrl } from "../../utils/shared/urls.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 type { PublicUser } from "../../../shared/user.ts";
|
||||||
import {
|
import {
|
||||||
canLoadMore,
|
canLoadMore,
|
||||||
@@ -39,6 +45,7 @@ import { useCategoryFilter } from "../../hooks/category/useCategoryFilter.ts";
|
|||||||
import { CategorySwitcher } from "../category/CategorySwitcher.tsx";
|
import { CategorySwitcher } from "../category/CategorySwitcher.tsx";
|
||||||
import { NewPlaylistButton } from "../playlist/NewPlaylistButton.tsx";
|
import { NewPlaylistButton } from "../playlist/NewPlaylistButton.tsx";
|
||||||
import { PlaylistCardList } from "../playlist/PlaylistCardList.tsx";
|
import { PlaylistCardList } from "../playlist/PlaylistCardList.tsx";
|
||||||
|
import { PlaylistSortTabs } from "../playlist/PlaylistSortTabs.tsx";
|
||||||
import { FeedFooter } from "../feed/FeedFooter.tsx";
|
import { FeedFooter } from "../feed/FeedFooter.tsx";
|
||||||
import { ConfirmModal } from "../shared/ConfirmModal.tsx";
|
import { ConfirmModal } from "../shared/ConfirmModal.tsx";
|
||||||
import { ErrorCard } from "../shared/ErrorCard.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=`
|
* 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,
|
* picks, or every playlist without one, in the order its `?sort=` names.
|
||||||
* which the tab mounts afresh.
|
* Each category's list in each order is another, which the tab mounts
|
||||||
|
* afresh.
|
||||||
*/
|
*/
|
||||||
export function ProfilePlaylistsTab(
|
export function ProfilePlaylistsTab(
|
||||||
props: ProfilePlaylistsTabProps,
|
props: ProfilePlaylistsTabProps,
|
||||||
): React.JSX.Element {
|
): React.JSX.Element {
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const categorySlug = params.get("category") || undefined;
|
const categorySlug = params.get("category") || undefined;
|
||||||
|
const sort = parsePlaylistSort(params.get("sort"));
|
||||||
return (
|
return (
|
||||||
<PlaylistList
|
<PlaylistList
|
||||||
key={categorySlug ?? ""}
|
key={`${categorySlug ?? ""}:${sort}`}
|
||||||
{...props}
|
{...props}
|
||||||
categorySlug={categorySlug}
|
categorySlug={categorySlug}
|
||||||
|
sort={sort}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -74,26 +84,28 @@ export function ProfilePlaylistsTab(
|
|||||||
* Renders the playlists `user` made, from `GET /api/users/:userId/playlists`,
|
* 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;
|
* 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
|
* 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 title row switches, before the tabs that change the order. The socket
|
||||||
* the list and the scroll. The owner can make one, which goes first, and
|
* keeps them live, and going back restores the list and the scroll. The owner
|
||||||
* delete one.
|
* can make one, which goes first, and delete one.
|
||||||
*/
|
*/
|
||||||
function PlaylistList(
|
function PlaylistList(
|
||||||
{ user, isOwnProfile, categorySlug }: ProfilePlaylistsTabProps & {
|
{ user, isOwnProfile, categorySlug, sort }: ProfilePlaylistsTabProps & {
|
||||||
categorySlug?: string;
|
categorySlug?: string;
|
||||||
|
sort: PlaylistSort;
|
||||||
},
|
},
|
||||||
): React.JSX.Element {
|
): React.JSX.Element {
|
||||||
const [, setParams] = useSearchParams();
|
const [, setParams] = useSearchParams();
|
||||||
const { user: me, authFetch } = useAuth();
|
const { user: me, authFetch } = useAuth();
|
||||||
const { categories } = useCategories("playlist");
|
const { categories } = useCategories("playlist");
|
||||||
const { cached, saveState } = useFeedCache<Playlist>(
|
const { cached, saveState } = useFeedCache<Playlist>(
|
||||||
`feed:profile-playlists:${user.id}${
|
`feed:profile-playlists:${user.id}:${categorySlug ?? ""}:${sort}`,
|
||||||
categorySlug ? `:${categorySlug}` : ""
|
|
||||||
}`,
|
|
||||||
hydratePlaylist,
|
hydratePlaylist,
|
||||||
);
|
);
|
||||||
const url = `${userApiUrl(user, "playlists")}${
|
const url = `${userApiUrl(user, "playlists")}${
|
||||||
categorySlug ? `?category=${encodeURIComponent(categorySlug)}` : ""
|
playlistSortQuery(
|
||||||
|
sort,
|
||||||
|
new URLSearchParams(categorySlug ? { category: categorySlug } : {}),
|
||||||
|
)
|
||||||
}`;
|
}`;
|
||||||
const { state, setItems: setAllItems, loadMore, resync } = usePagedList<
|
const { state, setItems: setAllItems, loadMore, resync } = usePagedList<
|
||||||
Playlist
|
Playlist
|
||||||
@@ -116,6 +128,7 @@ function PlaylistList(
|
|||||||
usePlaylistListSync(loaded?.items ?? [], setItems, {
|
usePlaylistListSync(loaded?.items ?? [], setItems, {
|
||||||
isOwner: isOwnProfile,
|
isOwner: isOwnProfile,
|
||||||
ownerId: user.id,
|
ownerId: user.id,
|
||||||
|
sort,
|
||||||
});
|
});
|
||||||
useOnReconnect(resync);
|
useOnReconnect(resync);
|
||||||
|
|
||||||
@@ -149,15 +162,25 @@ function PlaylistList(
|
|||||||
title={<Trans>Playlists</Trans>}
|
title={<Trans>Playlists</Trans>}
|
||||||
actions={
|
actions={
|
||||||
<div className="profile-section-actions">
|
<div className="profile-section-actions">
|
||||||
<CategorySwitcher
|
<PlaylistSortTabs
|
||||||
kind="playlist"
|
tabs={PLAYLIST_SORTS}
|
||||||
categorySlug={categorySlug}
|
sort={sort}
|
||||||
onSelect={(slug) =>
|
// Another order of the same list, as a tab is: it takes the
|
||||||
setParams((p) => {
|
// current history entry's place.
|
||||||
if (slug) p.set("category", slug);
|
onChange={(s) =>
|
||||||
else p.delete("category");
|
setParams((p) => playlistSortQuery(s, p), { replace: true })}
|
||||||
return p;
|
leading={
|
||||||
})}
|
<CategorySwitcher
|
||||||
|
kind="playlist"
|
||||||
|
categorySlug={categorySlug}
|
||||||
|
onSelect={(slug) =>
|
||||||
|
setParams((p) => {
|
||||||
|
if (slug) p.set("category", slug);
|
||||||
|
else p.delete("category");
|
||||||
|
return p;
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{isOwnProfile && (
|
{isOwnProfile && (
|
||||||
<NewPlaylistButton
|
<NewPlaylistButton
|
||||||
|
|||||||
@@ -50,8 +50,11 @@ export interface PlaylistEvent {
|
|||||||
* order.
|
* order.
|
||||||
*/
|
*/
|
||||||
dumpIds?: string[];
|
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;
|
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.
|
* 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
|
* `WSProvider` emits it as a `user` event on each `user_updated`, which the
|
||||||
@@ -98,6 +111,7 @@ export interface UserEvent {
|
|||||||
export interface WSEvents {
|
export interface WSEvents {
|
||||||
vote: VoteEvent;
|
vote: VoteEvent;
|
||||||
dump: DumpEvent;
|
dump: DumpEvent;
|
||||||
|
related: RelatedEvent;
|
||||||
playlist: PlaylistEvent;
|
playlist: PlaylistEvent;
|
||||||
comment: CommentEvent;
|
comment: CommentEvent;
|
||||||
user: UserEvent;
|
user: UserEvent;
|
||||||
|
|||||||
@@ -538,6 +538,10 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "dump_related_changed":
|
||||||
|
events.emit("related", { dumpIds: msg.dumpIds });
|
||||||
|
break;
|
||||||
|
|
||||||
case "vote_ack": {
|
case "vote_ack": {
|
||||||
const { dumpId, action, voteCount } = msg;
|
const { dumpId, action, voteCount } = msg;
|
||||||
// With more clicks in flight, the last ack tells how it ended.
|
// 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": {
|
case "playlist_dumps_updated": {
|
||||||
const { playlistId, dumpIds, dumpCount } = msg;
|
const { playlistId, dumpIds, lastDumpAt } = msg;
|
||||||
events.emit("playlist", {
|
events.emit("playlist", {
|
||||||
type: "dumps_updated",
|
type: "dumps_updated",
|
||||||
playlistId,
|
playlistId,
|
||||||
dumpIds,
|
dumpIds,
|
||||||
dumpCount,
|
lastDumpAt: lastDumpAt ? new Date(lastDumpAt) : undefined,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,90 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* Gives a dump's card, which is a clickable `<li>` and not a link, a link's
|
* Makes a dump's card open the dump as a link would, and mark it visited.
|
||||||
* behaviour: a middle or modified click opens the dump in a new tab.
|
|
||||||
*
|
*
|
||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { MouseEvent } from "react";
|
|
||||||
import { useNavigate } from "react-router";
|
|
||||||
import type { Dump } from "../../../shared/dump.ts";
|
import type { Dump } from "../../../shared/dump.ts";
|
||||||
import { dumpUrl } from "../../utils/shared/urls.ts";
|
import { dumpUrl } from "../../utils/shared/urls.ts";
|
||||||
import { markDumpVisited } from "../../utils/shared/visited.ts";
|
import { markDumpVisited } from "../../utils/shared/visited.ts";
|
||||||
|
import { useCardLink, type UseCardLinkResult } from "../shared/useCardLink.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returned by {@link useDumpCardLink} to `DumpCard` and `JournalCard`. Every
|
* Returns `useCardLink`'s handlers for the card of `dump`, for `DumpCard` and
|
||||||
* way it opens the dump marks it visited, which clears its unread dot.
|
* `JournalCard`: every way they open 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.
|
|
||||||
*/
|
*/
|
||||||
export function useDumpCardLink(
|
export function useDumpCardLink(
|
||||||
dump: Pick<Dump, "id" | "slug" | "ownerUsername">,
|
dump: Pick<Dump, "id" | "slug" | "ownerUsername">,
|
||||||
): UseDumpCardLinkResult {
|
): UseCardLinkResult {
|
||||||
const navigate = useNavigate();
|
return useCardLink(dumpUrl(dump), () => markDumpVisited(dump.id));
|
||||||
|
|
||||||
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();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
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 { useWS, useWSEvent } from "../ws/useWS.ts";
|
||||||
import { insertAt, usePositionMemory } from "../feed/usePositionMemory.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. */
|
/** Passed to {@link usePlaylistListSync} by each page, to fit its list. */
|
||||||
export interface PlaylistListSyncOptions {
|
export interface PlaylistListSyncOptions {
|
||||||
@@ -22,20 +26,27 @@ export interface PlaylistListSyncOptions {
|
|||||||
*/
|
*/
|
||||||
ownerId?: string;
|
ownerId?: string;
|
||||||
/**
|
/**
|
||||||
* Adds no new playlist, for a list of followed playlists, since the socket
|
* The order the list is in, for one the API sorts: the playlists page's and
|
||||||
* doesn't say what the user follows, or of search results. Playlists
|
* a profile's. A list in an order of its own leaves it out, the followed
|
||||||
* already listed still change, go, and come back where they were.
|
* 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
|
* Applies the socket's playlist events to `items`, the loaded list, which
|
||||||
* first, which `setPlaylists` updates. A created playlist goes where its date
|
* `setPlaylists` updates. A created playlist goes where the list's order puts
|
||||||
* puts it, first when new, an updated one changes in place or leaves when it
|
* it, an updated one changes in place or leaves when it turns private (unless
|
||||||
* turns private (unless `isOwner`), one that left comes back where it was
|
* `isOwner`), one that left comes back where it was when it returns, a
|
||||||
* when it returns, a deleted one leaves, and a playlist's `dumpCount` follows
|
* deleted one leaves, and a playlist's `dumpCount` and `lastDumpAt` follow
|
||||||
* its dumps. A renamed user's playlists take their new name.
|
* 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(
|
export function usePlaylistListSync(
|
||||||
items: Playlist[],
|
items: Playlist[],
|
||||||
@@ -53,7 +64,14 @@ export function usePlaylistListSync(
|
|||||||
});
|
});
|
||||||
|
|
||||||
useWSEvent("playlist", (ev) => {
|
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 &&
|
const shown = ev.playlist &&
|
||||||
(ev.playlist.isPublic || isOwner) &&
|
(ev.playlist.isPublic || isOwner) &&
|
||||||
(!ownerId || ev.playlist.userId === ownerId);
|
(!ownerId || ev.playlist.userId === ownerId);
|
||||||
@@ -66,15 +84,13 @@ export function usePlaylistListSync(
|
|||||||
const idx = prev.findIndex((p) => p.id === playlist.id);
|
const idx = prev.findIndex((p) => p.id === playlist.id);
|
||||||
if (idx !== -1) {
|
if (idx !== -1) {
|
||||||
if (!shown) return prev.filter((p) => p.id !== playlist.id);
|
if (!shown) return prev.filter((p) => p.id !== playlist.id);
|
||||||
const next = [...prev];
|
return risen(prev.with(idx, playlist), idx);
|
||||||
next[idx] = playlist;
|
|
||||||
return next;
|
|
||||||
}
|
}
|
||||||
if (!shown) return prev;
|
if (!shown) return prev;
|
||||||
if (oldIdx !== undefined) return insertAt(prev, playlist, oldIdx);
|
if (oldIdx !== undefined) return insertAt(prev, playlist, oldIdx);
|
||||||
// An update never adds a playlist the list doesn't hold.
|
// An update never adds a playlist the list doesn't hold.
|
||||||
if (ev.type === "updated" || noNewEntries) return prev;
|
if (ev.type === "updated" || !compare) return prev;
|
||||||
return withByDate(prev, playlist);
|
return withInOrder(prev, playlist, compare);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ev.type === "deleted") {
|
if (ev.type === "deleted") {
|
||||||
@@ -84,12 +100,13 @@ export function usePlaylistListSync(
|
|||||||
if (ev.type === "dumps_updated" && ev.dumpIds) {
|
if (ev.type === "dumps_updated" && ev.dumpIds) {
|
||||||
const idx = prev.findIndex((p) => p.id === ev.playlistId);
|
const idx = prev.findIndex((p) => p.id === ev.playlistId);
|
||||||
if (idx === -1) return prev;
|
if (idx === -1) return prev;
|
||||||
const next = [...prev];
|
// The dumps this user may see, which are those the playlist counts.
|
||||||
next[idx] = {
|
const playlist = {
|
||||||
...next[idx],
|
...prev[idx],
|
||||||
dumpCount: ev.dumpCount ?? ev.dumpIds.length,
|
dumpCount: ev.dumpIds.length,
|
||||||
|
lastDumpAt: ev.lastDumpAt,
|
||||||
};
|
};
|
||||||
return next;
|
return risen(prev.with(idx, playlist), idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
return prev;
|
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();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -67,8 +67,7 @@ type DumpState =
|
|||||||
* `DumpEdit` or a title edit here just saved it. Reached by a former name or
|
* `DumpEdit` or a title edit here just saved it. Reached by a former name or
|
||||||
* slug, the URL takes the current ones. Its comments, related dumps and
|
* slug, the URL takes the current ones. Its comments, related dumps and
|
||||||
* playlists come from their own endpoints once it is loaded, and the socket's
|
* playlists come from their own endpoints once it is loaded, and the socket's
|
||||||
* events keep them current; a reconnection fetches the dump and its comments
|
* events keep them current; a reconnection fetches them and the dump again. A dump deleted, or made private by its poster, while someone else
|
||||||
* again. A dump deleted, or made private by its poster, while someone else
|
|
||||||
* views it gives way to a notice. The poster and moderators can edit the
|
* views it gives way to a notice. The poster and moderators can edit the
|
||||||
* title in place, and get a link to `DumpEdit`.
|
* title in place, and get a link to `DumpEdit`.
|
||||||
*/
|
*/
|
||||||
@@ -79,7 +78,7 @@ export function Dump(): React.JSX.Element {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
// Taken once, since it stays in the history entry, where a reload or Back
|
// Taken once, since it stays in the history entry, where a reload or Back
|
||||||
// would show that snapshot forever. It is cleared from there below.
|
// would show that snapshot forever. It is cleared from there below.
|
||||||
const [handedOver] = useState(
|
const [handedOver, setHandedOver] = useState(
|
||||||
() => (location.state as { dump?: Dump } | null)?.dump ?? null,
|
() => (location.state as { dump?: Dump } | null)?.dump ?? null,
|
||||||
);
|
);
|
||||||
const preloaded = isAt(handedOver, username, slug) ? handedOver : null;
|
const preloaded = isAt(handedOver, username, slug) ? handedOver : null;
|
||||||
@@ -95,6 +94,19 @@ export function Dump(): React.JSX.Element {
|
|||||||
preloaded ? { status: "loaded", dump: preloaded } : { status: "loading" },
|
preloaded ? { status: "loaded", dump: preloaded } : { status: "loading" },
|
||||||
);
|
);
|
||||||
const loadedDump = dumpState.status === "loaded" ? dumpState.dump : null;
|
const loadedDump = dumpState.status === "loaded" ? dumpState.dump : null;
|
||||||
|
// The path last rendered. One that names another dump, as a related dump's
|
||||||
|
// link does, drops the dump on screen in the same render: left for the
|
||||||
|
// fetch below to drop, useCanonicalPath would first put its path back in
|
||||||
|
// the URL. The dump handed over goes with it, so that coming back to its
|
||||||
|
// path fetches it.
|
||||||
|
const [target, setTarget] = useState({ username, slug });
|
||||||
|
if (target.username !== username || target.slug !== slug) {
|
||||||
|
setTarget({ username, slug });
|
||||||
|
if (!isAt(loadedDump, username, slug)) {
|
||||||
|
setHandedOver(null);
|
||||||
|
setDumpState({ status: "loading" });
|
||||||
|
}
|
||||||
|
}
|
||||||
useCanonicalPath(loadedDump && dumpUrl(loadedDump));
|
useCanonicalPath(loadedDump && dumpUrl(loadedDump));
|
||||||
// The dump on screen, which a path it was just given, by useCanonicalPath or
|
// The dump on screen, which a path it was just given, by useCanonicalPath or
|
||||||
// a slug edited elsewhere, needn't fetch again.
|
// a slug edited elsewhere, needn't fetch again.
|
||||||
@@ -233,6 +245,13 @@ export function Dump(): React.JSX.Element {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useDumpListSync(relatedDumps, setRelatedDumps);
|
useDumpListSync(relatedDumps, setRelatedDumps);
|
||||||
|
// Counts the events that say this dump gained or lost a related dump.
|
||||||
|
const [relatedVersion, setRelatedVersion] = useState(0);
|
||||||
|
useWSEvent("related", ({ dumpIds }) => {
|
||||||
|
if (loadedDumpId && dumpIds.includes(loadedDumpId)) {
|
||||||
|
setRelatedVersion((n) => n + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Counts the playlist events, each of which may change the playlists
|
// Counts the playlist events, each of which may change the playlists
|
||||||
// holding the dump.
|
// holding the dump.
|
||||||
@@ -272,7 +291,9 @@ export function Dump(): React.JSX.Element {
|
|||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [loadedDumpId, authFetch]);
|
// The list sync above edits and removes its dumps, but can't tell which
|
||||||
|
// one a new link adds: that, like a reconnection, fetches it again.
|
||||||
|
}, [loadedDumpId, authFetch, relatedVersion, connectionEpoch]);
|
||||||
|
|
||||||
// The server leaves out the private playlists of others.
|
// The server leaves out the private playlists of others.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,17 +1,33 @@
|
|||||||
/**
|
/**
|
||||||
* Renders every public playlist, `/playlists`, and those of one playlist
|
* Renders every public playlist, `/playlists`, and those of one playlist
|
||||||
* category, `/playlists/:categorySlug`, newest first.
|
* category, `/playlists/:categorySlug`, in the order `?sort=` names, or with
|
||||||
|
* `?mine=1` the signed-in user's own. The header's link and the avatar
|
||||||
|
* menu's lead here.
|
||||||
*
|
*
|
||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useNavigate, useParams } from "react-router";
|
import {
|
||||||
|
Navigate,
|
||||||
|
useLocation,
|
||||||
|
useNavigate,
|
||||||
|
useParams,
|
||||||
|
useSearchParams,
|
||||||
|
} from "react-router";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
import type { Playlist } from "../../shared/playlist.ts";
|
import { type Playlist, PLAYLIST_SORTS } from "../../shared/playlist.ts";
|
||||||
import { playlistsPath } from "../../shared/paths.ts";
|
import {
|
||||||
|
parsePlaylistsTab,
|
||||||
|
PLAYLISTS_TABS,
|
||||||
|
playlistSortQuery,
|
||||||
|
playlistsPath,
|
||||||
|
type PlaylistsTab,
|
||||||
|
} from "../../shared/paths.ts";
|
||||||
|
import { loginPath } from "../utils/auth/next.ts";
|
||||||
|
import { userApiUrl } from "../utils/shared/urls.ts";
|
||||||
import { canLoadMore, pageUrl, readPage } from "../utils/feed/pagedList.ts";
|
import { canLoadMore, pageUrl, readPage } from "../utils/feed/pagedList.ts";
|
||||||
import {
|
import {
|
||||||
deserializePlaylist,
|
deserializePlaylist,
|
||||||
@@ -34,18 +50,23 @@ import { PageShell } from "../components/layout/PageShell.tsx";
|
|||||||
import { PageHeader } from "../components/layout/PageHeader.tsx";
|
import { PageHeader } from "../components/layout/PageHeader.tsx";
|
||||||
import { CategorySwitcher } from "../components/category/CategorySwitcher.tsx";
|
import { CategorySwitcher } from "../components/category/CategorySwitcher.tsx";
|
||||||
import { PlaylistCardList } from "../components/playlist/PlaylistCardList.tsx";
|
import { PlaylistCardList } from "../components/playlist/PlaylistCardList.tsx";
|
||||||
|
import { PlaylistSortTabs } from "../components/playlist/PlaylistSortTabs.tsx";
|
||||||
import { FeedFooter } from "../components/feed/FeedFooter.tsx";
|
import { FeedFooter } from "../components/feed/FeedFooter.tsx";
|
||||||
import { ListState } from "../components/shared/ListState.tsx";
|
import { ListState } from "../components/shared/ListState.tsx";
|
||||||
import { NotFound } from "./NotFound.tsx";
|
import { NotFound } from "./NotFound.tsx";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the list for the route's category, or every public playlist
|
* Renders the list for the route's category, or every public playlist
|
||||||
* without one. Each category's list is another, which the page mounts
|
* without one, on the tab the URL's query names. Each category's list on
|
||||||
* afresh. A slug that names no playlist category, once the categories have
|
* each tab is another, which the page mounts afresh. A slug that names no
|
||||||
* loaded, is a page not found.
|
* playlist category, once the categories have loaded, is a page not found.
|
||||||
*/
|
*/
|
||||||
export function Playlists(): React.JSX.Element {
|
export function Playlists(): React.JSX.Element {
|
||||||
const { categorySlug } = useParams();
|
const { categorySlug } = useParams();
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const location = useLocation();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const tab = parsePlaylistsTab(params);
|
||||||
const { categories, isLoaded } = useCategories("playlist");
|
const { categories, isLoaded } = useCategories("playlist");
|
||||||
const category = categorySlug
|
const category = categorySlug
|
||||||
? categories.find((c) => c.slug === categorySlug)
|
? categories.find((c) => c.slug === categorySlug)
|
||||||
@@ -53,33 +74,57 @@ export function Playlists(): React.JSX.Element {
|
|||||||
useDocumentTitle(category ? category.name : t`Playlists`);
|
useDocumentTitle(category ? category.name : t`Playlists`);
|
||||||
|
|
||||||
if (categorySlug && isLoaded && !category) return <NotFound />;
|
if (categorySlug && isLoaded && !category) return <NotFound />;
|
||||||
|
// A guest isn't offered the tab of their own playlists, so only a link
|
||||||
|
// leads one there, or signing out on it.
|
||||||
|
if (tab === "mine" && !user) {
|
||||||
|
return (
|
||||||
|
<Navigate to={loginPath(location.pathname + location.search)} replace />
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<PlaylistsList
|
<PlaylistsList
|
||||||
key={categorySlug ?? ""}
|
key={`${categorySlug ?? ""}:${tab}`}
|
||||||
categorySlug={categorySlug}
|
categorySlug={categorySlug}
|
||||||
categoryId={category?.id}
|
categoryId={category?.id}
|
||||||
|
tab={tab}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the public playlists, from `GET /api/playlists`, a page at a time
|
* Renders the public playlists, from `GET /api/playlists`, as a grid of
|
||||||
* as the list scrolls; with `categorySlug`, only those in that playlist
|
* covers, a page at a time as the list scrolls; with `categorySlug`, only
|
||||||
* category, which the menu in the title row switches. The socket keeps them
|
* those in that playlist category, which the menu before the tabs switches,
|
||||||
* live, a reconnection fetches the pages loaded again, and going back
|
* keeping the tab, while a tab changes the order in place of the current
|
||||||
|
* history entry, as a feed's tabs do. A signed-in user's last tab lists
|
||||||
|
* their own instead, the private ones too, from
|
||||||
|
* `GET /api/users/:userId/playlists` in the default order. The socket keeps
|
||||||
|
* them live, a reconnection fetches the pages loaded again, and going back
|
||||||
* restores the list and the scroll.
|
* restores the list and the scroll.
|
||||||
*/
|
*/
|
||||||
function PlaylistsList(
|
function PlaylistsList(
|
||||||
{ categorySlug, categoryId }: { categorySlug?: string; categoryId?: string },
|
{ categorySlug, categoryId, tab }: {
|
||||||
|
categorySlug?: string;
|
||||||
|
categoryId?: string;
|
||||||
|
tab: PlaylistsTab;
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, authFetch } = useAuth();
|
const { user, authFetch } = useAuth();
|
||||||
|
// `Playlists` shows the tab to no guest.
|
||||||
|
const owner = tab === "mine" && user ? user : undefined;
|
||||||
|
const sort = tab === "mine" ? PLAYLIST_SORTS[0] : tab;
|
||||||
const { cached, saveState } = useFeedCache<Playlist>(
|
const { cached, saveState } = useFeedCache<Playlist>(
|
||||||
`feed:playlists:${categorySlug ?? "all"}`,
|
`feed:playlists:${categorySlug ?? "all"}:${tab}`,
|
||||||
hydratePlaylist,
|
hydratePlaylist,
|
||||||
);
|
);
|
||||||
const url = `${API_URL}/api/playlists${
|
const url = `${
|
||||||
categorySlug ? `?category=${encodeURIComponent(categorySlug)}` : ""
|
owner ? userApiUrl(owner, "playlists") : `${API_URL}/api/playlists`
|
||||||
|
}${
|
||||||
|
playlistSortQuery(
|
||||||
|
sort,
|
||||||
|
new URLSearchParams(categorySlug ? { category: categorySlug } : {}),
|
||||||
|
)
|
||||||
}`;
|
}`;
|
||||||
const { state, setItems: setAllItems, loadMore, resync } = usePagedList<
|
const { state, setItems: setAllItems, loadMore, resync } = usePagedList<
|
||||||
Playlist
|
Playlist
|
||||||
@@ -97,6 +142,7 @@ function PlaylistsList(
|
|||||||
usePlaylistListSync(
|
usePlaylistListSync(
|
||||||
loaded?.items ?? [],
|
loaded?.items ?? [],
|
||||||
useCategoryFilter(setAllItems, categoryId),
|
useCategoryFilter(setAllItems, categoryId),
|
||||||
|
{ sort, isOwner: !!owner, ownerId: owner?.id },
|
||||||
);
|
);
|
||||||
useOnReconnect(resync);
|
useOnReconnect(resync);
|
||||||
|
|
||||||
@@ -110,12 +156,20 @@ function PlaylistsList(
|
|||||||
useScrollRestore(cached?.scrollY, !!loaded);
|
useScrollRestore(cached?.scrollY, !!loaded);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell narrow>
|
<PageShell>
|
||||||
<PageHeader icon="📋" title={<Trans>Playlists</Trans>}>
|
<PageHeader icon="📋" title={<Trans>Playlists</Trans>}>
|
||||||
<CategorySwitcher
|
<PlaylistSortTabs
|
||||||
kind="playlist"
|
tabs={user ? PLAYLISTS_TABS : PLAYLIST_SORTS}
|
||||||
categorySlug={categorySlug}
|
sort={tab}
|
||||||
onSelect={(slug) => navigate(playlistsPath(slug))}
|
onChange={(next) =>
|
||||||
|
navigate(playlistsPath(categorySlug, next), { replace: true })}
|
||||||
|
leading={
|
||||||
|
<CategorySwitcher
|
||||||
|
kind="playlist"
|
||||||
|
categorySlug={categorySlug}
|
||||||
|
onSelect={(slug) => navigate(playlistsPath(slug, tab))}
|
||||||
|
/>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<ListState
|
<ListState
|
||||||
@@ -129,7 +183,7 @@ function PlaylistsList(
|
|||||||
>
|
>
|
||||||
{({ data }) => (
|
{({ data }) => (
|
||||||
<>
|
<>
|
||||||
<PlaylistCardList playlists={data.items} />
|
<PlaylistCardList playlists={data.items} grid privateBadgeOnly />
|
||||||
<FeedFooter
|
<FeedFooter
|
||||||
hasMore={data.hasMore}
|
hasMore={data.hasMore}
|
||||||
loadingMore={data.loadingMore}
|
loadingMore={data.loadingMore}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import {
|
|||||||
userPath,
|
userPath,
|
||||||
} from "../../shared/paths.ts";
|
} from "../../shared/paths.ts";
|
||||||
import { NotFound } from "./NotFound.tsx";
|
import { NotFound } from "./NotFound.tsx";
|
||||||
|
import { markdownToPlainText } from "../../shared/markdown.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Held by `Search` for a query, from the first page of `SearchResults`. The
|
* Held by `Search` for a query, from the first page of `SearchResults`. The
|
||||||
@@ -165,7 +166,7 @@ export function Search(): React.JSX.Element {
|
|||||||
setData((d) => ({ ...d, playlists: fn(d.playlists) })),
|
setData((d) => ({ ...d, playlists: fn(d.playlists) })),
|
||||||
[setData],
|
[setData],
|
||||||
),
|
),
|
||||||
{ isOwner: true, noNewEntries: true },
|
{ isOwner: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
// What changed while the socket was down: every page of dumps loaded is
|
// What changed while the socket was down: every page of dumps loaded is
|
||||||
@@ -297,21 +298,27 @@ export function Search(): React.JSX.Element {
|
|||||||
? <p className="search-page-empty">{t`No users match "${q}".`}</p>
|
? <p className="search-page-empty">{t`No users match "${q}".`}</p>
|
||||||
: (
|
: (
|
||||||
<ul className="user-results">
|
<ul className="user-results">
|
||||||
{results.users.map((u) => (
|
{results.users.map((u) => {
|
||||||
<li key={u.id}>
|
// The profile renders the description's Markdown; this
|
||||||
<Link
|
// line has room for its text only.
|
||||||
to={userPath(u.username)}
|
const description = u.description &&
|
||||||
className="user-result-item"
|
markdownToPlainText(u.description, "");
|
||||||
>
|
return (
|
||||||
<span className="user-result-name">@{u.username}</span>
|
<li key={u.id}>
|
||||||
{u.description && (
|
<Link
|
||||||
<span className="user-result-description">
|
to={userPath(u.username)}
|
||||||
{u.description}
|
className="user-result-item"
|
||||||
</span>
|
>
|
||||||
)}
|
<span className="user-result-name">@{u.username}</span>
|
||||||
</Link>
|
{description && (
|
||||||
</li>
|
<span className="user-result-description">
|
||||||
))}
|
{description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -44,8 +44,12 @@ import { NotFound } from "./NotFound.tsx";
|
|||||||
const OVERVIEW = "overview";
|
const OVERVIEW = "overview";
|
||||||
type Tab = ProfileTab | typeof OVERVIEW;
|
type Tab = ProfileTab | typeof OVERVIEW;
|
||||||
const TABS: readonly Tab[] = [OVERVIEW, ...PROFILE_TABS];
|
const TABS: readonly Tab[] = [OVERVIEW, ...PROFILE_TABS];
|
||||||
/** The lists of dumps, which `?category=` narrows; the other tabs drop it. */
|
/**
|
||||||
const DUMP_LISTS: readonly Tab[] = ["dumps", "upvoted"];
|
* The lists of dumps, which one `?category=` narrows, so it passes from one to
|
||||||
|
* the other. Any other change of tab drops the query: a tab's is its own, as
|
||||||
|
* the playlists' category, of another kind, and their `?sort=`.
|
||||||
|
*/
|
||||||
|
const DUMP_LISTS: readonly (Tab | null)[] = ["dumps", "upvoted"];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders `/@:username` and its tabs, `/@:username/:tab`, and the follow
|
* Renders `/@:username` and its tabs, `/@:username/:tab`, and the follow
|
||||||
@@ -168,7 +172,12 @@ export function UserPublicProfile(): React.JSX.Element {
|
|||||||
]}
|
]}
|
||||||
activeTab={tab}
|
activeTab={tab}
|
||||||
onChange={(key) =>
|
onChange={(key) =>
|
||||||
setTab(key, DUMP_LISTS.includes(key) ? undefined : "")}
|
setTab(
|
||||||
|
key,
|
||||||
|
DUMP_LISTS.includes(key) && DUMP_LISTS.includes(tab)
|
||||||
|
? undefined
|
||||||
|
: "",
|
||||||
|
)}
|
||||||
className="profile-tabs-scroller"
|
className="profile-tabs-scroller"
|
||||||
innerClassName="profile-tabs"
|
innerClassName="profile-tabs"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -13,9 +13,13 @@ import { makeSession } from "../../fixtures.ts";
|
|||||||
import { currentPath, renderWithContexts } from "../../render.tsx";
|
import { currentPath, renderWithContexts } from "../../render.tsx";
|
||||||
|
|
||||||
describe("AppHeader", () => {
|
describe("AppHeader", () => {
|
||||||
test("guests get a login button", async () => {
|
test("guests get the playlists link and a login button", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderWithContexts(<AppHeader />);
|
renderWithContexts(<AppHeader />);
|
||||||
|
expect(screen.getByRole("link", { name: "Playlists" })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"/playlists",
|
||||||
|
);
|
||||||
await user.click(screen.getByRole("button", { name: "Log in" }));
|
await user.click(screen.getByRole("button", { name: "Log in" }));
|
||||||
expect(currentPath()).toBe("/login");
|
expect(currentPath()).toBe("/login");
|
||||||
});
|
});
|
||||||
@@ -26,7 +30,7 @@ describe("AppHeader", () => {
|
|||||||
});
|
});
|
||||||
expect(screen.getByRole("link", { name: "Playlists" })).toHaveAttribute(
|
expect(screen.getByRole("link", { name: "Playlists" })).toHaveAttribute(
|
||||||
"href",
|
"href",
|
||||||
"/@ann/playlists",
|
"/playlists",
|
||||||
);
|
);
|
||||||
expect(screen.getByRole("button", { name: "User menu" }))
|
expect(screen.getByRole("button", { name: "User menu" }))
|
||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ describe("UserMenu", () => {
|
|||||||
"href",
|
"href",
|
||||||
"/@ann",
|
"/@ann",
|
||||||
);
|
);
|
||||||
expect(screen.getByRole("menuitem", { name: "Playlists" })).toHaveAttribute(
|
// One playlists link, to the page of everyone's, whose last tab is the
|
||||||
"href",
|
// user's own: the header's text link is hidden where it has no room.
|
||||||
"/@ann/playlists",
|
expect(screen.getByRole("menuitem", { name: "Playlists" }))
|
||||||
);
|
.toHaveAttribute("href", "/playlists");
|
||||||
expect(screen.getByRole("menuitem", { name: "Community" })).toHaveAttribute(
|
expect(screen.getByRole("menuitem", { name: "Community" })).toHaveAttribute(
|
||||||
"href",
|
"href",
|
||||||
"/community",
|
"/community",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
import { screen } from "@testing-library/react";
|
import { fireEvent, screen } from "@testing-library/react";
|
||||||
import { userEvent } from "@testing-library/user-event";
|
import { userEvent } from "@testing-library/user-event";
|
||||||
import { PlaylistCard } from "../../../components/playlist/PlaylistCard.tsx";
|
import { PlaylistCard } from "../../../components/playlist/PlaylistCard.tsx";
|
||||||
import { makePlaylist } from "../../fixtures.ts";
|
import { makePlaylist } from "../../fixtures.ts";
|
||||||
@@ -38,6 +38,78 @@ describe("PlaylistCard", () => {
|
|||||||
expect(currentPath()).toBe("/@ann/playlists/mix");
|
expect(currentPath()).toBe("/@ann/playlists/mix");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a middle click anywhere on the card opens it in a new tab", () => {
|
||||||
|
const opened = vi.spyOn(globalThis, "open").mockReturnValue(null);
|
||||||
|
const p = makePlaylist({ slug: "mix", ownerUsername: "ann" });
|
||||||
|
const { container } = renderWithContexts(
|
||||||
|
<ul>
|
||||||
|
<PlaylistCard playlist={p} />
|
||||||
|
</ul>,
|
||||||
|
);
|
||||||
|
fireEvent(
|
||||||
|
container.querySelector(".playlist-card-preview")!,
|
||||||
|
new MouseEvent("auxclick", { bubbles: true, button: 1 }),
|
||||||
|
);
|
||||||
|
expect(opened).toHaveBeenCalledWith(
|
||||||
|
"/@ann/playlists/mix",
|
||||||
|
"_blank",
|
||||||
|
"noopener",
|
||||||
|
);
|
||||||
|
expect(currentPath()).toBe("/");
|
||||||
|
opened.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it dates its last dump, or its creation while it holds none", () => {
|
||||||
|
const day = 86_400_000;
|
||||||
|
const createdAt = new Date(Date.now() - 40 * day);
|
||||||
|
const filled = renderWithContexts(
|
||||||
|
<ul>
|
||||||
|
<PlaylistCard
|
||||||
|
playlist={makePlaylist({
|
||||||
|
createdAt,
|
||||||
|
lastDumpAt: new Date(Date.now() - 2 * day),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</ul>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("updated 2 days ago")).toBeInTheDocument();
|
||||||
|
filled.unmount();
|
||||||
|
renderWithContexts(
|
||||||
|
<ul>
|
||||||
|
<PlaylistCard playlist={makePlaylist({ createdAt })} />
|
||||||
|
</ul>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("created last month")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the description shows as text, without its Markdown or its images", () => {
|
||||||
|
const withText = renderWithContexts(
|
||||||
|
<ul>
|
||||||
|
<PlaylistCard
|
||||||
|
playlist={makePlaylist({
|
||||||
|
description:
|
||||||
|
"**Late** [tunes](https://x.y) ",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</ul>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
withText.container.querySelector(".playlist-card-description"),
|
||||||
|
).toHaveTextContent(/^Late tunes$/);
|
||||||
|
withText.unmount();
|
||||||
|
|
||||||
|
const imageOnly = renderWithContexts(
|
||||||
|
<ul>
|
||||||
|
<PlaylistCard
|
||||||
|
playlist={makePlaylist({ description: "" })}
|
||||||
|
/>
|
||||||
|
</ul>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
imageOnly.container.querySelector(".playlist-card-description"),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
test("the owner doesn't see their own name, or an unread dot", () => {
|
test("the owner doesn't see their own name, or an unread dot", () => {
|
||||||
const p = makePlaylist({ ownerUsername: "me", createdAt: new Date() });
|
const p = makePlaylist({ ownerUsername: "me", createdAt: new Date() });
|
||||||
const { container } = renderWithContexts(
|
const { container } = renderWithContexts(
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ function setup({ signedIn = true } = {}) {
|
|||||||
events: {
|
events: {
|
||||||
vote: useEvents("vote"),
|
vote: useEvents("vote"),
|
||||||
dump: useEvents("dump"),
|
dump: useEvents("dump"),
|
||||||
|
related: useEvents("related"),
|
||||||
playlist: useEvents("playlist"),
|
playlist: useEvents("playlist"),
|
||||||
comment: useEvents("comment"),
|
comment: useEvents("comment"),
|
||||||
user: useEvents("user"),
|
user: useEvents("user"),
|
||||||
@@ -458,6 +459,13 @@ describe("dumps, playlists, comments, users", () => {
|
|||||||
expect(ws().recentDumps).toEqual([]);
|
expect(ws().recentDumps).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("changed links are passed on as a related event", () => {
|
||||||
|
const { socket, events } = setup();
|
||||||
|
socket.welcome();
|
||||||
|
socket.receive({ type: "dump_related_changed", dumpIds: ["a", "b"] });
|
||||||
|
expect(events().related).toEqual([{ dumpIds: ["a", "b"] }]);
|
||||||
|
});
|
||||||
|
|
||||||
test("injectDump adds my own new dump once", () => {
|
test("injectDump adds my own new dump once", () => {
|
||||||
const { ws } = setup();
|
const { ws } = setup();
|
||||||
const d = makeDump({ id: "mine" });
|
const d = makeDump({ id: "mine" });
|
||||||
@@ -481,13 +489,11 @@ describe("dumps, playlists, comments, users", () => {
|
|||||||
type: "playlist_dumps_updated",
|
type: "playlist_dumps_updated",
|
||||||
playlistId: "p",
|
playlistId: "p",
|
||||||
dumpIds: ["x"],
|
dumpIds: ["x"],
|
||||||
dumpCount: 2,
|
|
||||||
});
|
});
|
||||||
expect(events().playlist.at(-1)).toEqual({
|
expect(events().playlist.at(-1)).toEqual({
|
||||||
type: "dumps_updated",
|
type: "dumps_updated",
|
||||||
playlistId: "p",
|
playlistId: "p",
|
||||||
dumpIds: ["x"],
|
dumpIds: ["x"],
|
||||||
dumpCount: 2,
|
|
||||||
});
|
});
|
||||||
socket.receive({ type: "playlist_deleted", playlistId: "p", userId: "u" });
|
socket.receive({ type: "playlist_deleted", playlistId: "p", userId: "u" });
|
||||||
expect(ws().deletedPlaylistIds.has("p")).toBe(true);
|
expect(ws().deletedPlaylistIds.has("p")).toBe(true);
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ describe("usePlaylistListSync", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("prepends a created playlist once", () => {
|
test("prepends a created playlist once", () => {
|
||||||
const { send, items } = setup([makePlaylist({ id: "a" })]);
|
const { send, items } = setup([makePlaylist({ id: "a" })], {
|
||||||
|
sort: "new",
|
||||||
|
});
|
||||||
const p = makePlaylist({ id: "b" });
|
const p = makePlaylist({ id: "b" });
|
||||||
send("created", p);
|
send("created", p);
|
||||||
send("created", { ...p });
|
send("created", { ...p });
|
||||||
@@ -36,28 +38,83 @@ describe("usePlaylistListSync", () => {
|
|||||||
test("a playlist made public goes where its date puts it", () => {
|
test("a playlist made public goes where its date puts it", () => {
|
||||||
const at = (id: string, day: number) =>
|
const at = (id: string, day: number) =>
|
||||||
makePlaylist({ id, createdAt: new Date(Date.UTC(2026, 0, day)) });
|
makePlaylist({ id, createdAt: new Date(Date.UTC(2026, 0, day)) });
|
||||||
const { send, items } = setup([at("c", 30), at("a", 10)]);
|
const { send, items } = setup([at("c", 30), at("a", 10)], {
|
||||||
|
sort: "new",
|
||||||
|
});
|
||||||
send("created", at("b", 20));
|
send("created", at("b", 20));
|
||||||
// Older than every one loaded: a later page holds it.
|
// Older than every one loaded: a later page holds it.
|
||||||
send("created", at("old", 1));
|
send("created", at("old", 1));
|
||||||
expect(ids(items())).toEqual(["c", "b", "a"]);
|
expect(ids(items())).toEqual(["c", "b", "a"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("created: filtered by owner, privacy and noNewEntries", () => {
|
test("created: filtered by owner and privacy, and added to a sorted list only", () => {
|
||||||
const p = makePlaylist({ id: "p", userId: "them" });
|
const p = makePlaylist({ id: "p", userId: "them" });
|
||||||
const byOwner = setup([], { ownerId: "me" });
|
const byOwner = setup([], { ownerId: "me", sort: "new" });
|
||||||
byOwner.send("created", p);
|
byOwner.send("created", p);
|
||||||
expect(byOwner.items()).toEqual([]);
|
expect(byOwner.items()).toEqual([]);
|
||||||
|
|
||||||
const priv = setup([]);
|
const priv = setup([], { sort: "new" });
|
||||||
priv.send("created", { ...p, isPublic: false });
|
priv.send("created", { ...p, isPublic: false });
|
||||||
expect(priv.items()).toEqual([]);
|
expect(priv.items()).toEqual([]);
|
||||||
|
|
||||||
const followed = setup([], { noNewEntries: true });
|
// A list in an order of its own, as the followed playlists'.
|
||||||
|
const followed = setup([]);
|
||||||
followed.send("created", p);
|
followed.send("created", p);
|
||||||
expect(followed.items()).toEqual([]);
|
expect(followed.items()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a created playlist goes where the list's order puts it", () => {
|
||||||
|
const at = (id: string, day: number, dumpCount: number, added?: number) =>
|
||||||
|
makePlaylist({
|
||||||
|
id,
|
||||||
|
dumpCount,
|
||||||
|
createdAt: new Date(Date.UTC(2026, 0, day)),
|
||||||
|
lastDumpAt: added ? new Date(Date.UTC(2026, 1, added)) : undefined,
|
||||||
|
});
|
||||||
|
const loaded = [at("a", 1, 3, 20), at("b", 2, 2, 10), at("c", 3, 0)];
|
||||||
|
// Made between b and c, it took a dump after b did and before a did.
|
||||||
|
const made = at("new", 2.5, 1, 15);
|
||||||
|
|
||||||
|
const updated = setup(loaded, { sort: "updated" });
|
||||||
|
updated.send("created", made);
|
||||||
|
expect(ids(updated.items())).toEqual(["a", "new", "b", "c"]);
|
||||||
|
|
||||||
|
const dumps = setup(loaded, { sort: "dumps" });
|
||||||
|
dumps.send("created", made);
|
||||||
|
expect(ids(dumps.items())).toEqual(["a", "b", "new", "c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a playlist that rises in the order moves up, one that falls stays", () => {
|
||||||
|
const at = (id: string, day: number) =>
|
||||||
|
makePlaylist({
|
||||||
|
id,
|
||||||
|
dumpCount: 1,
|
||||||
|
lastDumpAt: new Date(Date.UTC(2026, 0, day)),
|
||||||
|
});
|
||||||
|
const loaded = [at("a", 30), at("b", 20), at("c", 10)];
|
||||||
|
const dumped = (playlistId: string, day?: number) => ({
|
||||||
|
type: "dumps_updated" as const,
|
||||||
|
playlistId,
|
||||||
|
dumpIds: day ? ["x", "y"] : [],
|
||||||
|
lastDumpAt: day ? new Date(Date.UTC(2026, 0, day)) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sorted = setup(loaded, { sort: "updated" });
|
||||||
|
sorted.emit("playlist", dumped("c", 25));
|
||||||
|
expect(ids(sorted.items())).toEqual(["a", "c", "b"]);
|
||||||
|
expect(sorted.items()[1].dumpCount).toBe(2);
|
||||||
|
sorted.send("updated", { ...at("b", 31), title: "Risen" });
|
||||||
|
expect(ids(sorted.items())).toEqual(["b", "a", "c"]);
|
||||||
|
// Emptied, it comes after every other, perhaps on a page not loaded.
|
||||||
|
sorted.emit("playlist", dumped("b"));
|
||||||
|
expect(ids(sorted.items())).toEqual(["b", "a", "c"]);
|
||||||
|
expect(sorted.items()[0].lastDumpAt).toBeUndefined();
|
||||||
|
|
||||||
|
const unsorted = setup(loaded);
|
||||||
|
unsorted.emit("playlist", dumped("c", 25));
|
||||||
|
expect(ids(unsorted.items())).toEqual(["a", "b", "c"]);
|
||||||
|
});
|
||||||
|
|
||||||
test("updated: in place, removed when private, never added by itself", () => {
|
test("updated: in place, removed when private, never added by itself", () => {
|
||||||
const p = makePlaylist({ id: "p", title: "one" });
|
const p = makePlaylist({ id: "p", title: "one" });
|
||||||
const other = makePlaylist({ id: "q" });
|
const other = makePlaylist({ id: "q" });
|
||||||
@@ -72,7 +129,7 @@ describe("usePlaylistListSync", () => {
|
|||||||
|
|
||||||
test("one that left comes back where it was, even to a followed list", () => {
|
test("one that left comes back where it was, even to a followed list", () => {
|
||||||
const initial = ["a", "b", "c"].map((id) => makePlaylist({ id }));
|
const initial = ["a", "b", "c"].map((id) => makePlaylist({ id }));
|
||||||
const { set, send, items } = setup(initial, { noNewEntries: true });
|
const { set, send, items } = setup(initial);
|
||||||
// Gone private for others, as a deletion.
|
// Gone private for others, as a deletion.
|
||||||
set({ deletedPlaylistIds: new Set(["b"]) });
|
set({ deletedPlaylistIds: new Set(["b"]) });
|
||||||
expect(ids(items())).toEqual(["a", "c"]);
|
expect(ids(items())).toEqual(["a", "c"]);
|
||||||
@@ -99,7 +156,7 @@ describe("usePlaylistListSync", () => {
|
|||||||
expect(ids(items())).toEqual(["c"]);
|
expect(ids(items())).toEqual(["c"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("dumps_updated sets the count", () => {
|
test("dumps_updated counts the dumps this viewer may see", () => {
|
||||||
const { emit, items } = setup([makePlaylist({ id: "a", dumpCount: 1 })]);
|
const { emit, items } = setup([makePlaylist({ id: "a", dumpCount: 1 })]);
|
||||||
emit("playlist", {
|
emit("playlist", {
|
||||||
type: "dumps_updated",
|
type: "dumps_updated",
|
||||||
@@ -109,17 +166,6 @@ describe("usePlaylistListSync", () => {
|
|||||||
expect(items()[0].dumpCount).toBe(3);
|
expect(items()[0].dumpCount).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("dumps_updated counts the members this viewer can't see too", () => {
|
|
||||||
const { emit, items } = setup([makePlaylist({ id: "a", dumpCount: 3 })]);
|
|
||||||
emit("playlist", {
|
|
||||||
type: "dumps_updated",
|
|
||||||
playlistId: "a",
|
|
||||||
dumpIds: ["x", "y"],
|
|
||||||
dumpCount: 4,
|
|
||||||
});
|
|
||||||
expect(items()[0].dumpCount).toBe(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a renamed owner's playlists take the new name", () => {
|
test("a renamed owner's playlists take the new name", () => {
|
||||||
const { emit, items } = setup([
|
const { emit, items } = setup([
|
||||||
makePlaylist({ id: "a", userId: "u1", ownerUsername: "old" }),
|
makePlaylist({ id: "a", userId: "u1", ownerUsername: "old" }),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
import { act, screen, waitFor, within } from "@testing-library/react";
|
import { act, screen, waitFor, within } from "@testing-library/react";
|
||||||
import { userEvent } from "@testing-library/user-event";
|
import { userEvent } from "@testing-library/user-event";
|
||||||
import { Route } from "react-router";
|
import { Link, Route } from "react-router";
|
||||||
import { Dump } from "../../pages/Dump.tsx";
|
import { Dump } from "../../pages/Dump.tsx";
|
||||||
import { DumpEdit } from "../../pages/DumpEdit.tsx";
|
import { DumpEdit } from "../../pages/DumpEdit.tsx";
|
||||||
import { RestrictedLoggedIn } from "../../pages/RestrictedLoggedIn.tsx";
|
import { RestrictedLoggedIn } from "../../pages/RestrictedLoggedIn.tsx";
|
||||||
@@ -225,6 +225,73 @@ describe("Dump page", () => {
|
|||||||
expect(await screen.findByText("Best of")).toBeInTheDocument();
|
expect(await screen.findByText("Best of")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a related dump's link leads to that dump", async () => {
|
||||||
|
const first = dumpRoutes();
|
||||||
|
const second = makeDump({
|
||||||
|
id: "d2",
|
||||||
|
slug: "next-find",
|
||||||
|
title: "Next find",
|
||||||
|
});
|
||||||
|
route(
|
||||||
|
"GET /api/users/:u/dumps/:slug",
|
||||||
|
({ params }) => wire(params.slug === second.slug ? second : first),
|
||||||
|
);
|
||||||
|
route(
|
||||||
|
"GET /api/dumps/:id/related",
|
||||||
|
({ params }) => wire(params.id === first.id ? [second] : []),
|
||||||
|
);
|
||||||
|
const { user } = openDump();
|
||||||
|
await user.click(await screen.findByRole("link", { name: "Next find" }));
|
||||||
|
expect(await screen.findByRole("heading", { name: "Next find", level: 1 }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
expect(currentPath()).toBe("/@owner/dumps/next-find");
|
||||||
|
// Its parts are the new dump's.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByRole("heading", { name: "Related" })).not
|
||||||
|
.toBeInTheDocument()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a dump handed over is fetched when come back to", async () => {
|
||||||
|
const first = dumpRoutes();
|
||||||
|
const second = makeDump({
|
||||||
|
id: "d2",
|
||||||
|
slug: "next-find",
|
||||||
|
title: "Next find",
|
||||||
|
});
|
||||||
|
route(
|
||||||
|
"GET /api/users/:u/dumps/:slug",
|
||||||
|
({ params }) => wire(params.slug === second.slug ? second : first),
|
||||||
|
);
|
||||||
|
route(
|
||||||
|
"GET /api/dumps/:id/related",
|
||||||
|
({ params }) => wire([params.id === first.id ? second : first]),
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderApp(<Dump />, {
|
||||||
|
route: "/start",
|
||||||
|
path: "/:handle/dumps/:slug",
|
||||||
|
extraRoutes: (
|
||||||
|
<Route
|
||||||
|
path="/start"
|
||||||
|
element={
|
||||||
|
<Link to="/@owner/dumps/a-find" state={{ dump: first }}>open</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
FakeWebSocket.last.welcome();
|
||||||
|
await user.click(screen.getByRole("link", { name: "open" }));
|
||||||
|
await user.click(await screen.findByRole("link", { name: "Next find" }));
|
||||||
|
await user.click(await screen.findByRole("link", { name: "A find" }));
|
||||||
|
expect(await screen.findByRole("heading", { name: "A find", level: 1 }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
expect(calls("GET /api/users/:u/dumps/:slug").map((c) => c.path)).toEqual([
|
||||||
|
"/api/users/@owner/dumps/next-find",
|
||||||
|
"/api/users/@owner/dumps/a-find",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test("a live update of this dump is applied", async () => {
|
test("a live update of this dump is applied", async () => {
|
||||||
const d = dumpRoutes();
|
const d = dumpRoutes();
|
||||||
const { socket } = openDump();
|
const { socket } = openDump();
|
||||||
@@ -237,6 +304,25 @@ describe("Dump page", () => {
|
|||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a new link to or from this dump shows among its related dumps", async () => {
|
||||||
|
dumpRoutes();
|
||||||
|
const { socket } = openDump();
|
||||||
|
await screen.findByRole("heading", { name: "A find" });
|
||||||
|
expect(screen.queryByRole("heading", { name: "Related" })).toBeNull();
|
||||||
|
route(
|
||||||
|
"GET /api/dumps/:id/related",
|
||||||
|
() => [wire(makeDump({ id: "d2", slug: "linked", title: "Linked" }))],
|
||||||
|
);
|
||||||
|
// Links between other dumps leave this page alone.
|
||||||
|
socket.receive({ type: "dump_related_changed", dumpIds: ["d2", "d3"] });
|
||||||
|
socket.receive({ type: "dump_related_changed", dumpIds: ["d2", "d1"] });
|
||||||
|
expect(await screen.findByRole("link", { name: "Linked" }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("heading", { name: "Related" }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
expect(calls("GET /api/dumps/:id/related")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
test("a dump deleted, or made private, while on screen gives way to a notice", async () => {
|
test("a dump deleted, or made private, while on screen gives way to a notice", async () => {
|
||||||
dumpRoutes();
|
dumpRoutes();
|
||||||
const { socket } = openDump(other);
|
const { socket } = openDump(other);
|
||||||
|
|||||||
@@ -423,7 +423,6 @@ describe("Followed feed", () => {
|
|||||||
type: "playlist_dumps_updated",
|
type: "playlist_dumps_updated",
|
||||||
playlistId: "mix",
|
playlistId: "mix",
|
||||||
dumpIds: ["x"],
|
dumpIds: ["x"],
|
||||||
dumpCount: 1,
|
|
||||||
});
|
});
|
||||||
expect(await screen.findByText("Added to the mix")).toBeInTheDocument();
|
expect(await screen.findByText("Added to the mix")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -452,7 +452,6 @@ describe("PlaylistDetail", () => {
|
|||||||
type: "playlist_dumps_updated",
|
type: "playlist_dumps_updated",
|
||||||
playlistId: "p1",
|
playlistId: "p1",
|
||||||
dumpIds: ["a", "c"],
|
dumpIds: ["a", "c"],
|
||||||
dumpCount: 3,
|
|
||||||
});
|
});
|
||||||
expect(titles()).toEqual(["Alpha", "Charlie"]);
|
expect(titles()).toEqual(["Alpha", "Charlie"]);
|
||||||
socket.receive({
|
socket.receive({
|
||||||
@@ -463,7 +462,6 @@ describe("PlaylistDetail", () => {
|
|||||||
type: "playlist_dumps_updated",
|
type: "playlist_dumps_updated",
|
||||||
playlistId: "p1",
|
playlistId: "p1",
|
||||||
dumpIds: ["a", "b", "c"],
|
dumpIds: ["a", "b", "c"],
|
||||||
dumpCount: 3,
|
|
||||||
});
|
});
|
||||||
expect(await screen.findByText("Bravo")).toBeInTheDocument();
|
expect(await screen.findByText("Bravo")).toBeInTheDocument();
|
||||||
expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"]);
|
expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"]);
|
||||||
@@ -522,7 +520,6 @@ describe("PlaylistDetail", () => {
|
|||||||
type: "playlist_dumps_updated",
|
type: "playlist_dumps_updated",
|
||||||
playlistId: "p1",
|
playlistId: "p1",
|
||||||
dumpIds: ["d", "a", "b", "c"],
|
dumpIds: ["d", "a", "b", "c"],
|
||||||
dumpCount: 4,
|
|
||||||
});
|
});
|
||||||
expect(await screen.findByText("Delta")).toBeInTheDocument();
|
expect(await screen.findByText("Delta")).toBeInTheDocument();
|
||||||
socket.receive({
|
socket.receive({
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { Route } from "react-router";
|
||||||
import { screen } from "@testing-library/react";
|
import { screen } from "@testing-library/react";
|
||||||
import { userEvent } from "@testing-library/user-event";
|
import { userEvent } from "@testing-library/user-event";
|
||||||
import { Playlists } from "../../pages/Playlists.tsx";
|
import { Playlists } from "../../pages/Playlists.tsx";
|
||||||
import { calls, route } from "../fetch.ts";
|
import { calls, route } from "../fetch.ts";
|
||||||
import { makeCategory, makePlaylist, page, wire } from "../fixtures.ts";
|
import {
|
||||||
|
makeCategory,
|
||||||
|
makePlaylist,
|
||||||
|
makeSession,
|
||||||
|
page,
|
||||||
|
wire,
|
||||||
|
} from "../fixtures.ts";
|
||||||
|
import type { Session } from "../../utils/auth/storage.ts";
|
||||||
import { currentPath, renderApp } from "../render.tsx";
|
import { currentPath, renderApp } from "../render.tsx";
|
||||||
import { FakeWebSocket } from "../ws.ts";
|
import { FakeWebSocket } from "../ws.ts";
|
||||||
|
|
||||||
@@ -15,12 +23,14 @@ const mixes = makeCategory({
|
|||||||
});
|
});
|
||||||
const music = makeCategory({ id: "dc1", slug: "music", name: "Music" });
|
const music = makeCategory({ id: "dc1", slug: "music", name: "Music" });
|
||||||
|
|
||||||
function open(url: string) {
|
function open(url: string, session?: Session) {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderApp(<Playlists />, {
|
renderApp(<Playlists />, {
|
||||||
route: url,
|
route: url,
|
||||||
path: "/playlists/:categorySlug?",
|
path: "/playlists/:categorySlug?",
|
||||||
categories: [music, mixes],
|
categories: [music, mixes],
|
||||||
|
session,
|
||||||
|
extraRoutes: <Route path="/login" element={null} />,
|
||||||
});
|
});
|
||||||
return { user, socket: FakeWebSocket.last };
|
return { user, socket: FakeWebSocket.last };
|
||||||
}
|
}
|
||||||
@@ -85,6 +95,132 @@ describe("Playlists", () => {
|
|||||||
expect(screen.queryByText("Filed")).toBeNull();
|
expect(screen.queryByText("Filed")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the tabs change the order, in the URL, which picking a category keeps", async () => {
|
||||||
|
route(
|
||||||
|
"GET /api/playlists",
|
||||||
|
(req) =>
|
||||||
|
page([
|
||||||
|
makePlaylist({
|
||||||
|
title: `By ${req.url.searchParams.get("sort") ?? "default"}`,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const { user } = open("/playlists");
|
||||||
|
expect(await screen.findByText("By default")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Most dumps" }));
|
||||||
|
expect(currentPath()).toBe("/playlists?sort=dumps");
|
||||||
|
expect(await screen.findByText("By dumps")).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole("button", { name: "All" }));
|
||||||
|
await user.click(screen.getByRole("option", { name: "Mixes" }));
|
||||||
|
expect(currentPath()).toBe("/playlists/mixes?sort=dumps");
|
||||||
|
const asked = calls("GET /api/playlists").at(-1)!.url.searchParams;
|
||||||
|
expect([asked.get("category"), asked.get("sort")]).toEqual([
|
||||||
|
"mixes",
|
||||||
|
"dumps",
|
||||||
|
]);
|
||||||
|
// The default order is the bare path, and so is one the URL misnames.
|
||||||
|
await user.click(screen.getByRole("button", { name: "Updated" }));
|
||||||
|
expect(currentPath()).toBe("/playlists/mixes");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a playlist that takes a dump goes first in the updated order", async () => {
|
||||||
|
const at = (id: string, day: number) =>
|
||||||
|
makePlaylist({
|
||||||
|
id,
|
||||||
|
title: `Playlist ${id}`,
|
||||||
|
dumpCount: 1,
|
||||||
|
lastDumpAt: new Date(Date.UTC(2026, 0, day)),
|
||||||
|
});
|
||||||
|
route("GET /api/playlists", () => page([at("a", 20), at("b", 10)]));
|
||||||
|
const { socket } = open("/playlists");
|
||||||
|
await screen.findByText("Playlist a");
|
||||||
|
socket.welcome();
|
||||||
|
socket.receive({
|
||||||
|
type: "playlist_dumps_updated",
|
||||||
|
playlistId: "b",
|
||||||
|
dumpIds: ["x", "y"],
|
||||||
|
lastDumpAt: "2026-01-25T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
screen.getAllByRole("link", { name: /^Playlist \w$/ }).map((a) =>
|
||||||
|
a.textContent
|
||||||
|
),
|
||||||
|
).toEqual(["Playlist b", "Playlist a"]);
|
||||||
|
expect(screen.getByText("2 dumps")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a signed-in user's last tab lists their own, private ones too", async () => {
|
||||||
|
const session = makeSession({ id: "ann-id", username: "ann" });
|
||||||
|
route(
|
||||||
|
"GET /api/playlists",
|
||||||
|
() => page([makePlaylist({ title: "Theirs" })]),
|
||||||
|
);
|
||||||
|
route(
|
||||||
|
"GET /api/users/:u/playlists",
|
||||||
|
() =>
|
||||||
|
page([
|
||||||
|
makePlaylist({ title: "Hidden", userId: "ann-id", isPublic: false }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const { user, socket } = open("/playlists/mixes?sort=dumps", session);
|
||||||
|
expect(await screen.findByText("Theirs")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Their own come in one order: the tab takes the order's place.
|
||||||
|
await user.click(screen.getByRole("button", { name: "Mine" }));
|
||||||
|
expect(currentPath()).toBe("/playlists/mixes?mine=1");
|
||||||
|
expect(await screen.findByText("Hidden")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Theirs")).toBeNull();
|
||||||
|
const asked = calls("GET /api/users/:u/playlists").at(-1)!.url;
|
||||||
|
expect(asked.pathname).toBe("/api/users/ann-id/playlists");
|
||||||
|
expect([asked.searchParams.get("category"), asked.searchParams.get("sort")])
|
||||||
|
.toEqual(["mixes", null]);
|
||||||
|
|
||||||
|
// The socket brings their new ones, private or not, and no one else's.
|
||||||
|
socket.welcome();
|
||||||
|
for (
|
||||||
|
const playlist of [
|
||||||
|
makePlaylist({
|
||||||
|
title: "Mine too",
|
||||||
|
userId: "ann-id",
|
||||||
|
isPublic: false,
|
||||||
|
categoryIds: ["pc1"],
|
||||||
|
}),
|
||||||
|
makePlaylist({
|
||||||
|
title: "Bob's",
|
||||||
|
userId: "bob-id",
|
||||||
|
categoryIds: ["pc1"],
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
socket.receive({ type: "playlist_created", playlist: wire(playlist) });
|
||||||
|
}
|
||||||
|
expect(screen.getByText("Mine too")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Bob's")).toBeNull();
|
||||||
|
|
||||||
|
// Picking a category keeps the tab, an order leaves it.
|
||||||
|
await user.click(screen.getByRole("button", { name: "Mixes" }));
|
||||||
|
await user.click(screen.getByRole("option", { name: "All" }));
|
||||||
|
expect(currentPath()).toBe("/playlists?mine=1");
|
||||||
|
await user.click(screen.getByRole("button", { name: "New" }));
|
||||||
|
expect(currentPath()).toBe("/playlists?sort=new");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a guest has no such tab", async () => {
|
||||||
|
route(
|
||||||
|
"GET /api/playlists",
|
||||||
|
() => page([makePlaylist({ title: "Theirs" })]),
|
||||||
|
);
|
||||||
|
open("/playlists");
|
||||||
|
expect(await screen.findByText("Theirs")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Mine" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a guest sent to their own playlists signs in first", () => {
|
||||||
|
open("/playlists?mine=1");
|
||||||
|
expect(currentPath()).toBe("/login?next=%2Fplaylists%3Fmine%3D1");
|
||||||
|
});
|
||||||
|
|
||||||
test("a slug that names no playlist category is not found", async () => {
|
test("a slug that names no playlist category is not found", async () => {
|
||||||
route("GET /api/playlists", () => page([]));
|
route("GET /api/playlists", () => page([]));
|
||||||
open("/playlists/music");
|
open("/playlists/music");
|
||||||
|
|||||||
@@ -292,6 +292,42 @@ describe("UserPublicProfile", () => {
|
|||||||
expect(screen.queryByText("Filed mix")).not.toBeInTheDocument();
|
expect(screen.queryByText("Filed mix")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the playlists tab's order is in its URL, beside the category, and no other tab's", async () => {
|
||||||
|
const mixes = makeCategory({
|
||||||
|
id: "pc1",
|
||||||
|
kind: "playlist",
|
||||||
|
slug: "mixes",
|
||||||
|
name: "Mixes",
|
||||||
|
});
|
||||||
|
const { user } = open({ url: "/@ann/playlists", categories: [mixes] });
|
||||||
|
route(
|
||||||
|
"GET /api/users/:u/playlists",
|
||||||
|
() => page([makePlaylist({ title: "Mix", userId: "ann" })]),
|
||||||
|
);
|
||||||
|
const asked = () =>
|
||||||
|
calls("GET /api/users/:u/playlists").at(-1)!.url.searchParams;
|
||||||
|
await screen.findByText("Mix");
|
||||||
|
expect(asked().get("sort")).toBeNull();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "New" }));
|
||||||
|
expect(currentPath()).toBe("/@ann/playlists?sort=new");
|
||||||
|
await waitFor(() => expect(asked().get("sort")).toBe("new"));
|
||||||
|
await user.click(screen.getByRole("button", { name: "All" }));
|
||||||
|
await user.click(screen.getByRole("option", { name: "Mixes" }));
|
||||||
|
expect(currentPath()).toBe("/@ann/playlists?sort=new&category=mixes");
|
||||||
|
await waitFor(() => expect(asked().get("category")).toBe("mixes"));
|
||||||
|
expect(asked().get("sort")).toBe("new");
|
||||||
|
// The default order is the bare tab's.
|
||||||
|
await user.click(screen.getByRole("button", { name: "Updated" }));
|
||||||
|
expect(currentPath()).toBe("/@ann/playlists?category=mixes");
|
||||||
|
|
||||||
|
// Neither the order nor a playlist category means anything to the dumps.
|
||||||
|
await user.click(screen.getByRole("button", { name: "Most dumps" }));
|
||||||
|
expect(currentPath()).toBe("/@ann/playlists?category=mixes&sort=dumps");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Dumps" }));
|
||||||
|
expect(currentPath()).toBe("/@ann/dumps");
|
||||||
|
});
|
||||||
|
|
||||||
test("the dumps tab loads more as its end scrolls into view", async () => {
|
test("the dumps tab loads more as its end scrolls into view", async () => {
|
||||||
open({ url: "/@ann/dumps" });
|
open({ url: "/@ann/dumps" });
|
||||||
// After open's, so that it wins.
|
// After open's, so that it wins.
|
||||||
|
|||||||
@@ -478,7 +478,6 @@
|
|||||||
[data-style="nyt"] .notifications-empty-icon,
|
[data-style="nyt"] .notifications-empty-icon,
|
||||||
[data-style="nyt"] .page-title-icon,
|
[data-style="nyt"] .page-title-icon,
|
||||||
[data-style="nyt"] .dump-card-preview-icon,
|
[data-style="nyt"] .dump-card-preview-icon,
|
||||||
[data-style="nyt"] .playlist-card-icon,
|
|
||||||
[data-style="nyt"] .rich-content-compact-icon,
|
[data-style="nyt"] .rich-content-compact-icon,
|
||||||
[data-style="nyt"] .journal-card-glyph,
|
[data-style="nyt"] .journal-card-glyph,
|
||||||
[data-style="nyt"] .fdz__file-icon,
|
[data-style="nyt"] .fdz__file-icon,
|
||||||
|
|||||||
@@ -105,22 +105,39 @@ export function withFirst<T extends { id: string }>(items: T[], item: T): T[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns `items`, newest first as the API pages them, with `item` where its
|
* Returns `items`, in the order of `compare` as the API pages them, with
|
||||||
* `createdAt` puts it: before the first item no newer than it. An item older
|
* `item` where that order puts it: before the first item that doesn't come
|
||||||
* than every one loaded is left to a later page, and `items` returned as they
|
* before it. An item that comes after every one loaded is left to a later
|
||||||
* are, as they are when `item` is already there. The list sync hooks put a
|
* page, and `items` returned as they are, as they are when `item` is already
|
||||||
* new or newly visible dump or playlist in a list with it.
|
* there. `usePlaylistListSync` puts a new, newly visible or risen playlist in
|
||||||
|
* a list with it, whatever its order.
|
||||||
|
*/
|
||||||
|
export function withInOrder<T extends { id: string }>(
|
||||||
|
items: T[],
|
||||||
|
item: T,
|
||||||
|
compare: (a: T, b: T) => number,
|
||||||
|
): T[] {
|
||||||
|
if (items.some((i) => i.id === item.id)) return items;
|
||||||
|
if (items.length === 0) return [item];
|
||||||
|
const idx = items.findIndex((i) => compare(i, item) >= 0);
|
||||||
|
if (idx === -1) return items;
|
||||||
|
return [...items.slice(0, idx), item, ...items.slice(idx)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns `items`, newest first, with `item` where its `createdAt` puts it,
|
||||||
|
* as {@link withInOrder} does. `useDumpListSync` puts a new or newly visible
|
||||||
|
* dump in a list with it.
|
||||||
*/
|
*/
|
||||||
export function withByDate<T extends { id: string; createdAt: Date }>(
|
export function withByDate<T extends { id: string; createdAt: Date }>(
|
||||||
items: T[],
|
items: T[],
|
||||||
item: T,
|
item: T,
|
||||||
): T[] {
|
): T[] {
|
||||||
if (items.some((i) => i.id === item.id)) return items;
|
return withInOrder(
|
||||||
if (items.length === 0) return [item];
|
items,
|
||||||
const time = item.createdAt.getTime();
|
item,
|
||||||
const idx = items.findIndex((i) => i.createdAt.getTime() <= time);
|
(a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
|
||||||
if (idx === -1) return items;
|
);
|
||||||
return [...items.slice(0, idx), item, ...items.slice(idx)];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -95,7 +95,10 @@ export function deserializeChatMessage(raw: Wire<ChatMessage>): ChatMessage {
|
|||||||
|
|
||||||
/** Returns `raw` as a {@link Playlist}. */
|
/** Returns `raw` as a {@link Playlist}. */
|
||||||
export function deserializePlaylist(raw: Wire<Playlist>): Playlist {
|
export function deserializePlaylist(raw: Wire<Playlist>): Playlist {
|
||||||
return dates(raw);
|
return {
|
||||||
|
...dates(raw),
|
||||||
|
lastDumpAt: raw.lastDumpAt ? new Date(raw.lastDumpAt) : undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -110,7 +113,10 @@ export function hydratePlaylist(raw: unknown): Playlist {
|
|||||||
export function deserializePlaylistWithDumps(
|
export function deserializePlaylistWithDumps(
|
||||||
raw: Wire<PlaylistWithDumps>,
|
raw: Wire<PlaylistWithDumps>,
|
||||||
): PlaylistWithDumps {
|
): PlaylistWithDumps {
|
||||||
return { ...dates(raw), dumps: raw.dumps.map(deserializeDump) };
|
return {
|
||||||
|
...deserializePlaylist(raw),
|
||||||
|
dumps: raw.dumps.map(deserializeDump),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns `raw` as a {@link PlaylistMembership}. */
|
/** Returns `raw` as a {@link PlaylistMembership}. */
|
||||||
|
|||||||
Reference in New Issue
Block a user