v4: playlists have categories of their own — a set apart from the dumps', which admins manage on /manage under "Playlist categories", next to "Dump categories". Each category now has a kind, dump or playlist, its slug and name unique within its kind only; migration 0025 rebuilds categories with the column, every existing one becoming a dump category with its dumps, and adds playlist_categories. A playlist's owner files it under any of them in the create and edit forms, and it shows them as chips on its card and its page, each leading to /playlists/<slug>.

/playlists is the new page of every public playlist, newest first (GET /api/playlists), and /playlists/<slug> a playlist category's, with a menu of the categories, linked from the user menu as "All playlists"; `playlists` is reserved as a dump category's slug, and one that had it is renamed. A profile's Playlists tab narrows to a playlist category with the same menu as its Dumps tab (?category=), and so do the Dumps and Playlists tabs of /search, each with its own kind (category and playlistCategory for GET /api/search), another tab dropping it.

The category service takes the kind throughout, so a dump can't be filed under a playlist category nor the other way round, and categoryJoin is the one filter join the dump, follow and playlist services share. useCategories(kind) gives one kind's set from the one fetch, CategoryChips replaces the chips the dump card and page each drew, useCategoryFilter keeps a list to its category as the socket brings more, and useCategorySelection holds a form's picked categories.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-09 21:53:16 +00:00
parent 681170a517
commit 53515ce46f
71 changed files with 2166 additions and 705 deletions

View File

@@ -31,6 +31,7 @@ import { up as up0021PageUrls } from "./migrations/0021_page_urls.ts";
import { up as up0022ReserveCommunity } from "./migrations/0022_reserve_community.ts";
import { up as up0023MentionEveryone } from "./migrations/0023_mention_everyone.ts";
import { up as up0024Federation } from "./migrations/0024_federation.ts";
import { up as up0025PlaylistCategories } from "./migrations/0025_playlist_categories.ts";
interface Migration {
name: string;
@@ -74,6 +75,7 @@ const MIGRATIONS: Migration[] = [
{ name: "0022_reserve_community", up: up0022ReserveCommunity },
{ name: "0023_mention_everyone", up: up0023MentionEveryone },
{ name: "0024_federation", up: up0024Federation },
{ name: "0025_playlist_categories", up: up0025PlaylistCategories },
];
/**

View File

@@ -0,0 +1,110 @@
/**
* Gives playlists categories of their own, a set apart from the dumps': each
* category gets a `kind`, `dump` or `playlist`, its slug and name now unique
* within its kind only, and `playlist_categories` files playlists under them.
*
* `/playlists` becomes the page of every public playlist, so its first segment
* is reserved.
*
* @module
*/
import type { DatabaseSync } from "node:sqlite";
import { renameReservedCategories } from "../../lib/reserved-slugs.ts";
/**
* Run once by `runMigrations`. Every existing category becomes a dump
* category, filing the same dumps.
*
* SQLite can't change a table's UNIQUE constraints in place, so `categories`
* is rebuilt. `foreign_keys` can't be switched off inside the runner's
* transaction, and dropping `categories` would then empty `dump_categories`
* through its cascade, so both are read, dropped child first, made anew as in
* schema.sql, and refilled. A database made from schema.sql already has the
* `kind` column and is left as it is.
*/
export function up(db: DatabaseSync): void {
const columns = new Set(
(db.prepare(`PRAGMA table_info(categories);`).all() as { name: string }[])
.map((c) => c.name),
);
if (!columns.has("kind")) {
const categories = db.prepare(
`SELECT id, slug, name, position, created_at, updated_at FROM categories;`,
).all() as {
id: string;
slug: string;
name: string;
position: number;
created_at: string;
updated_at: string | null;
}[];
const links = db.prepare(
`SELECT dump_id, category_id FROM dump_categories;`,
).all() as { dump_id: string; category_id: string }[];
db.exec(`DROP TABLE dump_categories;`);
db.exec(`DROP TABLE categories;`);
db.exec(
`CREATE TABLE categories (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL DEFAULT 'dump' CHECK (kind IN ('dump', 'playlist')),
slug TEXT NOT NULL,
name TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT,
UNIQUE (kind, slug),
UNIQUE (kind, name)
);`,
);
db.exec(
`CREATE TABLE dump_categories (
dump_id TEXT NOT NULL,
category_id TEXT NOT NULL,
PRIMARY KEY (dump_id, category_id),
FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
);`,
);
db.exec(
`CREATE INDEX idx_dump_categories_category ON dump_categories(category_id);`,
);
const insertCategory = db.prepare(
`INSERT INTO categories (id, kind, slug, name, position, created_at, updated_at)
VALUES (?, 'dump', ?, ?, ?, ?, ?);`,
);
for (const c of categories) {
insertCategory.run(
c.id,
c.slug,
c.name,
c.position,
c.created_at,
c.updated_at,
);
}
const insertLink = db.prepare(
`INSERT INTO dump_categories (dump_id, category_id) VALUES (?, ?);`,
);
for (const l of links) insertLink.run(l.dump_id, l.category_id);
}
db.exec(
`CREATE TABLE IF NOT EXISTS playlist_categories (
playlist_id TEXT NOT NULL,
category_id TEXT NOT NULL,
PRIMARY KEY (playlist_id, category_id),
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
);`,
);
db.exec(
`CREATE INDEX IF NOT EXISTS idx_playlist_categories_category ON playlist_categories(category_id);`,
);
renameReservedCategories(db);
}

View File

@@ -69,11 +69,14 @@ CREATE TABLE playlist_dumps (
CREATE TABLE categories (
id TEXT PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL DEFAULT 'dump' CHECK (kind IN ('dump', 'playlist')),
slug TEXT NOT NULL,
name TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT
updated_at TEXT,
UNIQUE (kind, slug),
UNIQUE (kind, name)
);
CREATE TABLE dump_categories (
@@ -84,6 +87,14 @@ CREATE TABLE dump_categories (
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
);
CREATE TABLE playlist_categories (
playlist_id TEXT NOT NULL,
category_id TEXT NOT NULL,
PRIMARY KEY (playlist_id, category_id),
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
);
CREATE TABLE comments (
id TEXT PRIMARY KEY,
dump_id TEXT NOT NULL,
@@ -136,6 +147,7 @@ CREATE INDEX idx_comment_likes_user ON comment_likes(user_id);
CREATE INDEX idx_dump_backlinks_to ON dump_backlinks(to_dump_id);
CREATE INDEX idx_dump_backlinks_from ON dump_backlinks(from_dump_id);
CREATE INDEX idx_dump_categories_category ON dump_categories(category_id);
CREATE INDEX idx_playlist_categories_category ON playlist_categories(category_id);
CREATE TABLE follows (
id TEXT PRIMARY KEY,

View File

@@ -1,9 +1,10 @@
/**
* Checks which slugs a category may take. A category's feed lives at the URL
* root (`/music`), so its slug must not shadow another top-level page. The
* category service runs this check when a category is created or renamed, and
* the migrations that reserve more words rename the categories that had one
* with {@link renameReservedCategories}.
* Checks which slugs a dump category may take. A dump category's feed lives at
* the URL root (`/music`), so its slug must not shadow another top-level page.
* The category service runs this check when a dump category is created or
* renamed, and the migrations that reserve more words rename the categories
* that had one with {@link renameReservedCategories}. A playlist category's
* page is `/playlists/<slug>`, under a reserved word, so it reserves nothing.
*
* Dump and playlist slugs reserve nothing: they live under their owner's
* `dumps/` and `playlists/`, where nothing else is named by a user. A user
@@ -32,6 +33,7 @@ export const RESERVED_SLUGS: readonly string[] = [
"settings",
"manage",
"community",
"playlists",
"authorize",
"federation",
"api",
@@ -48,8 +50,13 @@ export function isReservedSlug(slug: string): boolean {
/**
* Renames each category whose slug is now reserved, such as `hot`, to the
* first of `<slug>-2`, `<slug>-3`… that is free, and logs it. Migration 0021
* runs it for v4's words, 0022 for `community`, and 0024 for `authorize` and
* `federation`.
* runs it for v4's words, 0022 for `community`, 0024 for `authorize` and
* `federation`, and 0025 for `playlists`.
*
* It reads every category: until 0025 gave categories a `kind`, each was a
* dump category, and 0025 makes no other. A later migration that reserves
* more words must leave the playlist categories alone, and limit it to
* `kind = 'dump'`, which the earlier ones can't, having no such column.
*/
export function renameReservedCategories(db: DatabaseSync): void {
const rows = db.prepare(`SELECT id, slug FROM categories;`).all() as {

View File

@@ -6,11 +6,16 @@
*/
import type { SQLOutputValue } from "node:sqlite";
import type { Category } from "../../shared/category.ts";
import {
type Category,
type CategoryKind,
isCategoryKind,
} from "../../shared/category.ts";
/** Returned by the category service's `SELECT`s on `categories`. */
export interface CategoryRow {
id: string;
kind: CategoryKind;
slug: string;
name: string;
/** Sorts the categories, lowest first. */
@@ -24,6 +29,7 @@ export interface CategoryRow {
export function isCategoryRow(obj: unknown): obj is CategoryRow {
return !!obj && typeof obj === "object" &&
"id" in obj && typeof obj.id === "string" &&
"kind" in obj && isCategoryKind(obj.kind) &&
"slug" in obj && typeof obj.slug === "string" &&
"name" in obj && typeof obj.name === "string" &&
"position" in obj && typeof obj.position === "number" &&
@@ -36,6 +42,7 @@ export function isCategoryRow(obj: unknown): obj is CategoryRow {
export function categoryRowToApi(row: CategoryRow): Category {
return {
id: row.id,
kind: row.kind,
slug: row.slug,
name: row.name,
position: row.position,

View File

@@ -1,13 +1,17 @@
/**
* Serves the categories dumps are filed under, and changes them for those with
* `category:manage`. A category's feed is `GET /api/dumps?category=:slug`.
* Serves the categories dumps and playlists are filed under, a set for each
* kind, and changes them for those with `category:manage`. A dump category's
* feed is `GET /api/dumps?category=:slug`, a playlist category's
* `GET /api/playlists?category=:slug`.
*
* - `GET /api/categories` — public. Answers every category, in order.
* - `POST /api/categories` — permission `category:manage`. Creates a category.
* - `GET /api/categories` — public. Answers every category of both kinds, by
* kind, then in order.
* - `POST /api/categories` — permission `category:manage`. Creates a category
* of the kind it names.
* - `PUT /api/categories/:id` — permission `category:manage`. Updates a
* category.
* - `DELETE /api/categories/:id` — permission `category:manage`. Deletes a
* category; its dumps stay.
* category; its dumps or playlists stay.
*
* @module
*/

View File

@@ -79,7 +79,7 @@ router.get("/feed", authMiddleware, (ctx) => {
throw new APIException(400, "from must be users or playlists");
}
const { page, limit } = parsePagination(params);
const categoryId = resolveCategoryFilter(params);
const categoryId = resolveCategoryFilter("dump", params);
const userId = ctx.state.user.userId;
const { items, hasMore } = categoryId === "missing"
? EMPTY_FEED

View File

@@ -3,12 +3,17 @@
* playlist's id; its page reads it by its path, through
* `GET /api/users/:key/playlists/:slug`.
*
* - `POST /api/playlists` — signed in. Creates a playlist.
* - `GET /api/playlists?category=&page=&limit=` — public. Answers a page of
* every public playlist, newest first, or only those in the playlist
* category at `category`, which 404s when there is none.
* - `POST /api/playlists` — signed in. Creates a playlist, filed under the
* playlist categories it names.
* - `GET /api/playlists/:playlistId` — optional sign-in. Answers a playlist
* with the dumps the caller may see in it. A private one 404s but for its
* owner.
* - `PUT /api/playlists/:playlistId` — signed in, as its owner or with
* `playlist:moderate`. Updates its title, slug, description and visibility.
* `playlist:moderate`. Updates its title, slug, description, visibility and
* categories.
* A new slug keeps the old one as an alias, 3 times a day at most but for
* those with `playlist:moderate`.
* - `DELETE /api/playlists/:playlistId` — signed in, as its owner or with
@@ -32,7 +37,7 @@
import { Router } from "@oak/oak";
import { authenticateMedia, parseOptionalAuth } from "../lib/auth.ts";
import { type APIResponse } from "../../shared/api.ts";
import { type APIResponse, type PaginatedData } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import {
isCreatePlaylistRequest,
@@ -49,6 +54,7 @@ import {
getEditablePlaylist,
getPlaylist,
getPlaylistImageInfo,
listPublicPlaylists,
removeDumpFromPlaylist,
reorderPlaylist,
setPlaylistImage,
@@ -60,6 +66,8 @@ import {
validateImageUpload,
} from "../lib/upload.ts";
import { PLAYLIST_IMAGES_DIR } from "../config.ts";
import { parsePagination } from "../lib/pagination.ts";
import { getCategoryBySlug } from "../services/category-service.ts";
/**
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
@@ -69,6 +77,19 @@ const router: Router<AuthState> = new Router<AuthState>({
prefix: "/api/playlists",
});
router.get("/", (ctx) => {
const params = ctx.request.url.searchParams;
const { page, limit } = parsePagination(params);
const slug = params.get("category");
const categoryId = slug ? getCategoryBySlug("playlist", slug).id : undefined;
const { items, hasMore } = listPublicPlaylists(page, limit, categoryId);
const responseBody: APIResponse<PaginatedData<Playlist>> = {
success: true,
data: { items, hasMore },
};
ctx.response.body = responseBody;
});
router.post("/", authMiddleware, async (ctx) => {
const body: unknown = await ctx.request.body.json();
if (!isCreatePlaylistRequest(body)) {

View File

@@ -3,9 +3,11 @@
* Only the dumps are paged. Private dumps and playlists are found only by
* their owner.
*
* - `GET /api/search?q=&page=&limit=` — optional sign-in. Answers a page of the
* matching dumps with their total, and up to 20 matching users and
* playlists. A blank `q` finds nothing.
* - `GET /api/search?q=&page=&limit=&category=&playlistCategory=` — optional
* sign-in. Answers a page of the matching dumps with their total, and up to
* 20 matching users and playlists. A blank `q` finds nothing. `category`
* keeps the dumps in that dump category, `playlistCategory` the playlists in
* that playlist category; a slug that names none finds nothing.
*
* @module
*/
@@ -16,6 +18,7 @@ import { parsePagination } from "../lib/pagination.ts";
import { searchDumps } from "../services/dump-service.ts";
import { searchUsersGlobal } from "../services/user-service.ts";
import { searchPlaylists } from "../services/playlist-service.ts";
import { resolveCategoryFilter } from "../services/category-service.ts";
import type { APIResponse } from "../../shared/api.ts";
import type { SearchResults } from "../../shared/search.ts";
@@ -44,14 +47,19 @@ router.get("/", async (ctx) => {
return;
}
const { items: dumpItems, total: dumpTotal } = searchDumps(
q,
page,
limit,
requestingUserId,
const dumpCategoryId = resolveCategoryFilter("dump", params);
const playlistCategoryId = resolveCategoryFilter(
"playlist",
params,
"playlistCategory",
);
const { items: dumpItems, total: dumpTotal } = dumpCategoryId === "missing"
? { items: [], total: 0 }
: searchDumps(q, page, limit, requestingUserId, dumpCategoryId);
const users = searchUsersGlobal(q, 20);
const playlists = searchPlaylists(q, 20, requestingUserId);
const playlists = playlistCategoryId === "missing"
? []
: searchPlaylists(q, 20, requestingUserId, playlistCategoryId);
const responseBody: APIResponse<SearchResults> = {
success: true,

View File

@@ -19,8 +19,9 @@
* - `GET /api/users/:key/upvoted?page=&limit=&category=` — optional sign-in.
* Answers a page of the dumps they upvoted, private ones only to
* themselves, optionally in one category.
* - `GET /api/users/:key/playlists?page=&limit=` — optional sign-in. Answers a
* page of the playlists they made, the private ones only to themselves.
* - `GET /api/users/:key/playlists?page=&limit=&category=` — optional
* sign-in. Answers a page of the playlists they made, the private ones only
* to themselves, optionally in one playlist category.
* - `GET /api/users/:key/playlists/:slug` — optional sign-in. Answers their
* playlist whose slug is or was `slug`, with its current slug and the dumps
* the caller may see; a private one 404s but for its owner.
@@ -163,7 +164,7 @@ router.get("/:key/dumps", async (ctx) => {
const requestingUserId = await parseOptionalAuth(ctx);
const params = ctx.request.url.searchParams;
const { page, limit } = parsePagination(params);
const categoryId = resolveCategoryFilter(params);
const categoryId = resolveCategoryFilter("dump", params);
const includePrivate = requestingUserId === user.id;
const { items, hasMore } = categoryId === "missing"
? { items: [], hasMore: false }
@@ -190,7 +191,7 @@ router.get("/:key/upvoted", async (ctx) => {
const requestingUserId = await parseOptionalAuth(ctx);
const params = ctx.request.url.searchParams;
const { page, limit } = parsePagination(params);
const categoryId = resolveCategoryFilter(params);
const categoryId = resolveCategoryFilter("dump", params);
const { items, hasMore } = categoryId === "missing"
? { items: [], hasMore: false }
: getVotedDumpsByUser(user.id, page, limit, requestingUserId, categoryId);
@@ -204,13 +205,12 @@ router.get("/:key/upvoted", async (ctx) => {
router.get("/:key/playlists", async (ctx) => {
const user = resolveUserKey(ctx.params.key);
const requestingUserId = await parseOptionalAuth(ctx);
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, hasMore } = listPlaylistsByUser(
user.id,
requestingUserId,
page,
limit,
);
const params = ctx.request.url.searchParams;
const { page, limit } = parsePagination(params);
const categoryId = resolveCategoryFilter("playlist", params);
const { items, hasMore } = categoryId === "missing"
? { items: [], hasMore: false }
: listPlaylistsByUser(user.id, requestingUserId, page, limit, categoryId);
const responseBody: APIResponse<PaginatedData<Playlist>> = {
success: true,
data: { items, hasMore },

View File

@@ -1,7 +1,10 @@
/**
* Stores the categories and which dumps belong to them. The category routes
* manage and list them, the dump service files each dump it writes under its
* categories, and every service that lists dumps fills in their `categoryIds`.
* Stores the categories and which dumps and playlists belong to them, a set
* of categories for each {@link CategoryKind}. The category routes manage and
* list them, the dump and playlist services file each dump or playlist they
* write under its categories, and every service that lists dumps or playlists
* fills in their `categoryIds` and filters them by category with
* {@link categoryJoin}.
*
* @module
*/
@@ -9,6 +12,7 @@
import { APIException } from "../lib/api-exception.ts";
import type {
Category,
CategoryKind,
CreateCategoryRequest,
UpdateCategoryRequest,
} from "../../shared/category.ts";
@@ -27,13 +31,23 @@ import { VALIDATION } from "../../shared/validation.ts";
import { parseRows } from "../model/rows.ts";
/**
* Throws a 400 unless `slug` is a valid category slug: a slug within a
* category's length, and none of the top-level paths other pages hold
* (reserved-slugs.ts), since a category's feed lives at the URL root.
* Names, for each kind, the table that files things under its categories and
* the column there of the thing filed.
*/
function validateCategorySlug(slug: string): void {
const LINKS: Record<CategoryKind, { table: string; column: string }> = {
dump: { table: "dump_categories", column: "dump_id" },
playlist: { table: "playlist_categories", column: "playlist_id" },
};
/**
* Throws a 400 unless `slug` is a valid slug for a category of `kind`: a slug
* within a category's length, and for a dump category none of the top-level
* paths other pages hold (reserved-slugs.ts), since its feed lives at the URL
* root. A playlist category's lives under `/playlists/`.
*/
function validateCategorySlug(slug: string, kind: CategoryKind): void {
validateSlug(slug, VALIDATION.CATEGORY_SLUG_MAX);
if (isReservedSlug(slug)) {
if (kind === "dump" && isReservedSlug(slug)) {
throw new APIException(400, `Slug "${slug}" is reserved`);
}
}
@@ -82,12 +96,15 @@ export function getCategoryById(id: string): Category {
}
/**
* Returns the category at `slug`, for {@link listDumpsByCategory}.
* Returns the category of `kind` at `slug`, for {@link listDumpsByCategory}
* and the list of every public playlist.
*
* @throws {APIException} 404 when there is no such category.
*/
export function getCategoryBySlug(slug: string): Category {
const row = db.prepare(`SELECT * FROM categories WHERE slug = ?;`).get(slug);
export function getCategoryBySlug(kind: CategoryKind, slug: string): Category {
const row = db.prepare(
`SELECT * FROM categories WHERE kind = ? AND slug = ?;`,
).get(kind, slug);
if (!row || !isCategoryRow(row)) {
throw new APIException(404, "Category not found");
}
@@ -95,35 +112,64 @@ export function getCategoryBySlug(slug: string): Category {
}
/**
* Returns the id of the category at `slug`, or undefined when there is none.
* Returns the id of the category of `kind` at `slug`, or undefined when there
* is none.
*/
export function getCategoryIdBySlug(slug: string): string | undefined {
const row = db.prepare(`SELECT id FROM categories WHERE slug = ?;`).get(slug);
export function getCategoryIdBySlug(
kind: CategoryKind,
slug: string,
): string | undefined {
const row = db.prepare(
`SELECT id FROM categories WHERE kind = ? AND slug = ?;`,
).get(kind, slug);
return row && typeof (row as { id?: unknown }).id === "string"
? (row as { id: string }).id
: undefined;
}
/**
* Returns the id of the category the `?category=` slug of `params` names,
* undefined without one, or "missing" when it names none, so that the list
* comes back empty rather than unfiltered. The follows routes filter the
* index's "followed" tab with it, and the users routes a profile's lists.
* Returns the id of the category of `kind` whose slug the `name` parameter of
* `params`, `?category=` by default, gives, undefined without one, or
* "missing" when it names none, so that the list comes back empty rather than
* unfiltered. The follows routes filter the index's "followed" tab with it,
* the users routes a profile's lists, and the search route its results.
*/
export function resolveCategoryFilter(
kind: CategoryKind,
params: URLSearchParams,
name = "category",
): string | "missing" | undefined {
const slug = params.get("category");
const slug = params.get(name);
if (!slug) return undefined;
return getCategoryIdBySlug(slug) ?? "missing";
return getCategoryIdBySlug(kind, slug) ?? "missing";
}
/**
* Returns every category, by position then slug, for `GET /api/categories`.
* Returns the join that keeps only the rows of `alias`, a dump or playlist as
* `kind` says, filed under `categoryId`, with its parameters, or nothing
* without one. A query puts the join after its `FROM` and the parameters
* before its own.
*/
export function categoryJoin(
kind: CategoryKind,
alias: string,
categoryId?: string,
): [string, string[]] {
if (!categoryId) return ["", []];
const { table, column } = LINKS[kind];
return [
`INNER JOIN ${table} cat ON cat.${column} = ${alias}.id AND cat.category_id = ?`,
[categoryId],
];
}
/**
* Returns every category, of both kinds, by kind, position then slug, for
* `GET /api/categories`.
*/
export function listCategories(): Category[] {
const rows = db.prepare(
`SELECT * FROM categories ORDER BY position ASC, slug ASC;`,
`SELECT * FROM categories ORDER BY kind ASC, position ASC, slug ASC;`,
).all();
return parseRows(rows, isCategoryRow, categoryRowToApi);
}
@@ -133,20 +179,21 @@ export function listCategories(): Category[] {
* and the position defaults to 0.
*
* @throws {APIException} 400 when the slug or name is invalid, 409 when
* either is taken.
* either is taken within its kind.
*/
export function createCategory(req: CreateCategoryRequest): Category {
validateCategorySlug(req.slug);
validateCategorySlug(req.slug, req.kind);
const name = requireName(req.name);
const id = crypto.randomUUID();
const createdAt = new Date();
try {
db.prepare(
`INSERT INTO categories (id, slug, name, position, created_at)
VALUES (?, ?, ?, ?, ?);`,
`INSERT INTO categories (id, kind, slug, name, position, created_at)
VALUES (?, ?, ?, ?, ?, ?);`,
).run(
id,
req.kind,
req.slug,
name,
req.position ?? 0,
@@ -158,6 +205,7 @@ export function createCategory(req: CreateCategoryRequest): Category {
return {
id,
kind: req.kind,
slug: req.slug,
name,
position: req.position ?? 0,
@@ -170,7 +218,7 @@ export function createCategory(req: CreateCategoryRequest): Category {
* the category as it now is. Fields `req` leaves out keep their value.
*
* @throws {APIException} 404 when there is no such category, 400 when the new
* slug or name is invalid, 409 when either is taken.
* slug or name is invalid, 409 when either is taken within its kind.
*/
export function updateCategory(
id: string,
@@ -180,7 +228,7 @@ export function updateCategory(
const newSlug = req.slug ?? existing.slug;
if (req.slug !== undefined && req.slug !== existing.slug) {
validateCategorySlug(req.slug);
validateCategorySlug(req.slug, existing.kind);
}
const newName = req.name !== undefined
? requireName(req.name)
@@ -212,8 +260,8 @@ export function updateCategory(
}
/**
* Deletes category `id`, for `DELETE /api/categories/:id`. Its dumps stay, and
* lose it from their `categoryIds`.
* Deletes category `id`, for `DELETE /api/categories/:id`. Its dumps or
* playlists stay, and lose it from their `categoryIds`.
*
* @throws {APIException} 404 when there is no such category.
*/
@@ -225,20 +273,25 @@ export function deleteCategory(id: string): void {
}
/**
* Checks that every id in `categoryIds` names a category. The dump service
* runs it before it writes a dump row or stores an uploaded file, so a bad id
* fails the request before anything is written.
* Checks that every id in `categoryIds` names a category of `kind`, so that a
* dump is never filed under a playlist category, nor the other way round. The
* dump and playlist services run it before they write a row or store an
* uploaded file, so a bad id fails the request before anything is written.
*
* @throws {APIException} 400 when an id names no category.
* @throws {APIException} 400 when an id names no category of `kind`.
*/
export function assertCategoriesExist(categoryIds: string[]): void {
export function assertCategoriesExist(
kind: CategoryKind,
categoryIds: string[],
): void {
const unique = [...new Set(categoryIds)];
if (unique.length > 0) {
const placeholders = unique.map(() => "?").join(", ");
const found = db.prepare(
`SELECT COUNT(*) as count FROM categories WHERE id IN (${placeholders});`,
).get(...unique) as { count: number } | undefined;
`SELECT COUNT(*) as count FROM categories
WHERE kind = ? AND id IN (${placeholders});`,
).get(kind, ...unique) as { count: number } | undefined;
if ((found?.count ?? 0) !== unique.length) {
throw new APIException(400, "One or more categories do not exist");
}
@@ -246,56 +299,64 @@ export function assertCategoriesExist(categoryIds: string[]): void {
}
/**
* Replaces the categories of dump `dumpId` with `categoryIds`, duplicates
* dropped. An empty list takes the dump out of every category. Called inside
* the dump service's transaction, it joins that one.
* Replaces the categories of the dump or playlist `id`, as `kind` says, with
* `categoryIds`, duplicates dropped. An empty list takes it out of every
* category. Called inside the dump or playlist service's transaction, it
* joins that one.
*
* @throws {APIException} 400 when an id names no category.
* @throws {APIException} 400 when an id names no category of `kind`.
*/
export function setDumpCategories(
dumpId: string,
export function setCategories(
kind: CategoryKind,
id: string,
categoryIds: string[],
): void {
assertCategoriesExist(categoryIds);
assertCategoriesExist(kind, categoryIds);
const unique = [...new Set(categoryIds)];
const { table, column } = LINKS[kind];
transaction(() => {
db.prepare(`DELETE FROM dump_categories WHERE dump_id = ?;`).run(dumpId);
db.prepare(`DELETE FROM ${table} WHERE ${column} = ?;`).run(id);
const insert = db.prepare(
`INSERT INTO dump_categories (dump_id, category_id) VALUES (?, ?);`,
`INSERT INTO ${table} (${column}, category_id) VALUES (?, ?);`,
);
for (const categoryId of unique) {
insert.run(dumpId, categoryId);
insert.run(id, categoryId);
}
});
}
/**
* Sets `categoryIds` on each of `dumps` in place, in one query. The dump row
* doesn't carry them, so every service that returns dumps calls this first.
* Sets `categoryIds` on each of `items`, dumps or playlists as `kind` says, in
* place, in one query. Their rows don't carry them, so every service that
* returns dumps or playlists calls this first.
*/
export function attachCategoryIds(dumps: Dump[]): void {
if (dumps.length === 0) return;
const ids = dumps.map((d) => d.id);
export function attachCategoryIds(
kind: CategoryKind,
items: { id: string; categoryIds?: string[] }[],
): void {
if (items.length === 0) return;
const { table, column } = LINKS[kind];
const ids = items.map((d) => d.id);
const placeholders = ids.map(() => "?").join(", ");
const rows = db.prepare(
`SELECT dump_id, category_id FROM dump_categories WHERE dump_id IN (${placeholders});`,
).all(...ids) as Array<{ dump_id: string; category_id: string }>;
`SELECT ${column} as id, category_id FROM ${table} WHERE ${column} IN (${placeholders});`,
).all(...ids) as Array<{ id: string; category_id: string }>;
const byDump = new Map<string, string[]>();
for (const { dump_id, category_id } of rows) {
const list = byDump.get(dump_id) ?? [];
const byItem = new Map<string, string[]>();
for (const { id, category_id } of rows) {
const list = byItem.get(id) ?? [];
list.push(category_id);
byDump.set(dump_id, list);
byItem.set(id, list);
}
for (const dump of dumps) {
dump.categoryIds = byDump.get(dump.id) ?? [];
for (const item of items) {
item.categoryIds = byItem.get(item.id) ?? [];
}
}
/**
* Returns a page of the dumps in the category at `slug`, newest first, for
* `GET /api/dumps?category=`. Private dumps are left out, except
* Returns a page of the dumps in the dump category at `slug`, newest first,
* for `GET /api/dumps?category=`. Private dumps are left out, except
* `requestingUserId`'s own.
*
* @throws {APIException} 404 when there is no such category.
@@ -306,7 +367,7 @@ export function listDumpsByCategory(
limit: number,
requestingUserId?: string,
): Page<Dump> {
const category = getCategoryBySlug(slug);
const category = getCategoryBySlug("dump", slug);
const offset = (page - 1) * limit;
const privacyClause = requestingUserId
@@ -314,15 +375,15 @@ export function listDumpsByCategory(
: "d.is_private = 0";
const privacyParams = requestingUserId ? [requestingUserId] : [];
const [catJoin, catParams] = categoryJoin("dump", "d", category.id);
const rows = db.prepare(
`SELECT ${SELECT_COLS_ALIASED}
FROM dumps d
INNER JOIN dump_categories dc ON dc.dump_id = d.id
WHERE dc.category_id = ? AND ${privacyClause}
FROM dumps d ${catJoin}
WHERE ${privacyClause}
ORDER BY d.created_at DESC LIMIT ? OFFSET ?;`,
).all(category.id, ...privacyParams, limit + 1, offset);
).all(...catParams, ...privacyParams, limit + 1, offset);
const items = parseRows(rows, isDumpRow, dumpRowToApi);
attachCategoryIds(items);
attachCategoryIds("dump", items);
return pageOf(items, limit);
}

View File

@@ -56,7 +56,8 @@ import { relinkBacklinks } from "./backlink-service.ts";
import {
assertCategoriesExist,
attachCategoryIds,
setDumpCategories,
categoryJoin,
setCategories,
} from "./category-service.ts";
import { LIKE, likePattern } from "../lib/like.ts";
import { parseRows } from "../model/rows.ts";
@@ -114,7 +115,7 @@ export async function createUrlDump(
if (!isValidHttpUrl(request.url)) {
throw new APIException(400, "Invalid URL");
}
assertCategoriesExist(request.categoryIds ?? []);
assertCategoriesExist("dump", request.categoryIds ?? []);
const dumpId = crypto.randomUUID();
const createdAt = new Date();
@@ -144,7 +145,7 @@ export async function createUrlDump(
richContent ? JSON.stringify(richContent) : null,
isPrivate ? 1 : 0,
);
setDumpCategories(dumpId, categoryIds);
setCategories("dump", dumpId, categoryIds);
});
const dump: Dump = { ...fetchDump(dumpId), categoryIds };
@@ -184,7 +185,7 @@ export async function createFileDump(
if (file.size > DUMP_MAX_FILE_SIZE_BYTES) {
throw new APIException(400, "File too large (max 50 MB)");
}
assertCategoriesExist(categoryIds);
assertCategoriesExist("dump", categoryIds);
const dumpId = crypto.randomUUID();
const createdAt = new Date();
@@ -215,7 +216,7 @@ export async function createFileDump(
file.size,
isPrivate ? 1 : 0,
);
setDumpCategories(dumpId, dedupedCategoryIds);
setCategories("dump", dumpId, dedupedCategoryIds);
});
} catch (err) {
// Leave no file behind when the write or the insert fails.
@@ -262,7 +263,7 @@ export function getDump(dumpId: string, requestingUserId?: string): Dump {
if (dump.isPrivate && dump.userId !== requestingUserId) {
throw new APIException(404, "Dump not found");
}
attachCategoryIds([dump]);
attachCategoryIds("dump", [dump]);
return dump;
}
@@ -288,13 +289,15 @@ export function getDumpByPath(
/**
* Returns a page of the dumps `requestingUserId` may see whose title, note, or
* previewed title or description contains `query`, newest first, with how many
* match in all. `GET /api/search` calls it.
* match in all, optionally only those in the dump category `categoryId`.
* `GET /api/search` calls it.
*/
export function searchDumps(
query: string,
page: number,
limit: number,
requestingUserId?: string,
categoryId?: string,
): { items: Dump[]; total: number } {
if (!query.trim()) return { items: [], total: 0 };
const pattern = likePattern(query);
@@ -302,29 +305,28 @@ export function searchDumps(
const searchClause =
`(title ${LIKE} OR comment ${LIKE} OR json_extract(rich_content,'$.title') ${LIKE} OR json_extract(rich_content,'$.description') ${LIKE})`;
const searchParams = [pattern, pattern, pattern, pattern] as const;
const privacyClause = requestingUserId
? "(is_private = 0 OR user_id = ?)"
: "is_private = 0";
const [catJoin, catParams] = categoryJoin("dump", "dumps", categoryId);
const params = [
...catParams,
...(requestingUserId ? [requestingUserId] : []),
pattern,
pattern,
pattern,
pattern,
];
if (requestingUserId) {
const rows = db.prepare(
`SELECT ${SELECT_COLS} FROM dumps WHERE (is_private = 0 OR user_id = ?) AND ${searchClause} ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
).all(requestingUserId, ...searchParams, limit, offset);
const totalRow = db.prepare(
`SELECT COUNT(*) as count FROM dumps WHERE (is_private = 0 OR user_id = ?) AND ${searchClause};`,
).get(requestingUserId, ...searchParams) as { count: number } | undefined;
const items = parseRows(rows, isDumpRow, dumpRowToApi);
attachCategoryIds(items);
return { items, total: totalRow?.count ?? 0 };
} else {
const rows = db.prepare(
`SELECT ${SELECT_COLS} FROM dumps WHERE is_private = 0 AND ${searchClause} ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
).all(...searchParams, limit, offset);
const totalRow = db.prepare(
`SELECT COUNT(*) as count FROM dumps WHERE is_private = 0 AND ${searchClause};`,
).get(...searchParams) as { count: number } | undefined;
const items = parseRows(rows, isDumpRow, dumpRowToApi);
attachCategoryIds(items);
return { items, total: totalRow?.count ?? 0 };
}
const rows = db.prepare(
`SELECT ${SELECT_COLS} FROM dumps ${catJoin} WHERE ${privacyClause} AND ${searchClause} ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
).all(...params, limit, offset);
const totalRow = db.prepare(
`SELECT COUNT(*) as count FROM dumps ${catJoin} WHERE ${privacyClause} AND ${searchClause};`,
).get(...params) as { count: number } | undefined;
const items = parseRows(rows, isDumpRow, dumpRowToApi);
attachCategoryIds("dump", items);
return { items, total: totalRow?.count ?? 0 };
}
/**
@@ -352,7 +354,7 @@ export function listDumps(
}
const items = rows.map(dumpRowToApi);
attachCategoryIds(items);
attachCategoryIds("dump", items);
return pageOf(items, limit);
}
@@ -372,7 +374,7 @@ export function listTopDumps(from: Date, to: Date, limit: number): Dump[] {
LIMIT ?;`,
).all(from.toISOString(), to.toISOString(), limit);
const items = parseRows(rows, isDumpRow, dumpRowToApi);
attachCategoryIds(items);
attachCategoryIds("dump", items);
return items;
}
@@ -441,7 +443,7 @@ export async function updateDump(
): Promise<Dump> {
const dump = fetchDump(dumpId);
if (request.categoryIds !== undefined) {
assertCategoriesExist(request.categoryIds);
assertCategoriesExist("dump", request.categoryIds);
}
if (dump.kind === "url" && !isValidHttpUrl(request.url ?? dump.url!)) {
throw new APIException(400, "Invalid URL");
@@ -479,10 +481,10 @@ export async function updateDump(
);
if (request.categoryIds !== undefined) {
const ids = [...new Set(request.categoryIds)];
setDumpCategories(dumpId, ids);
setCategories("dump", dumpId, ids);
updatedDump.categoryIds = ids;
} else {
attachCategoryIds([updatedDump]);
attachCategoryIds("dump", [updatedDump]);
}
broadcastDumpChange(dump, updatedDump);
notifyIfWentPublic(dump, updatedDump);
@@ -550,10 +552,10 @@ export async function updateDump(
if (request.categoryIds !== undefined) {
const ids = [...new Set(request.categoryIds)];
setDumpCategories(dumpId, ids);
setCategories("dump", dumpId, ids);
updatedDump.categoryIds = ids;
} else {
attachCategoryIds([updatedDump]);
attachCategoryIds("dump", [updatedDump]);
}
broadcastDumpChange(dump, updatedDump);
@@ -610,7 +612,7 @@ export async function replaceFileDump(
if (dump.kind !== "file") {
throw new APIException(400, "Not a file dump");
}
if (categoryIds !== undefined) assertCategoriesExist(categoryIds);
if (categoryIds !== undefined) assertCategoriesExist("dump", categoryIds);
if (slug !== undefined) changeSlug("dump", dump, slug, exempt);
// The new file is written beside the old one and renamed over it (atomic: a
@@ -650,7 +652,7 @@ export async function replaceFileDump(
dumpId,
);
if (categoryIds !== undefined) {
setDumpCategories(dumpId, [...new Set(categoryIds)]);
setCategories("dump", dumpId, [...new Set(categoryIds)]);
}
});
} catch (err) {
@@ -664,7 +666,7 @@ export async function replaceFileDump(
if (categoryIds !== undefined) {
dump.categoryIds = [...new Set(categoryIds)];
} else {
attachCategoryIds([dump]);
attachCategoryIds("dump", [dump]);
}
if (comment) {
@@ -690,20 +692,6 @@ export async function replaceFileDump(
return updatedDump;
}
/**
* Returns the join that keeps only the dumps `d` filed under `categoryId`,
* with its parameters, or nothing without one. The lists of a user's dumps
* filter by it.
*/
function categoryJoin(categoryId?: string): [string, string[]] {
return categoryId
? [
"INNER JOIN dump_categories dc ON dc.dump_id = d.id AND dc.category_id = ?",
[categoryId],
]
: ["", []];
}
/**
* Returns a page of `userId`'s dumps, newest first, for
* `GET /api/users/:key/dumps`, optionally only those in `categoryId`. The
@@ -720,7 +708,7 @@ export function getDumpsByUser(
): Page<Dump> {
const offset = (page - 1) * limit;
const privacyFilter = includePrivate ? "" : " AND d.is_private = 0";
const [catJoin, catParams] = categoryJoin(categoryId);
const [catJoin, catParams] = categoryJoin("dump", "d", categoryId);
const rows = db.prepare(
`SELECT ${SELECT_COLS_ALIASED} FROM dumps d ${catJoin}
WHERE d.user_id = ?${privacyFilter}
@@ -730,7 +718,7 @@ export function getDumpsByUser(
throw new APIException(500, "Malformed dump data");
}
const items = rows.map(dumpRowToApi);
attachCategoryIds(items);
attachCategoryIds("dump", items);
return pageOf(items, limit);
}
@@ -753,7 +741,7 @@ export function getVotedDumpsByUser(
const privacyFilter = isOwn
? "(d.is_private = 0 OR d.user_id = ?)"
: "d.is_private = 0";
const [catJoin, catParams] = categoryJoin(categoryId);
const [catJoin, catParams] = categoryJoin("dump", "d", categoryId);
const rows = db.prepare(
`SELECT ${SELECT_COLS_ALIASED}
FROM dumps d
@@ -773,7 +761,7 @@ export function getVotedDumpsByUser(
throw new APIException(500, "Malformed dump data");
}
const items = rows.map(dumpRowToApi);
attachCategoryIds(items);
attachCategoryIds("dump", items);
return pageOf(items, limit);
}

View File

@@ -24,7 +24,7 @@ import {
import { isFollowRow } from "../model/follow.ts";
import { isPlaylistRow, playlistRowToApi } from "../model/playlist.ts";
import { type Page, pageOf } from "../lib/pagination.ts";
import { attachCategoryIds } from "./category-service.ts";
import { attachCategoryIds, categoryJoin } from "./category-service.ts";
import { sendToUser } from "./ws-service.ts";
/**
@@ -167,10 +167,7 @@ export function getFollowedUsersDumpFeed(
): Page<Dump> {
const offset = (page - 1) * limit;
const catJoin = categoryId
? "INNER JOIN dump_categories dc ON dc.dump_id = d.id AND dc.category_id = ?"
: "";
const catParams = categoryId ? [categoryId] : [];
const [catJoin, catParams] = categoryJoin("dump", "d", categoryId);
const rawRows = db.prepare(
`SELECT ${SELECT_COLS_ALIASED}
@@ -188,7 +185,7 @@ export function getFollowedUsersDumpFeed(
throw new APIException(500, "Malformed dump data");
}
const items = userFeedRows.map(dumpRowToApi);
attachCategoryIds(items);
attachCategoryIds("dump", items);
return pageOf(items, limit);
}
@@ -208,10 +205,7 @@ export function getFollowedPlaylistsDumpFeed(
): Page<Dump> {
const offset = (page - 1) * limit;
const catJoin = categoryId
? "INNER JOIN dump_categories dc ON dc.dump_id = d.id AND dc.category_id = ?"
: "";
const catParams = categoryId ? [categoryId] : [];
const [catJoin, catParams] = categoryJoin("dump", "d", categoryId);
const rawRows = db.prepare(
`SELECT ${SELECT_COLS_ALIASED}
@@ -232,7 +226,7 @@ export function getFollowedPlaylistsDumpFeed(
throw new APIException(500, "Malformed dump data");
}
const items = rawRows.map(dumpRowToApi);
attachCategoryIds(items);
attachCategoryIds("dump", items);
return pageOf(items, limit);
}
@@ -264,7 +258,9 @@ export function getFollowedPlaylistsByUser(
if (!rawRows.every(isPlaylistRow)) {
throw new APIException(500, "Malformed playlist data");
}
return pageOf(rawRows.map(playlistRowToApi), limit);
const items = rawRows.map(playlistRowToApi);
attachCategoryIds("playlist", items);
return pageOf(items, limit);
}
/**

View File

@@ -37,6 +37,12 @@ import {
} from "./notification-service.ts";
import { changeSlug, newSlug, resolveSlug } from "./slug-service.ts";
import { linkAttachments } from "./attachment-service.ts";
import {
assertCategoriesExist,
attachCategoryIds,
categoryJoin,
setCategories,
} from "./category-service.ts";
import {
isMemberVisibleTo,
type PlaylistMember,
@@ -53,6 +59,16 @@ const PLAYLIST_SELECT = `p.*, u.username as owner_username,
(SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count
FROM playlists p JOIN users u ON u.id = p.user_id`;
/**
* Converts the rows of a {@link PLAYLIST_SELECT} to playlists, their
* categories filled in.
*/
function playlistsOf(rows: unknown[]): Playlist[] {
const playlists = parseRows(rows, isPlaylistRow, playlistRowToApi);
attachCategoryIds("playlist", playlists);
return playlists;
}
/**
* Returns playlist `playlistId`, private or not. A caller that shows it to
* someone must check `isPublic` first, as the OG middleware does.
@@ -65,7 +81,9 @@ export function getPlaylistById(playlistId: string): Playlist {
if (!row || !isPlaylistRow(row)) {
throw new APIException(404, "Playlist not found");
}
return playlistRowToApi(row);
const playlist = playlistRowToApi(row);
attachCategoryIds("playlist", [playlist]);
return playlist;
}
/**
@@ -80,9 +98,12 @@ export function getPlaylistByPath(userId: string, slug: string): Playlist {
}
/**
* Creates a playlist owned by `userId`, for `POST /api/playlists`. If it is
* public, it notifies the users its description mentions. It links the
* attachments the description embeds and broadcasts `playlist_created`.
* Creates a playlist owned by `userId`, for `POST /api/playlists`, filed under
* the playlist categories `req` names. If it is public, it notifies the users
* its description mentions. It links the attachments the description embeds
* and broadcasts `playlist_created`.
*
* @throws {APIException} 400 when a category id names no playlist category.
*/
export function createPlaylist(
req: CreatePlaylistRequest,
@@ -91,6 +112,7 @@ export function createPlaylist(
const id = crypto.randomUUID();
const createdAt = new Date();
const title = req.title.trim();
assertCategoriesExist("playlist", req.categoryIds ?? []);
transaction(() => {
db.prepare(
`INSERT INTO playlists (id, user_id, title, slug, description, is_public, created_at)
@@ -104,6 +126,9 @@ export function createPlaylist(
req.isPublic ? 1 : 0,
createdAt.toISOString(),
);
if (req.categoryIds?.length) {
setCategories("playlist", id, req.categoryIds);
}
});
const playlist = getPlaylistById(id);
if (req.description) {
@@ -157,27 +182,50 @@ export function getPlaylist(
/**
* Returns up to `limit` playlists whose title or description contains
* `query`, newest first, for `GET /api/search`. Private playlists are left
* out, except `requestingUserId`'s own. A blank `query` finds nothing.
* `query`, newest first, for `GET /api/search`, optionally only those in the
* playlist category `categoryId`. Private playlists are left out, except
* `requestingUserId`'s own. A blank `query` finds nothing.
*/
export function searchPlaylists(
query: string,
limit: number,
requestingUserId?: string,
categoryId?: string,
): Playlist[] {
if (!query.trim()) return [];
const pattern = likePattern(query);
const searchClause = `(p.title ${LIKE} OR p.description ${LIKE})`;
const [catJoin, catParams] = categoryJoin("playlist", "p", categoryId);
const rows = requestingUserId
? db.prepare(
`SELECT ${PLAYLIST_SELECT} WHERE (p.is_public = 1 OR p.user_id = ?) AND ${searchClause} ORDER BY p.created_at DESC LIMIT ?;`,
).all(requestingUserId, pattern, pattern, 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)
: db.prepare(
`SELECT ${PLAYLIST_SELECT} WHERE p.is_public = 1 AND ${searchClause} ORDER BY p.created_at DESC LIMIT ?;`,
).all(pattern, pattern, limit);
`SELECT ${PLAYLIST_SELECT} ${catJoin} WHERE p.is_public = 1 AND ${searchClause} ORDER BY p.created_at DESC LIMIT ?;`,
).all(...catParams, pattern, pattern, limit);
return parseRows(rows, isPlaylistRow, playlistRowToApi);
return playlistsOf(rows);
}
/**
* Returns a page of every public playlist, newest first, for
* `GET /api/playlists`, optionally only those in the playlist category
* `categoryId`. The client's `/playlists` page lists them.
*/
export function listPublicPlaylists(
page: number,
limit: number,
categoryId?: string,
): Page<Playlist> {
const offset = (page - 1) * limit;
const [catJoin, catParams] = categoryJoin("playlist", "p", categoryId);
const rows = db.prepare(
`SELECT ${PLAYLIST_SELECT} ${catJoin}
WHERE p.is_public = 1
ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`,
).all(...catParams, limit + 1, offset);
return pageOf(playlistsOf(rows), limit);
}
/**
@@ -191,12 +239,13 @@ export function listNewPublicPlaylists(from: Date, to: Date): Playlist[] {
WHERE p.is_public = 1 AND p.created_at >= ? AND p.created_at < ?
ORDER BY dump_count DESC, p.created_at DESC;`,
).all(from.toISOString(), to.toISOString());
return parseRows(rows, isPlaylistRow, playlistRowToApi);
return playlistsOf(rows);
}
/**
* Returns a page of `userId`'s playlists, newest first, for
* `GET /api/users/:key/playlists`. The private ones are listed only when
* `GET /api/users/:key/playlists`, optionally only those in the playlist
* category `categoryId`. The private ones are listed only when
* `requestingUserId` is `userId`.
*/
export function listPlaylistsByUser(
@@ -204,16 +253,19 @@ export function listPlaylistsByUser(
requestingUserId: string | null,
page: number,
limit: number,
categoryId?: string,
): Page<Playlist> {
const isOwner = requestingUserId === userId;
const offset = (page - 1) * limit;
const privacyFilter = isOwner ? "" : " AND p.is_public = 1";
const [catJoin, catParams] = categoryJoin("playlist", "p", categoryId);
const sql = isOwner
? `SELECT ${PLAYLIST_SELECT} WHERE p.user_id = ? ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`
: `SELECT ${PLAYLIST_SELECT} WHERE p.user_id = ? AND p.is_public = 1 ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`;
const rows = db.prepare(sql).all(userId, limit + 1, offset);
return pageOf(parseRows(rows, isPlaylistRow, playlistRowToApi), limit);
const rows = db.prepare(
`SELECT ${PLAYLIST_SELECT} ${catJoin}
WHERE p.user_id = ?${privacyFilter}
ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`,
).all(...catParams, userId, limit + 1, offset);
return pageOf(playlistsOf(rows), limit);
}
/**
@@ -227,8 +279,9 @@ export function listPlaylistsByUser(
* attachments and broadcasts `playlist_updated`.
*
* @throws {APIException} 404 when there is no such playlist, 403 when the user
* is neither its owner nor a moderator, 400 when the slug isn't valid, 409
* when it is taken, 429 when it changed too often lately.
* is neither its owner nor a moderator, 400 when the slug isn't valid or a
* category id names no playlist category, 409 when the slug is taken, 429
* when it changed too often lately.
*/
export function updatePlaylist(
playlistId: string,
@@ -250,20 +303,28 @@ export function updatePlaylist(
? req.isPublic
: playlist.isPublic;
if (req.categoryIds !== undefined) {
assertCategoriesExist("playlist", req.categoryIds);
}
if (req.slug !== undefined) {
changeSlug("playlist", playlist, req.slug, canModerate);
}
const now = new Date();
db.prepare(
`UPDATE playlists SET title = ?, description = ?, is_public = ?, updated_at = ? WHERE id = ?;`,
).run(
newTitle,
newDescription,
newIsPublic ? 1 : 0,
now.toISOString(),
playlist.id,
);
transaction(() => {
db.prepare(
`UPDATE playlists SET title = ?, description = ?, is_public = ?, updated_at = ? WHERE id = ?;`,
).run(
newTitle,
newDescription,
newIsPublic ? 1 : 0,
now.toISOString(),
playlist.id,
);
if (req.categoryIds !== undefined) {
setCategories("playlist", playlist.id, req.categoryIds);
}
});
const updated: Playlist = {
...playlist,
@@ -272,6 +333,9 @@ export function updatePlaylist(
description: newDescription ?? undefined,
isPublic: newIsPublic,
updatedAt: now,
categoryIds: req.categoryIds !== undefined
? [...new Set(req.categoryIds)]
: playlist.categoryIds,
};
if (newDescription) {
if (newIsPublic) {
@@ -536,7 +600,7 @@ export function getPlaylistMembershipsForDump(
ORDER BY p.created_at DESC;`,
).all(dumpId, userId);
return rows.map((row) => {
const memberships = rows.map((row) => {
if (!isPlaylistRow(row)) {
throw new APIException(500, "Malformed playlist data");
}
@@ -545,6 +609,8 @@ export function getPlaylistMembershipsForDump(
hasDump: Boolean(row.has_dump),
};
});
attachCategoryIds("playlist", memberships.map((m) => m.playlist));
return memberships;
}
/**
@@ -564,7 +630,7 @@ export function getPlaylistsForDump(
ORDER BY p.created_at DESC LIMIT 20;`,
).all(dumpId, requestingUserId ?? "");
return parseRows(rows, isPlaylistRow, playlistRowToApi);
return playlistsOf(rows);
}
/**

View File

@@ -28,14 +28,14 @@ Deno.test("runMigrations: a fresh database is left as schema.sql made it", () =>
.get() as {
n: number;
};
assertEquals(applied.n, 24);
assertEquals(applied.n, 25);
// Running again applies nothing.
runMigrations(db);
assertEquals(
(db.prepare(`SELECT COUNT(*) as n FROM schema_migrations;`).get() as {
n: number;
}).n,
24,
25,
);
db.close();
});

View File

@@ -0,0 +1,108 @@
import "../../setup.ts";
import { assertEquals, assertThrows } from "@std/assert";
import { DatabaseSync } from "node:sqlite";
import { up as up0025 } from "../../../db/migrations/0025_playlist_categories.ts";
/** Returns a database with the tables 0025 changes, as 0006 made them. */
function oldDb(): DatabaseSync {
const db = new DatabaseSync(":memory:");
db.exec(`PRAGMA foreign_keys = ON;`);
db.exec(`
CREATE TABLE dumps (id TEXT PRIMARY KEY);
CREATE TABLE playlists (id TEXT PRIMARY KEY);
CREATE TABLE categories (
id TEXT PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL UNIQUE,
position INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT
);
CREATE TABLE dump_categories (
dump_id TEXT NOT NULL,
category_id TEXT NOT NULL,
PRIMARY KEY (dump_id, category_id),
FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
);
`);
return db;
}
Deno.test("migration 0025 keeps the categories as dump categories, with their dumps", () => {
const db = oldDb();
db.exec(`INSERT INTO dumps (id) VALUES ('d1'), ('d2');`);
db.exec(
`INSERT INTO categories (id, slug, name, position, created_at) VALUES
('c1', 'music', 'Music', 2, '2026-01-01T00:00:00.000Z'),
('c2', 'playlists', 'Playlists', 1, '2026-01-02T00:00:00.000Z');`,
);
db.exec(
`INSERT INTO dump_categories (dump_id, category_id) VALUES
('d1', 'c1'), ('d2', 'c1'), ('d2', 'c2');`,
);
db.exec("BEGIN;");
up0025(db);
db.exec("COMMIT;");
up0025(db); // idempotent
assertEquals(
db.prepare(
`SELECT id, kind, slug, name, position FROM categories ORDER BY id;`,
).all().map((r) => ({ ...r })),
[
{ id: "c1", kind: "dump", slug: "music", name: "Music", position: 2 },
// `playlists` is now the page of every playlist.
{
id: "c2",
kind: "dump",
slug: "playlists-2",
name: "Playlists",
position: 1,
},
],
);
assertEquals(
db.prepare(
`SELECT dump_id, category_id FROM dump_categories ORDER BY dump_id, category_id;`,
).all().map((r) => ({ ...r })),
[
{ dump_id: "d1", category_id: "c1" },
{ dump_id: "d2", category_id: "c1" },
{ dump_id: "d2", category_id: "c2" },
],
);
// A slug or name is unique within its kind only.
db.exec(
`INSERT INTO categories (id, kind, slug, name, created_at)
VALUES ('p1', 'playlist', 'music', 'Music', '2026-01-03T00:00:00.000Z');`,
);
assertThrows(() =>
db.exec(
`INSERT INTO categories (id, kind, slug, name, created_at)
VALUES ('p2', 'playlist', 'music', 'Other', '2026-01-03T00:00:00.000Z');`,
)
);
// The links still cascade.
db.exec(`INSERT INTO playlists (id) VALUES ('pl1');`);
db.exec(
`INSERT INTO playlist_categories (playlist_id, category_id) VALUES ('pl1', 'p1');`,
);
db.exec(`DELETE FROM categories WHERE id IN ('c1', 'p1');`);
assertEquals(
(db.prepare(`SELECT COUNT(*) as n FROM dump_categories;`).get() as {
n: number;
}).n,
1,
);
assertEquals(
(db.prepare(`SELECT COUNT(*) as n FROM playlist_categories;`).get() as {
n: number;
}).n,
0,
);
db.close();
});

View File

@@ -26,6 +26,7 @@ import {
} from "../services/email-service.ts";
import type { APIError, APIResponse } from "../../shared/api.ts";
import type { Role } from "../../shared/user.ts";
import type { CategoryKind } from "../../shared/category.ts";
export { app, db, TEST_DIR };
@@ -40,6 +41,7 @@ const TABLES = [
"federation_outbox",
"dump_backlinks",
"dump_categories",
"playlist_categories",
"playlist_dumps",
"comment_likes",
"comments",
@@ -268,13 +270,19 @@ export function insertComment(
}
export function insertCategory(
opts: { slug: string; name?: string; position?: number },
opts: {
slug: string;
name?: string;
position?: number;
kind?: CategoryKind;
},
): string {
const id = crypto.randomUUID();
db.prepare(
`INSERT INTO categories (id, slug, name, position, created_at) VALUES (?, ?, ?, ?, ?);`,
`INSERT INTO categories (id, kind, slug, name, position, created_at) VALUES (?, ?, ?, ?, ?, ?);`,
).run(
id,
opts.kind ?? "dump",
opts.slug,
opts.name ?? opts.slug,
opts.position ?? 0,

View File

@@ -10,6 +10,7 @@ Deno.test("category slugs: the feed tabs, app pages and server paths are reserve
"settings",
"manage",
"community",
"playlists",
"api",
"ws",
]
@@ -17,7 +18,7 @@ Deno.test("category slugs: the feed tabs, app pages and server paths are reserve
assert(isReservedSlug(word), word);
}
// v3's words are free again: nothing lives at /dumps or /users now.
for (const word of ["music", "dumps", "users", "playlists", "~"]) {
for (const word of ["music", "dumps", "users", "~"]) {
assertFalse(isReservedSlug(word), word);
}
});

View File

@@ -29,7 +29,7 @@ test("categories: listed by position, then slug", async () => {
test("categories: only category:manage may create, update or delete", async () => {
const admin = await createUser({ role: "admin" });
const mod = await createUser({ role: "moderator" });
const json = { slug: "music", name: "Music" };
const json = { kind: "dump", slug: "music", name: "Music" };
assertEquals((await post("/api/categories", { json })).status, 401);
assertEquals(
(await post("/api/categories", { auth: mod.auth, json })).status,
@@ -64,13 +64,17 @@ test("categories: only category:manage may create, update or delete", async () =
test("categories: slug and name rules, and friendly conflicts", async () => {
const admin = await createUser({ role: "admin" });
const create = (json: unknown) =>
post<Category>("/api/categories", { auth: admin.auth, json });
const create = (json: Record<string, unknown>) =>
post<Category>("/api/categories", {
auth: admin.auth,
json: { kind: "dump", ...json },
});
for (
const [json, message] of [
[{ slug: "Bad Slug", name: "x" }, "lowercase"],
[{ slug: "hot", name: "x" }, "reserved"],
[{ slug: "settings", name: "x" }, "reserved"],
[{ slug: "playlists", name: "x" }, "reserved"],
[{ slug: "a".repeat(51), name: "x" }, "at most 50"],
[{ slug: "ok", name: " " }, "Name is required"],
[{ slug: "ok", name: "x".repeat(51) }, "at most 50"],
@@ -144,3 +148,37 @@ test("GET /api/dumps?category=: members only, private for the owner", async () =
[],
);
});
test("categories: each kind is a set apart", async () => {
const admin = await createUser({ role: "admin" });
const create = (json: Record<string, unknown>) =>
post<Category>("/api/categories", { auth: admin.auth, json });
assertEquals((await create({ slug: "music", name: "Music" })).status, 400);
const dump = await create({ kind: "dump", slug: "music", name: "Music" });
assertEquals(dump.data.kind, "dump");
// The same slug and name are free in the other kind, taken in the same.
const playlist = await create({
kind: "playlist",
slug: "music",
name: "Music",
});
assertEquals(playlist.status, 201);
assertEquals(playlist.data.kind, "playlist");
assertEquals(
(await create({ kind: "playlist", slug: "music", name: "Other" })).status,
409,
);
// A playlist category's page lives under /playlists/: nothing is reserved.
assertEquals(
(await create({ kind: "playlist", slug: "hot", name: "Hot" })).status,
201,
);
const list = await get<Category[]>("/api/categories");
assertEquals(list.data.map((c) => [c.kind, c.slug]), [
["dump", "music"],
["playlist", "hot"],
["playlist", "music"],
]);
});

View File

@@ -130,6 +130,18 @@ test("POST /api/dumps: an unknown category is refused and nothing is left behind
assertEquals(count(`SELECT COUNT(*) as n FROM dumps;`), 0);
});
test("POST /api/dumps: a playlist category is refused", async () => {
const user = await createUser();
const lists = insertCategory({ slug: "mixes", kind: "playlist" });
using _ = stubNetwork(() => PAGE.clone());
const res = await post("/api/dumps", {
auth: user.auth,
json: { url: "https://example.com/e", categoryIds: [lists] },
});
assertEquals(res.status, 400);
assertEquals(count(`SELECT COUNT(*) as n FROM dumps;`), 0);
});
test("POST /api/dumps: a file dump is stored and served back", async () => {
const user = await createUser();
const res = await post<Dump>("/api/dumps", {

View File

@@ -6,6 +6,7 @@ import {
del,
formWithFile,
get,
insertCategory,
insertDump,
insertPlaylist,
PNG,
@@ -539,3 +540,126 @@ test("reorder: a moderator orders around the owner's private dumps", async () =>
b.id,
]);
});
test("playlist categories: filed on creation, replaced on update, dump categories refused", async () => {
const user = await createUser();
const mixes = insertCategory({ slug: "mixes", kind: "playlist" });
const live = insertCategory({ slug: "live", kind: "playlist" });
const music = insertCategory({ slug: "music" });
const refused = await post("/api/playlists", {
auth: user.auth,
json: { title: "Wrong", isPublic: true, categoryIds: [music] },
});
assertEquals(refused.status, 400);
assertEquals(count(`SELECT COUNT(*) as n FROM playlists;`), 0);
assertEquals(
(await post("/api/playlists", {
auth: user.auth,
json: { title: "Bad", isPublic: true, categoryIds: "mixes" },
})).status,
400,
);
const created = await post<Playlist>("/api/playlists", {
auth: user.auth,
json: { title: "Mix", isPublic: true, categoryIds: [mixes, mixes] },
});
assertEquals(created.status, 201);
assertEquals(created.data.categoryIds, [mixes]);
assertEquals(
(await get<Playlist>(`/api/playlists/${created.data.id}`)).data
.categoryIds,
[mixes],
);
const id = created.data.id;
assertEquals(
(await put(`/api/playlists/${id}`, {
auth: user.auth,
json: { categoryIds: [music] },
})).status,
400,
);
const updated = await put<Playlist>(`/api/playlists/${id}`, {
auth: user.auth,
json: { categoryIds: [live] },
});
assertEquals(updated.data.categoryIds, [live]);
// A change of anything else keeps them.
const renamed = await put<Playlist>(`/api/playlists/${id}`, {
auth: user.auth,
json: { title: "Live mix" },
});
assertEquals(renamed.data.categoryIds, [live]);
const cleared = await put<Playlist>(`/api/playlists/${id}`, {
auth: user.auth,
json: { categoryIds: [] },
});
assertEquals(cleared.data.categoryIds, []);
});
test("GET /api/playlists: every public playlist, newest first, by category", async () => {
const owner = await createUser();
const other = await createUser();
const mixes = insertCategory({ slug: "mixes", kind: "playlist" });
insertCategory({ slug: "music" });
const a = insertPlaylist({ userId: owner.id, title: "A" });
insertPlaylist({ userId: other.id, title: "B" });
const hidden = insertPlaylist({
userId: owner.id,
title: "Hidden",
isPublic: false,
});
db.prepare(`UPDATE playlists SET created_at = ? WHERE id = ?;`)
.run("2026-01-01T00:00:00.000Z", a.id);
for (const p of [a, hidden]) {
db.prepare(
`INSERT INTO playlist_categories (playlist_id, category_id) VALUES (?, ?);`,
).run(p.id, mixes);
}
const all = await get<PaginatedData<Playlist>>("/api/playlists", {
auth: owner.auth,
});
assertEquals(all.data.items.map((p) => p.title), ["B", "A"]);
assertEquals(all.data.items[1].categoryIds, [mixes]);
assertEquals(
(await get<PaginatedData<Playlist>>("/api/playlists?limit=1")).data
.hasMore,
true,
);
assertEquals(
(await get<PaginatedData<Playlist>>("/api/playlists?category=mixes")).data
.items.map((p) => p.id),
[a.id],
);
// A dump category's slug names no playlist category.
assertEquals((await get("/api/playlists?category=music")).status, 404);
assertEquals((await get("/api/playlists?category=nope")).status, 404);
});
test("GET /api/users/:key/playlists?category=: their playlists in one category", async () => {
const owner = await createUser();
const mixes = insertCategory({ slug: "mixes", kind: "playlist" });
const a = insertPlaylist({ userId: owner.id, title: "A" });
insertPlaylist({ userId: owner.id, title: "B" });
db.prepare(
`INSERT INTO playlist_categories (playlist_id, category_id) VALUES (?, ?);`,
).run(a.id, mixes);
const url = `/api/users/${owner.id}/playlists`;
assertEquals(
(await get<PaginatedData<Playlist>>(url)).data.items.length,
2,
);
assertEquals(
(await get<PaginatedData<Playlist>>(`${url}?category=mixes`)).data.items
.map((p) => p.id),
[a.id],
);
assertEquals(
(await get<PaginatedData<Playlist>>(`${url}?category=nope`)).data.items,
[],
);
});

View File

@@ -2,6 +2,7 @@ import {
createUser,
db,
get,
insertCategory,
insertDump,
insertPlaylist,
test,
@@ -62,3 +63,44 @@ test("GET /api/search: dumps, users and playlists, privacy-filtered", async () =
playlists: [],
});
});
test("GET /api/search: category and playlistCategory narrow each list", async () => {
const owner = await createUser();
const music = insertCategory({ slug: "music" });
const mixes = insertCategory({ slug: "mixes", kind: "playlist" });
const filed = insertDump({ userId: owner.id, title: "Banana song" });
insertDump({ userId: owner.id, title: "Banana bread" });
const list = insertPlaylist({ userId: owner.id, title: "Banana mix" });
insertPlaylist({ userId: owner.id, title: "Banana bin" });
db.prepare(
`INSERT INTO dump_categories (dump_id, category_id) VALUES (?, ?);`,
).run(filed.id, music);
db.prepare(
`INSERT INTO playlist_categories (playlist_id, category_id) VALUES (?, ?);`,
).run(list.id, mixes);
const all = (await get<SearchResults>("/api/search?q=banana")).data;
assertEquals(all.dumps.total, 2);
assertEquals(all.playlists.length, 2);
const dumps = (await get<SearchResults>(
"/api/search?q=banana&category=music",
)).data;
assertEquals(dumps.dumps.items.map((d) => d.id), [filed.id]);
assertEquals(dumps.dumps.total, 1);
assertEquals(dumps.playlists.length, 2);
const playlists = (await get<SearchResults>(
"/api/search?q=banana&playlistCategory=mixes",
)).data;
assertEquals(playlists.dumps.total, 2);
assertEquals(playlists.playlists.map((p) => p.id), [list.id]);
assertEquals(playlists.playlists[0].categoryIds, [mixes]);
// Each names a category of its own kind only.
const crossed = (await get<SearchResults>(
"/api/search?q=banana&category=mixes&playlistCategory=music",
)).data;
assertEquals(crossed.dumps.total, 0);
assertEquals(crossed.playlists, []);
});

View File

@@ -6,10 +6,27 @@ import {
} from "../../validators/category.ts";
Deno.test("category requests", () => {
assert(isCreateCategoryRequest({ slug: "a", name: "A" }));
assert(isCreateCategoryRequest({ slug: "a", name: "A", position: 2 }));
assertFalse(isCreateCategoryRequest({ slug: "a" }));
assertFalse(isCreateCategoryRequest({ slug: "a", name: "A", position: "2" }));
assert(isCreateCategoryRequest({ kind: "dump", slug: "a", name: "A" }));
assert(isCreateCategoryRequest({ kind: "playlist", slug: "a", name: "A" }));
assert(
isCreateCategoryRequest({
kind: "dump",
slug: "a",
name: "A",
position: 2,
}),
);
assertFalse(isCreateCategoryRequest({ slug: "a", name: "A" }));
assertFalse(isCreateCategoryRequest({ kind: "user", slug: "a", name: "A" }));
assertFalse(isCreateCategoryRequest({ kind: "dump", slug: "a" }));
assertFalse(
isCreateCategoryRequest({
kind: "dump",
slug: "a",
name: "A",
position: "2",
}),
);
assert(isUpdateCategoryRequest({}));
assertFalse(isUpdateCategoryRequest({ name: 1 }));
});

View File

@@ -5,9 +5,10 @@
* @module
*/
import type {
CreateCategoryRequest,
UpdateCategoryRequest,
import {
type CreateCategoryRequest,
isCategoryKind,
type UpdateCategoryRequest,
} from "../../shared/category.ts";
/** Checks whether `obj` has the fields of a {@link CreateCategoryRequest}. */
@@ -16,6 +17,7 @@ export function isCreateCategoryRequest(
): obj is CreateCategoryRequest {
if (!obj || typeof obj !== "object") return false;
const o = obj as Record<string, unknown>;
if (!isCategoryKind(o.kind)) return false;
if (typeof o.slug !== "string") return false;
if (typeof o.name !== "string") return false;
if ("position" in o && typeof o.position !== "number") return false;

View File

@@ -11,6 +11,13 @@ import type {
} from "../../shared/playlist.ts";
import { VALIDATION } from "../../shared/validation.ts";
/** Checks that `categoryIds`, when `o` has it, is a list of strings. */
function hasValidCategoryIds(o: Record<string, unknown>): boolean {
return !("categoryIds" in o) ||
(Array.isArray(o.categoryIds) &&
o.categoryIds.every((id) => typeof id === "string"));
}
/** Checks that `title` isn't blank and, trimmed, fits the limit. */
function isValidTitle(title: string): boolean {
const trimmed = title.trim();
@@ -20,7 +27,8 @@ function isValidTitle(title: string): boolean {
/**
* Checks whether `obj` is a {@link CreatePlaylistRequest} with a title that
* isn't blank, and a title and description within the `VALIDATION` limits. A
* null description passes.
* null description passes. Whether its category ids name playlist categories
* is the playlist service's to check.
*/
export function isCreatePlaylistRequest(
obj: unknown,
@@ -40,7 +48,7 @@ export function isCreatePlaylistRequest(
typeof o.description === "string" &&
(o.description as string).length > VALIDATION.PLAYLIST_DESCRIPTION_MAX
) return false;
return true;
return hasValidCategoryIds(o);
}
/**
@@ -68,7 +76,7 @@ export function isUpdatePlaylistRequest(
(o.description as string).length > VALIDATION.PLAYLIST_DESCRIPTION_MAX
) return false;
if ("isPublic" in o && typeof o.isPublic !== "boolean") return false;
return true;
return hasValidCategoryIds(o);
}
/**

File diff suppressed because one or more lines are too long

View File

@@ -18,13 +18,13 @@ msgid "[deleted]"
msgstr "[deleted]"
#. placeholder {0}: dump.commentCount
#: src/components/dump/DumpCard.tsx:139
#: src/components/dump/DumpCard.tsx:135
#: src/components/dump/JournalCard.tsx:136
msgid "{0, plural, one {# comment} other {# comments}}"
msgstr "{0, plural, one {# comment} other {# comments}}"
#. placeholder {0}: playlist.dumpCount
#: src/components/playlist/PlaylistCard.tsx:118
#: src/components/playlist/PlaylistCard.tsx:120
msgid "{0, plural, one {# dump} other {# dumps}}"
msgstr "{0, plural, one {# dump} other {# dumps}}"
@@ -60,7 +60,7 @@ msgstr "{0}–{1} characters: letters, numbers, or underscores"
msgid "email.digest.totals"
msgstr "{dumps, plural, one {# dump} other {# dumps}}, {upvotes, plural, one {# upvote} other {# upvotes}} and {comments, plural, one {# comment} other {# comments}}."
#: src/pages/Search.tsx:124
#: src/pages/Search.tsx:152
msgid "{label} ({count})"
msgstr "{label} ({count})"
@@ -205,15 +205,15 @@ msgstr "# Welcome to {site}!\n"
"\n"
"Your account has been created successfully. Welcome aboard!"
#: src/components/dump/create/DumpCreateModal.tsx:742
#: src/components/dump/create/DumpCreateModal.tsx:736
#: src/pages/PlaylistDetail.tsx:80
#: src/pages/UserPublicProfile.tsx:125
msgid "← Back"
msgstr "← Back"
#: src/pages/Dump.tsx:326
#: src/pages/Dump.tsx:568
#: src/pages/DumpEdit.tsx:232
#: src/pages/Dump.tsx:325
#: src/pages/Dump.tsx:552
#: src/pages/DumpEdit.tsx:233
msgid "← Back to all dumps"
msgstr "← Back to all dumps"
@@ -225,7 +225,7 @@ msgstr "+ Invite someone"
msgid "+ New playlist"
msgstr "+ New playlist"
#: src/pages/Dump.tsx:403
#: src/pages/Dump.tsx:399
msgid "+ Playlist"
msgstr "+ Playlist"
@@ -312,8 +312,8 @@ msgstr "Account on <0>{host}</0>"
msgid "Add a comment…"
msgstr "Add a comment…"
#: src/components/category/CategoryManager.tsx:104
#: src/components/category/CategoryManager.tsx:105
#: src/components/category/CategoryManager.tsx:115
#: src/components/category/CategoryManager.tsx:116
msgid "Add category"
msgstr "Add category"
@@ -325,7 +325,7 @@ msgstr "Add email…"
msgid "Add image"
msgstr "Add image"
#: src/components/dump/create/DumpCreateModal.tsx:465
#: src/components/dump/create/DumpCreateModal.tsx:458
#: src/components/playlist/AddToPlaylistModal.tsx:29
msgid "Add to playlist"
msgstr "Add to playlist"
@@ -339,11 +339,15 @@ msgstr "added to {playlist}"
msgid "Admin"
msgstr "Admin"
#: src/components/category/CategorySwitcher.tsx:100
#: src/components/category/CategorySwitcher.tsx:103
#: src/components/category/CategorySwitcher.tsx:104
#: src/components/category/CategorySwitcher.tsx:107
msgid "All"
msgstr "All"
#: src/components/layout/UserMenu.tsx:108
msgid "All playlists"
msgstr "All playlists"
#. placeholder {0}: first.ownerUsername
#. placeholder {1}: relativeTime(first.createdAt)
#: src/components/dump/create/DuplicateNotice.tsx:39
@@ -424,19 +428,19 @@ msgstr "Can't connect to the live updates server. Upvotes and notifications may
msgid "Can't sign in there"
msgstr "Can't sign in there"
#: src/components/category/CategoryManager.tsx:225
#: src/components/category/CategoryManager.tsx:226
#: src/components/category/CategoryManager.tsx:241
#: src/components/category/CategoryManager.tsx:242
#: src/components/chat/ChatEditBox.tsx:92
#: src/components/comment/CommentForm.tsx:127
#: src/components/dump/create/DumpCreateModal.tsx:752
#: src/components/dump/create/DumpCreateModal.tsx:746
#: src/components/form/FormActions.tsx:42
#: src/components/playlist/PlaylistEditForm.tsx:200
#: src/components/playlist/PlaylistEditForm.tsx:222
#: src/components/profile/DescriptionEditor.tsx:97
#: src/components/profile/EmailEditor.tsx:83
#: src/components/shared/ConfirmModal.tsx:63
#: src/pages/Authorize.tsx:158
#: src/pages/Dump.tsx:445
#: src/pages/DumpEdit.tsx:565
#: src/pages/Dump.tsx:441
#: src/pages/DumpEdit.tsx:560
msgid "Cancel"
msgstr "Cancel"
@@ -453,12 +457,11 @@ msgstr "Cancel reply"
msgid "Cancel search"
msgstr "Cancel search"
#: src/components/category/CategorySelect.tsx:33
#: src/pages/Manage.tsx:42
#: src/components/category/CategorySelect.tsx:38
msgid "Categories"
msgstr "Categories"
#: src/components/category/CategorySwitcher.tsx:123
#: src/components/category/CategorySwitcher.tsx:127
msgid "Category"
msgstr "Category"
@@ -513,7 +516,7 @@ msgstr "Checking…"
msgid "Close"
msgstr "Close"
#: src/components/dump/create/DumpCreateModal.tsx:496
#: src/components/dump/create/DumpCreateModal.tsx:489
msgid "Close anyway"
msgstr "Close anyway"
@@ -525,7 +528,7 @@ msgstr "Close emoji picker"
msgid "Close player"
msgstr "Close player"
#: src/components/dump/create/DumpCreateModal.tsx:486
#: src/components/dump/create/DumpCreateModal.tsx:479
msgid "Close without posting? The attached file won't be kept — everything you typed will."
msgstr "Close without posting? The attached file won't be kept — everything you typed will."
@@ -579,8 +582,8 @@ msgstr "Could not change password"
msgid "Could not connect to the server. Check your connection and try again."
msgstr "Could not connect to the server. Check your connection and try again."
#: src/components/profile/ProfilePlaylistsTab.tsx:126
#: src/pages/DumpEdit.tsx:559
#: src/components/profile/ProfilePlaylistsTab.tsx:177
#: src/pages/DumpEdit.tsx:554
msgid "Could not delete"
msgstr "Could not delete"
@@ -588,7 +591,7 @@ msgstr "Could not delete"
msgid "Could not rename"
msgstr "Could not rename"
#: src/pages/DumpEdit.tsx:512
#: src/pages/DumpEdit.tsx:506
msgid "Could not save"
msgstr "Could not save"
@@ -596,7 +599,7 @@ msgstr "Could not save"
msgid "Couldn't add that instance"
msgstr "Couldn't add that instance"
#: src/components/dump/create/DumpCreateModal.tsx:607
#: src/components/dump/create/DumpCreateModal.tsx:600
msgid "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later."
msgstr "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later."
@@ -616,19 +619,19 @@ msgstr "Couldn't unsubscribe"
msgid "Couldn't update. Try again."
msgstr "Couldn't update. Try again."
#: src/components/playlist/PlaylistEditForm.tsx:135
#: src/components/playlist/PlaylistEditForm.tsx:150
msgid "Cover"
msgstr "Cover"
#: src/components/playlist/PlaylistCreateForm.tsx:112
#: src/components/playlist/PlaylistCreateForm.tsx:122
msgid "Create"
msgstr "Create"
#: src/components/playlist/PlaylistCreateForm.tsx:112
#: src/components/playlist/PlaylistCreateForm.tsx:122
msgid "Create & Add"
msgstr "Create & Add"
#: src/components/playlist/PlaylistCreateForm.tsx:111
#: src/components/playlist/PlaylistCreateForm.tsx:121
msgid "Creating…"
msgstr "Creating…"
@@ -644,8 +647,8 @@ msgstr "Dark"
msgid "Default tab"
msgstr "Default tab"
#: src/components/category/CategoryManager.tsx:236
#: src/components/category/CategoryManager.tsx:237
#: src/components/category/CategoryManager.tsx:252
#: src/components/category/CategoryManager.tsx:253
#: src/components/chat/ChatMessageItem.tsx:118
#: src/components/chat/ChatMessageItem.tsx:119
#: src/components/chat/ChatMessageItem.tsx:220
@@ -656,28 +659,28 @@ msgstr "Default tab"
msgid "Delete"
msgstr "Delete"
#: src/components/category/CategoryManager.tsx:253
#: src/components/category/CategoryManager.tsx:269
msgid "Delete category"
msgstr "Delete category"
#. placeholder {0}: category.name
#: src/components/category/CategoryManager.tsx:252
#: src/components/category/CategoryManager.tsx:268
msgid "Delete category \"{0}\"? This cannot be undone."
msgstr "Delete category \"{0}\"? This cannot be undone."
#: src/pages/DumpEdit.tsx:314
#: src/pages/DumpEdit.tsx:562
#: src/pages/DumpEdit.tsx:315
#: src/pages/DumpEdit.tsx:557
msgid "Delete dump"
msgstr "Delete dump"
#. placeholder {0}: res.status
#: src/pages/DumpEdit.tsx:193
#: src/pages/DumpEdit.tsx:194
msgid "Delete failed ({0})"
msgstr "Delete failed ({0})"
#: src/components/playlist/PlaylistCard.tsx:142
#: src/components/playlist/PlaylistEditForm.tsx:191
#: src/components/profile/ProfilePlaylistsTab.tsx:156
#: src/components/playlist/PlaylistCard.tsx:149
#: src/components/playlist/PlaylistEditForm.tsx:213
#: src/components/profile/ProfilePlaylistsTab.tsx:207
#: src/pages/PlaylistDetail.tsx:141
msgid "Delete playlist"
msgstr "Delete playlist"
@@ -686,7 +689,7 @@ msgstr "Delete playlist"
msgid "Delete this comment?"
msgstr "Delete this comment?"
#: src/pages/DumpEdit.tsx:313
#: src/pages/DumpEdit.tsx:314
msgid "Delete this dump? This cannot be undone."
msgstr "Delete this dump? This cannot be undone."
@@ -694,7 +697,7 @@ msgstr "Delete this dump? This cannot be undone."
msgid "Delete this message?"
msgstr "Delete this message?"
#: src/components/profile/ProfilePlaylistsTab.tsx:155
#: src/components/profile/ProfilePlaylistsTab.tsx:206
#: src/pages/PlaylistDetail.tsx:140
msgid "Delete this playlist? This cannot be undone."
msgstr "Delete this playlist? This cannot be undone."
@@ -703,11 +706,11 @@ msgstr "Delete this playlist? This cannot be undone."
msgid "deleted message"
msgstr "deleted message"
#: src/components/playlist/PlaylistEditForm.tsx:176
#: src/components/playlist/PlaylistEditForm.tsx:191
msgid "Description"
msgstr "Description"
#: src/components/playlist/PlaylistCreateForm.tsx:104
#: src/components/playlist/PlaylistCreateForm.tsx:108
msgid "Description (optional)"
msgstr "Description (optional)"
@@ -728,7 +731,7 @@ msgstr "Disconnected — reconnecting…"
msgid "Dismiss"
msgstr "Dismiss"
#: src/components/dump/create/DumpCreateModal.tsx:546
#: src/components/dump/create/DumpCreateModal.tsx:539
msgid "Done"
msgstr "Done"
@@ -741,11 +744,11 @@ msgstr "Download {0}"
msgid "Drop a file here"
msgstr "Drop a file here"
#: src/components/dump/create/DumpCreateModal.tsx:653
#: src/components/dump/create/DumpCreateModal.tsx:646
msgid "Drop a file here, or paste one"
msgstr "Drop a file here, or paste one"
#: src/pages/DumpEdit.tsx:503
#: src/pages/DumpEdit.tsx:497
msgid "Drop a replacement here"
msgstr "Drop a replacement here"
@@ -753,16 +756,20 @@ msgstr "Drop a replacement here"
msgid "Dump"
msgstr "Dump"
#: src/components/dump/create/DumpCreateModal.tsx:765
#: src/pages/Manage.tsx:43
msgid "Dump categories"
msgstr "Dump categories"
#: src/components/dump/create/DumpCreateModal.tsx:759
msgid "Dump it"
msgstr "Dump it"
#: src/components/dump/create/DumpCreateModal.tsx:524
#: src/components/dump/create/DumpCreateModal.tsx:517
msgid "Dumped!"
msgstr "Dumped!"
#: src/components/profile/ProfileDumpsTab.tsx:46
#: src/pages/Search.tsx:120
#: src/pages/Search.tsx:148
#: src/pages/UserPublicProfile.tsx:160
msgid "Dumps"
msgstr "Dumps"
@@ -780,13 +787,13 @@ msgstr "Earlier"
#: src/components/chat/ChatMessageItem.tsx:108
#: src/components/chat/PendingItem.tsx:93
#: src/components/comment/CommentNode.tsx:256
#: src/components/playlist/PlaylistHeader.tsx:59
#: src/pages/Dump.tsx:564
#: src/components/playlist/PlaylistHeader.tsx:60
#: src/pages/Dump.tsx:548
msgid "Edit"
msgstr "Edit"
#. placeholder {0}: category.name
#: src/components/category/CategoryManager.tsx:84
#: src/components/category/CategoryManager.tsx:94
msgid "Edit {0}"
msgstr "Edit {0}"
@@ -799,7 +806,7 @@ msgid "Edit email"
msgstr "Edit email"
#: src/components/dump/create/TitleField.tsx:69
#: src/pages/Dump.tsx:462
#: src/pages/Dump.tsx:458
msgid "Edit title"
msgstr "Edit title"
@@ -812,8 +819,8 @@ msgstr "edited"
#. placeholder {0}: relativeTime(message.updatedAt)
#: src/components/chat/ChatMessageItem.tsx:132
#: src/components/comment/CommentNode.tsx:205
#: src/components/playlist/PlaylistHeader.tsx:94
#: src/pages/Dump.tsx:505
#: src/components/playlist/PlaylistHeader.tsx:95
#: src/pages/Dump.tsx:501
msgid "edited {0}"
msgstr "edited {0}"
@@ -822,17 +829,17 @@ msgstr "edited {0}"
#. placeholder {0}: message.updatedAt.toLocaleString()
#: src/components/chat/ChatMessageItem.tsx:128
#: src/components/comment/CommentNode.tsx:203
#: src/components/playlist/PlaylistHeader.tsx:92
#: src/pages/Dump.tsx:503
#: src/components/playlist/PlaylistHeader.tsx:93
#: src/pages/Dump.tsx:499
msgid "Edited {0}"
msgstr "Edited {0}"
#: src/pages/DumpEdit.tsx:257
#: src/pages/DumpEdit.tsx:258
msgid "Editing"
msgstr "Editing"
#. placeholder {0}: state.dump.title
#: src/pages/DumpEdit.tsx:80
#: src/pages/DumpEdit.tsx:81
msgid "Editing {0}"
msgstr "Editing {0}"
@@ -844,7 +851,7 @@ msgstr "Email"
msgid "Email address"
msgstr "Email address"
#: src/pages/Search.tsx:155
#: src/pages/Search.tsx:204
msgid "Enter a query to search."
msgstr "Enter a query to search."
@@ -852,15 +859,15 @@ msgstr "Enter a query to search."
msgid "Expand player"
msgstr "Expand player"
#: src/components/category/CategoryManager.tsx:246
#: src/components/category/CategoryManager.tsx:262
msgid "Failed to create category"
msgstr "Failed to create category"
#: src/components/playlist/PlaylistCreateForm.tsx:109
#: src/components/playlist/PlaylistCreateForm.tsx:119
msgid "Failed to create playlist"
msgstr "Failed to create playlist"
#: src/components/category/CategoryManager.tsx:169
#: src/components/category/CategoryManager.tsx:185
msgid "Failed to delete"
msgstr "Failed to delete"
@@ -874,7 +881,7 @@ msgstr "Failed to generate invite"
msgid "Failed to load"
msgstr "Failed to load"
#: src/components/dump/create/DumpCreateModal.tsx:555
#: src/components/dump/create/DumpCreateModal.tsx:548
msgid "Failed to post"
msgstr "Failed to post"
@@ -888,13 +895,13 @@ msgstr "Failed to post reply"
#: src/components/federation/GrantList.tsx:98
#: src/components/federation/PeerManager.tsx:158
#: src/components/playlist/PlaylistEditForm.tsx:185
#: src/components/playlist/PlaylistEditForm.tsx:207
#: src/components/profile/DescriptionEditor.tsx:99
#: src/components/profile/EmailEditor.tsx:86
msgid "Failed to save"
msgstr "Failed to save"
#: src/components/category/CategoryManager.tsx:247
#: src/components/category/CategoryManager.tsx:263
msgid "Failed to save category"
msgstr "Failed to save category"
@@ -914,11 +921,11 @@ msgstr "Failed to update role"
msgid "Feeds"
msgstr "Feeds"
#: src/components/dump/create/DumpCreateModal.tsx:594
#: src/components/dump/create/DumpCreateModal.tsx:587
msgid "Fetching preview…"
msgstr "Fetching preview…"
#: src/components/dump/create/DumpCreateModal.tsx:417
#: src/components/dump/create/DumpCreateModal.tsx:410
msgid "File too large (max {max})."
msgstr "File too large (max {max})."
@@ -1017,7 +1024,7 @@ msgstr "If you didn't ask for this, ignore this email. Your password stays the s
msgid "image"
msgstr "image"
#: src/pages/Dump.tsx:584
#: src/pages/Dump.tsx:568
msgid "In collections"
msgstr "In collections"
@@ -1031,7 +1038,7 @@ msgstr "In the chat"
msgid "Insert emoji"
msgstr "Insert emoji"
#: src/pages/Manage.tsx:49
#: src/pages/Manage.tsx:56
msgid "Instances"
msgstr "Instances"
@@ -1073,7 +1080,7 @@ msgstr "Invitees"
msgid "Journal"
msgstr "Journal"
#: src/components/dump/create/DumpCreateModal.tsx:493
#: src/components/dump/create/DumpCreateModal.tsx:486
msgid "Keep editing"
msgstr "Keep editing"
@@ -1094,7 +1101,7 @@ msgstr "Light"
msgid "Like"
msgstr "Like"
#: src/components/dump/create/DumpCreateModal.tsx:569
#: src/components/dump/create/DumpCreateModal.tsx:562
#: src/components/form/SlugField.tsx:69
msgid "Link"
msgstr "Link"
@@ -1119,8 +1126,8 @@ msgstr "Load more"
msgid "Load older messages"
msgstr "Load older messages"
#: src/pages/Dump.tsx:302
#: src/pages/DumpEdit.tsx:208
#: src/pages/Dump.tsx:301
#: src/pages/DumpEdit.tsx:209
msgid "Loading dump…"
msgstr "Loading dump…"
@@ -1140,7 +1147,7 @@ msgstr "Loading playlist…"
msgid "Loading profile…"
msgstr "Loading profile…"
#: src/components/category/CategoryManager.tsx:60
#: src/components/category/CategoryManager.tsx:69
#: src/components/chat/ChatModal.tsx:763
#: src/components/editor/EmojiPickerPopup.tsx:125
#: src/components/federation/PeerManager.tsx:120
@@ -1173,7 +1180,7 @@ msgstr "Log in to vote"
msgid "Log in with {0}"
msgstr "Log in with {0}"
#: src/components/layout/UserMenu.tsx:127
#: src/components/layout/UserMenu.tsx:139
#: src/pages/UserPublicProfile.tsx:133
msgid "Log out"
msgstr "Log out"
@@ -1191,7 +1198,7 @@ msgstr "Login failed"
msgid "Lowercase letters, numbers and single hyphens only"
msgstr "Lowercase letters, numbers and single hyphens only"
#: src/components/layout/UserMenu.tsx:114
#: src/components/layout/UserMenu.tsx:126
#: src/pages/Manage.tsx:32
#: src/pages/Manage.tsx:40
#: src/pages/UserPublicProfile.tsx:166
@@ -1215,8 +1222,8 @@ msgstr "Monthly recap"
msgid "Mute"
msgstr "Mute"
#: src/components/category/CategoryManager.tsx:192
#: src/components/category/CategoryManager.tsx:193
#: src/components/category/CategoryManager.tsx:208
#: src/components/category/CategoryManager.tsx:209
msgid "Name"
msgstr "Name"
@@ -1226,7 +1233,7 @@ msgstr "Name"
msgid "New"
msgstr "New"
#: src/components/dump/create/DumpCreateModal.tsx:465
#: src/components/dump/create/DumpCreateModal.tsx:458
#: src/components/dump/NewDumpButton.tsx:27
#: src/components/layout/DumpFab.tsx:66
#: src/components/layout/DumpFab.tsx:67
@@ -1253,7 +1260,7 @@ msgstr "New playlist"
msgid "digest.newPlaylists"
msgstr "New public playlists"
#: src/components/dump/create/DumpCreateModal.tsx:764
#: src/components/dump/create/DumpCreateModal.tsx:758
msgid "Next →"
msgstr "Next →"
@@ -1265,7 +1272,7 @@ msgstr "Next track"
msgid "No dumps in this playlist yet."
msgstr "No dumps in this playlist yet."
#: src/pages/Search.tsx:171
#: src/pages/Search.tsx:220
msgid "No dumps match \"{q}\"."
msgstr "No dumps match \"{q}\"."
@@ -1294,12 +1301,13 @@ msgstr "No messages yet. Say hello!"
msgid "No one yet."
msgstr "No one yet."
#: src/pages/Search.tsx:212
#: src/pages/Search.tsx:261
msgid "No playlists match \"{q}\"."
msgstr "No playlists match \"{q}\"."
#: src/components/playlist/PlaylistMembershipPanel.tsx:51
#: src/components/profile/ProfilePlaylistsTab.tsx:133
#: src/components/profile/ProfilePlaylistsTab.tsx:184
#: src/pages/Playlists.tsx:118
msgid "No playlists yet."
msgstr "No playlists yet."
@@ -1307,7 +1315,7 @@ msgstr "No playlists yet."
msgid "No reply from the server — this message may not have been sent"
msgstr "No reply from the server — this message may not have been sent"
#: src/pages/Search.tsx:186
#: src/pages/Search.tsx:235
msgid "No users match \"{q}\"."
msgstr "No users match \"{q}\"."
@@ -1327,7 +1335,7 @@ msgstr "Nothing happened this month."
msgid "Nothing happened this week."
msgstr "Nothing happened this week."
#: src/components/profile/ProfileDumpsTab.tsx:96
#: src/components/profile/ProfileDumpsTab.tsx:97
#: src/components/profile/ProfileOverviewTab.tsx:47
#: src/pages/Notifications.tsx:522
msgid "Nothing here yet."
@@ -1361,7 +1369,7 @@ msgstr "Once a week you'll get a digest of what happened here, and once a month
msgid "Only images can be dropped here."
msgstr "Only images can be dropped here."
#: src/pages/DumpEdit.tsx:118
#: src/pages/DumpEdit.tsx:119
msgid "Only its poster or a moderator can edit this dump."
msgstr "Only its poster or a moderator can edit this dump."
@@ -1432,7 +1440,7 @@ msgstr "Password updated"
msgid "Passwords do not match"
msgstr "Passwords do not match"
#: src/components/dump/create/DumpCreateModal.tsx:587
#: src/components/dump/create/DumpCreateModal.tsx:580
msgid "Paste a link…"
msgstr "Paste a link…"
@@ -1443,7 +1451,7 @@ msgstr "Paste a link…"
msgid "Pause"
msgstr "Pause"
#: src/components/dump/create/DumpCreateModal.tsx:505
#: src/components/dump/create/DumpCreateModal.tsx:498
msgid "Picked up where you left off."
msgstr "Picked up where you left off."
@@ -1455,12 +1463,18 @@ msgstr "Picked up where you left off."
msgid "Play"
msgstr "Play"
#: src/pages/Manage.tsx:48
msgid "Playlist categories"
msgstr "Playlist categories"
#: src/components/dump/create/StepIndicator.tsx:21
#: src/components/layout/AppHeader.tsx:172
#: src/components/layout/UserMenu.tsx:88
#: src/components/layout/UserMenu.tsx:92
#: src/components/profile/ProfileFollowTab.tsx:144
#: src/components/profile/ProfilePlaylistsTab.tsx:118
#: src/pages/Search.tsx:123
#: src/components/profile/ProfilePlaylistsTab.tsx:155
#: src/pages/Playlists.tsx:52
#: src/pages/Playlists.tsx:106
#: src/pages/Search.tsx:151
#: src/pages/UserPublicProfile.tsx:162
msgid "Playlists"
msgstr "Playlists"
@@ -1475,7 +1489,7 @@ msgstr "Post reply"
#: src/components/comment/CommentNode.tsx:290
#: src/components/comment/CommentThread.tsx:100
#: src/components/dump/create/DumpCreateModal.tsx:758
#: src/components/dump/create/DumpCreateModal.tsx:752
msgid "Posting…"
msgstr "Posting…"
@@ -1483,12 +1497,12 @@ msgstr "Posting…"
msgid "Previous track"
msgstr "Previous track"
#: src/components/dump/DumpCard.tsx:148
#: src/components/dump/DumpCard.tsx:144
#: src/components/dump/JournalCard.tsx:145
#: src/components/playlist/PlaylistCard.tsx:104
#: src/components/playlist/PlaylistHeader.tsx:76
#: src/components/playlist/PlaylistCard.tsx:106
#: src/components/playlist/PlaylistHeader.tsx:77
#: src/components/playlist/PlaylistMembershipPanel.tsx:81
#: src/pages/Dump.tsx:511
#: src/pages/Dump.tsx:507
msgid "private"
msgstr "private"
@@ -1496,8 +1510,8 @@ msgstr "private"
msgid "Private"
msgstr "Private"
#: src/components/playlist/PlaylistCard.tsx:103
#: src/components/playlist/PlaylistHeader.tsx:76
#: src/components/playlist/PlaylistCard.tsx:105
#: src/components/playlist/PlaylistHeader.tsx:77
msgid "public"
msgstr "public"
@@ -1505,7 +1519,7 @@ msgstr "public"
msgid "Public"
msgstr "Public"
#: src/pages/DumpEdit.tsx:290
#: src/pages/DumpEdit.tsx:291
msgid "Refresh metadata"
msgstr "Refresh metadata"
@@ -1513,7 +1527,7 @@ msgstr "Refresh metadata"
msgid "Refreshing stream…"
msgstr "Refreshing stream…"
#: src/pages/DumpEdit.tsx:289
#: src/pages/DumpEdit.tsx:290
msgid "Refreshing…"
msgstr "Refreshing…"
@@ -1531,7 +1545,7 @@ msgstr "Registering…"
msgid "Registration failed"
msgstr "Registration failed"
#: src/pages/Dump.tsx:575
#: src/pages/Dump.tsx:559
msgid "Related"
msgstr "Related"
@@ -1565,7 +1579,7 @@ msgstr "Rename"
msgid "Renaming…"
msgstr "Renaming…"
#: src/pages/DumpEdit.tsx:495
#: src/pages/DumpEdit.tsx:489
msgid "Replace file"
msgstr "Replace file"
@@ -1596,8 +1610,8 @@ msgstr "Reset password"
msgid "Reset password"
msgstr "Reset password"
#: src/pages/DumpEdit.tsx:471
#: src/pages/DumpEdit.tsx:526
#: src/pages/DumpEdit.tsx:465
#: src/pages/DumpEdit.tsx:520
msgid "Reset to default"
msgstr "Reset to default"
@@ -1617,8 +1631,8 @@ msgid "email.reset.title"
msgstr "Reset your password"
#: src/components/chat/PendingItem.tsx:86
#: src/pages/Dump.tsx:319
#: src/pages/DumpEdit.tsx:225
#: src/pages/Dump.tsx:318
#: src/pages/DumpEdit.tsx:226
msgid "Retry"
msgstr "Retry"
@@ -1626,30 +1640,30 @@ msgstr "Retry"
msgid "Role"
msgstr "Role"
#: src/components/category/CategoryManager.tsx:216
#: src/components/category/CategoryManager.tsx:232
#: src/components/chat/ChatEditBox.tsx:85
#: src/components/comment/CommentNode.tsx:216
#: src/components/playlist/PlaylistEditForm.tsx:207
#: src/components/playlist/PlaylistEditForm.tsx:229
#: src/components/profile/DescriptionEditor.tsx:89
#: src/components/profile/EmailEditor.tsx:75
#: src/pages/Dump.tsx:437
#: src/pages/DumpEdit.tsx:568
#: src/pages/Dump.tsx:433
#: src/pages/DumpEdit.tsx:563
msgid "Save"
msgstr "Save"
#: src/components/comment/CommentNode.tsx:217
#: src/components/playlist/PlaylistEditForm.tsx:204
#: src/components/playlist/PlaylistEditForm.tsx:226
#: src/components/profile/ChangePasswordModal.tsx:119
#: src/components/profile/DescriptionEditor.tsx:86
#: src/components/profile/EmailEditor.tsx:72
#: src/pages/Dump.tsx:436
#: src/pages/Dump.tsx:432
#: src/pages/ResetPassword.tsx:139
msgid "Saving…"
msgstr "Saving…"
#: src/components/layout/AppHeader.tsx:182
#: src/components/layout/SearchBar.tsx:83
#: src/pages/Search.tsx:69
#: src/pages/Search.tsx:87
msgid "Search"
msgstr "Search"
@@ -1665,11 +1679,11 @@ msgstr "Search emoji"
msgid "Search emoji…"
msgstr "Search emoji…"
#: src/pages/Search.tsx:166
#: src/pages/Search.tsx:215
msgid "Search failed"
msgstr "Search failed"
#: src/pages/Search.tsx:161
#: src/pages/Search.tsx:210
msgid "Searching…"
msgstr "Searching…"
@@ -1709,7 +1723,7 @@ msgstr "Set new password"
msgid "email.settings"
msgstr "Settings"
#: src/components/layout/UserMenu.tsx:104
#: src/components/layout/UserMenu.tsx:116
#: src/pages/Settings.tsx:75
#: src/pages/Settings.tsx:88
msgid "Settings"
@@ -1736,7 +1750,7 @@ msgstr "Signing you in…"
msgid "Single use, valid for 7 days."
msgstr "Single use, valid for 7 days."
#: src/components/category/CategoryManager.tsx:205
#: src/components/category/CategoryManager.tsx:221
msgid "slug"
msgstr "slug"
@@ -1760,7 +1774,7 @@ msgstr "Something went wrong"
msgid "Sounds"
msgstr "Sounds"
#: src/components/dump/create/DumpCreateModal.tsx:512
#: src/components/dump/create/DumpCreateModal.tsx:505
msgid "Start fresh"
msgstr "Start fresh"
@@ -1856,18 +1870,18 @@ msgstr "This unsubscribe link is missing or malformed."
msgid "This week"
msgstr "This week"
#: src/pages/DumpEdit.tsx:455
#: src/pages/DumpEdit.tsx:449
msgid "Thumbnail"
msgstr "Thumbnail"
#: src/components/dump/create/TitleField.tsx:45
#: src/components/playlist/PlaylistCreateForm.tsx:96
#: src/components/playlist/PlaylistEditForm.tsx:160
#: src/pages/DumpEdit.tsx:516
#: src/components/playlist/PlaylistCreateForm.tsx:100
#: src/components/playlist/PlaylistEditForm.tsx:175
#: src/pages/DumpEdit.tsx:510
msgid "Title"
msgstr "Title"
#: src/components/dump/create/DumpCreateModal.tsx:414
#: src/components/dump/create/DumpCreateModal.tsx:407
msgid "Title is required."
msgstr "Title is required."
@@ -1967,7 +1981,7 @@ msgid "Upload failed"
msgstr "Upload failed"
#. placeholder {0}: Math.round((uploadProgress ?? 0) * 100)
#: src/components/dump/create/DumpCreateModal.tsx:449
#: src/components/dump/create/DumpCreateModal.tsx:442
msgid "Uploading {0}%"
msgstr "Uploading {0}%"
@@ -1975,7 +1989,7 @@ msgstr "Uploading {0}%"
msgid "Uploading image…"
msgstr "Uploading image…"
#: src/components/dump/create/DumpCreateModal.tsx:761
#: src/components/dump/create/DumpCreateModal.tsx:755
msgid "Uploading…"
msgstr "Uploading…"
@@ -1993,15 +2007,15 @@ msgstr "Upvoted"
msgid "Upvoted ({0})"
msgstr "Upvoted ({0})"
#: src/pages/DumpEdit.tsx:487
#: src/pages/DumpEdit.tsx:481
msgid "URL"
msgstr "URL"
#: src/components/dump/create/DumpCreateModal.tsx:398
#: src/components/dump/create/DumpCreateModal.tsx:391
msgid "URL is required."
msgstr "URL is required."
#: src/components/category/CategoryManager.tsx:206
#: src/components/category/CategoryManager.tsx:222
msgid "URL slug"
msgstr "URL slug"
@@ -2013,7 +2027,7 @@ msgstr "User"
msgid "User management"
msgstr "User management"
#: src/components/layout/UserMenu.tsx:63
#: src/components/layout/UserMenu.tsx:67
msgid "User menu"
msgstr "User menu"
@@ -2026,7 +2040,7 @@ msgid "Username"
msgstr "Username"
#: src/components/profile/ProfileFollowTab.tsx:143
#: src/pages/Search.tsx:122
#: src/pages/Search.tsx:150
msgid "Users"
msgstr "Users"
@@ -2046,7 +2060,7 @@ msgstr "Users of a trusted instance sign in here with their account there. Those
msgid "View all →"
msgstr "View all →"
#: src/components/dump/create/DumpCreateModal.tsx:526
#: src/components/dump/create/DumpCreateModal.tsx:519
msgid "View dump →"
msgstr "View dump →"
@@ -2064,8 +2078,8 @@ msgstr "Weekly digest"
msgid "email.welcome.subject"
msgstr "Welcome to {site}"
#: src/components/dump/create/DumpCreateModal.tsx:712
#: src/pages/DumpEdit.tsx:544
#: src/components/dump/create/DumpCreateModal.tsx:705
#: src/pages/DumpEdit.tsx:538
msgid "What makes it worth it?"
msgstr "What makes it worth it?"
@@ -2074,7 +2088,7 @@ msgstr "What makes it worth it?"
msgid "Who am I?"
msgstr "Who am I?"
#: src/components/dump/create/DumpCreateModal.tsx:727
#: src/components/dump/create/DumpCreateModal.tsx:721
msgid "Who can see it"
msgstr "Who can see it"
@@ -2082,8 +2096,8 @@ msgstr "Who can see it"
msgid "Why & where"
msgstr "Why & where"
#: src/components/dump/create/DumpCreateModal.tsx:711
#: src/pages/DumpEdit.tsx:543
#: src/components/dump/create/DumpCreateModal.tsx:704
#: src/pages/DumpEdit.tsx:537
msgid "Why?"
msgstr "Why?"

File diff suppressed because one or more lines are too long

View File

@@ -18,13 +18,13 @@ msgid "[deleted]"
msgstr "[supprimé]"
#. placeholder {0}: dump.commentCount
#: src/components/dump/DumpCard.tsx:139
#: src/components/dump/DumpCard.tsx:135
#: src/components/dump/JournalCard.tsx:136
msgid "{0, plural, one {# comment} other {# comments}}"
msgstr "{0, plural, one {# commentaire} other {# commentaires}}"
#. placeholder {0}: playlist.dumpCount
#: src/components/playlist/PlaylistCard.tsx:118
#: src/components/playlist/PlaylistCard.tsx:120
msgid "{0, plural, one {# dump} other {# dumps}}"
msgstr "{0, plural, one {# reco} other {# recos}}"
@@ -60,7 +60,7 @@ msgstr "{0}–{1} caractères : lettres, chiffres ou tirets bas"
msgid "email.digest.totals"
msgstr "{dumps, plural, one {# reco} other {# recos}}, {upvotes, plural, one {# vote} other {# votes}} et {comments, plural, one {# commentaire} other {# commentaires}}."
#: src/pages/Search.tsx:124
#: src/pages/Search.tsx:152
msgid "{label} ({count})"
msgstr "{label} ({count})"
@@ -205,15 +205,15 @@ msgstr "# Bienvenue sur {site} !\n"
"\n"
"Votre compte a bien été créé. Bienvenue à bord !"
#: src/components/dump/create/DumpCreateModal.tsx:742
#: src/components/dump/create/DumpCreateModal.tsx:736
#: src/pages/PlaylistDetail.tsx:80
#: src/pages/UserPublicProfile.tsx:125
msgid "← Back"
msgstr "← Retour"
#: src/pages/Dump.tsx:326
#: src/pages/Dump.tsx:568
#: src/pages/DumpEdit.tsx:232
#: src/pages/Dump.tsx:325
#: src/pages/Dump.tsx:552
#: src/pages/DumpEdit.tsx:233
msgid "← Back to all dumps"
msgstr "← Retour à toutes les recos"
@@ -225,7 +225,7 @@ msgstr "+ Inviter quelqu'un"
msgid "+ New playlist"
msgstr "+ Nouvelle collection"
#: src/pages/Dump.tsx:403
#: src/pages/Dump.tsx:399
msgid "+ Playlist"
msgstr "+ Collection"
@@ -312,8 +312,8 @@ msgstr "Compte sur <0>{host}</0>"
msgid "Add a comment…"
msgstr "Ajouter un commentaire…"
#: src/components/category/CategoryManager.tsx:104
#: src/components/category/CategoryManager.tsx:105
#: src/components/category/CategoryManager.tsx:115
#: src/components/category/CategoryManager.tsx:116
msgid "Add category"
msgstr "Ajouter une catégorie"
@@ -325,7 +325,7 @@ msgstr "Ajouter un e-mail…"
msgid "Add image"
msgstr "Ajouter une image"
#: src/components/dump/create/DumpCreateModal.tsx:465
#: src/components/dump/create/DumpCreateModal.tsx:458
#: src/components/playlist/AddToPlaylistModal.tsx:29
msgid "Add to playlist"
msgstr "Ajouter à la collection"
@@ -339,11 +339,15 @@ msgstr "ajoutée à {playlist}"
msgid "Admin"
msgstr "Administrateur"
#: src/components/category/CategorySwitcher.tsx:100
#: src/components/category/CategorySwitcher.tsx:103
#: src/components/category/CategorySwitcher.tsx:104
#: src/components/category/CategorySwitcher.tsx:107
msgid "All"
msgstr "Tout"
#: src/components/layout/UserMenu.tsx:108
msgid "All playlists"
msgstr "Toutes les collections"
#. placeholder {0}: first.ownerUsername
#. placeholder {1}: relativeTime(first.createdAt)
#: src/components/dump/create/DuplicateNotice.tsx:39
@@ -424,19 +428,19 @@ msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les vo
msgid "Can't sign in there"
msgstr "Connexion impossible"
#: src/components/category/CategoryManager.tsx:225
#: src/components/category/CategoryManager.tsx:226
#: src/components/category/CategoryManager.tsx:241
#: src/components/category/CategoryManager.tsx:242
#: src/components/chat/ChatEditBox.tsx:92
#: src/components/comment/CommentForm.tsx:127
#: src/components/dump/create/DumpCreateModal.tsx:752
#: src/components/dump/create/DumpCreateModal.tsx:746
#: src/components/form/FormActions.tsx:42
#: src/components/playlist/PlaylistEditForm.tsx:200
#: src/components/playlist/PlaylistEditForm.tsx:222
#: src/components/profile/DescriptionEditor.tsx:97
#: src/components/profile/EmailEditor.tsx:83
#: src/components/shared/ConfirmModal.tsx:63
#: src/pages/Authorize.tsx:158
#: src/pages/Dump.tsx:445
#: src/pages/DumpEdit.tsx:565
#: src/pages/Dump.tsx:441
#: src/pages/DumpEdit.tsx:560
msgid "Cancel"
msgstr "Annuler"
@@ -453,12 +457,11 @@ msgstr "Annuler la réponse"
msgid "Cancel search"
msgstr "Annuler la recherche"
#: src/components/category/CategorySelect.tsx:33
#: src/pages/Manage.tsx:42
#: src/components/category/CategorySelect.tsx:38
msgid "Categories"
msgstr "Catégories"
#: src/components/category/CategorySwitcher.tsx:123
#: src/components/category/CategorySwitcher.tsx:127
msgid "Category"
msgstr "Catégorie"
@@ -513,7 +516,7 @@ msgstr "Vérification…"
msgid "Close"
msgstr "Fermer"
#: src/components/dump/create/DumpCreateModal.tsx:496
#: src/components/dump/create/DumpCreateModal.tsx:489
msgid "Close anyway"
msgstr "Fermer quand même"
@@ -525,7 +528,7 @@ msgstr "Fermer le sélecteur d'emoji"
msgid "Close player"
msgstr "Fermer le lecteur"
#: src/components/dump/create/DumpCreateModal.tsx:486
#: src/components/dump/create/DumpCreateModal.tsx:479
msgid "Close without posting? The attached file won't be kept — everything you typed will."
msgstr "Fermer sans publier ? Le fichier joint sera perdu — ce que vous avez écrit sera conservé."
@@ -579,8 +582,8 @@ msgstr "Impossible de changer le mot de passe"
msgid "Could not connect to the server. Check your connection and try again."
msgstr "Impossible de joindre le serveur. Vérifiez votre connexion et réessayez."
#: src/components/profile/ProfilePlaylistsTab.tsx:126
#: src/pages/DumpEdit.tsx:559
#: src/components/profile/ProfilePlaylistsTab.tsx:177
#: src/pages/DumpEdit.tsx:554
msgid "Could not delete"
msgstr "Suppression impossible"
@@ -588,7 +591,7 @@ msgstr "Suppression impossible"
msgid "Could not rename"
msgstr "Renommage impossible"
#: src/pages/DumpEdit.tsx:512
#: src/pages/DumpEdit.tsx:506
msgid "Could not save"
msgstr "Sauvegarde impossible"
@@ -596,7 +599,7 @@ msgstr "Sauvegarde impossible"
msgid "Couldn't add that instance"
msgstr "Impossible d'ajouter cette instance"
#: src/components/dump/create/DumpCreateModal.tsx:607
#: src/components/dump/create/DumpCreateModal.tsx:600
msgid "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later."
msgstr "Impossible de charger un aperçu pour ce lien — vérifiez-le, ou publiez-le tel quel et actualisez l'aperçu plus tard."
@@ -616,19 +619,19 @@ msgstr "Désabonnement impossible"
msgid "Couldn't update. Try again."
msgstr "La mise à jour a échoué. Réessayez."
#: src/components/playlist/PlaylistEditForm.tsx:135
#: src/components/playlist/PlaylistEditForm.tsx:150
msgid "Cover"
msgstr "Couverture"
#: src/components/playlist/PlaylistCreateForm.tsx:112
#: src/components/playlist/PlaylistCreateForm.tsx:122
msgid "Create"
msgstr "Créer"
#: src/components/playlist/PlaylistCreateForm.tsx:112
#: src/components/playlist/PlaylistCreateForm.tsx:122
msgid "Create & Add"
msgstr "Créer et ajouter"
#: src/components/playlist/PlaylistCreateForm.tsx:111
#: src/components/playlist/PlaylistCreateForm.tsx:121
msgid "Creating…"
msgstr "Création…"
@@ -644,8 +647,8 @@ msgstr "Sombre"
msgid "Default tab"
msgstr "Onglet par défaut"
#: src/components/category/CategoryManager.tsx:236
#: src/components/category/CategoryManager.tsx:237
#: src/components/category/CategoryManager.tsx:252
#: src/components/category/CategoryManager.tsx:253
#: src/components/chat/ChatMessageItem.tsx:118
#: src/components/chat/ChatMessageItem.tsx:119
#: src/components/chat/ChatMessageItem.tsx:220
@@ -656,28 +659,28 @@ msgstr "Onglet par défaut"
msgid "Delete"
msgstr "Supprimer"
#: src/components/category/CategoryManager.tsx:253
#: src/components/category/CategoryManager.tsx:269
msgid "Delete category"
msgstr "Supprimer la catégorie"
#. placeholder {0}: category.name
#: src/components/category/CategoryManager.tsx:252
#: src/components/category/CategoryManager.tsx:268
msgid "Delete category \"{0}\"? This cannot be undone."
msgstr "Supprimer la catégorie \"{0}\" ? Cette action est irréversible."
#: src/pages/DumpEdit.tsx:314
#: src/pages/DumpEdit.tsx:562
#: src/pages/DumpEdit.tsx:315
#: src/pages/DumpEdit.tsx:557
msgid "Delete dump"
msgstr "Supprimer la reco"
#. placeholder {0}: res.status
#: src/pages/DumpEdit.tsx:193
#: src/pages/DumpEdit.tsx:194
msgid "Delete failed ({0})"
msgstr "Échec de la suppression ({0})"
#: src/components/playlist/PlaylistCard.tsx:142
#: src/components/playlist/PlaylistEditForm.tsx:191
#: src/components/profile/ProfilePlaylistsTab.tsx:156
#: src/components/playlist/PlaylistCard.tsx:149
#: src/components/playlist/PlaylistEditForm.tsx:213
#: src/components/profile/ProfilePlaylistsTab.tsx:207
#: src/pages/PlaylistDetail.tsx:141
msgid "Delete playlist"
msgstr "Supprimer la collection"
@@ -686,7 +689,7 @@ msgstr "Supprimer la collection"
msgid "Delete this comment?"
msgstr "Supprimer ce commentaire ?"
#: src/pages/DumpEdit.tsx:313
#: src/pages/DumpEdit.tsx:314
msgid "Delete this dump? This cannot be undone."
msgstr "Supprimer cette reco ? Cette action est irréversible."
@@ -694,7 +697,7 @@ msgstr "Supprimer cette reco ? Cette action est irréversible."
msgid "Delete this message?"
msgstr "Supprimer ce message ?"
#: src/components/profile/ProfilePlaylistsTab.tsx:155
#: src/components/profile/ProfilePlaylistsTab.tsx:206
#: src/pages/PlaylistDetail.tsx:140
msgid "Delete this playlist? This cannot be undone."
msgstr "Supprimer cette collection ? Cette action est irréversible."
@@ -703,11 +706,11 @@ msgstr "Supprimer cette collection ? Cette action est irréversible."
msgid "deleted message"
msgstr "message supprimé"
#: src/components/playlist/PlaylistEditForm.tsx:176
#: src/components/playlist/PlaylistEditForm.tsx:191
msgid "Description"
msgstr "Description"
#: src/components/playlist/PlaylistCreateForm.tsx:104
#: src/components/playlist/PlaylistCreateForm.tsx:108
msgid "Description (optional)"
msgstr "Description (facultatif)"
@@ -728,7 +731,7 @@ msgstr "Déconnecté — reconnexion…"
msgid "Dismiss"
msgstr "Fermer"
#: src/components/dump/create/DumpCreateModal.tsx:546
#: src/components/dump/create/DumpCreateModal.tsx:539
msgid "Done"
msgstr "Terminé"
@@ -741,11 +744,11 @@ msgstr "Télécharger {0}"
msgid "Drop a file here"
msgstr "Déposez un fichier ici"
#: src/components/dump/create/DumpCreateModal.tsx:653
#: src/components/dump/create/DumpCreateModal.tsx:646
msgid "Drop a file here, or paste one"
msgstr "Déposez un fichier ici, ou collez-en un"
#: src/pages/DumpEdit.tsx:503
#: src/pages/DumpEdit.tsx:497
msgid "Drop a replacement here"
msgstr "Déposez un fichier de remplacement ici"
@@ -753,16 +756,20 @@ msgstr "Déposez un fichier de remplacement ici"
msgid "Dump"
msgstr "Reco"
#: src/components/dump/create/DumpCreateModal.tsx:765
#: src/pages/Manage.tsx:43
msgid "Dump categories"
msgstr "Catégories de recos"
#: src/components/dump/create/DumpCreateModal.tsx:759
msgid "Dump it"
msgstr "Recommander"
#: src/components/dump/create/DumpCreateModal.tsx:524
#: src/components/dump/create/DumpCreateModal.tsx:517
msgid "Dumped!"
msgstr "Recommandé !"
#: src/components/profile/ProfileDumpsTab.tsx:46
#: src/pages/Search.tsx:120
#: src/pages/Search.tsx:148
#: src/pages/UserPublicProfile.tsx:160
msgid "Dumps"
msgstr "Recos"
@@ -780,13 +787,13 @@ msgstr "Plus tôt"
#: src/components/chat/ChatMessageItem.tsx:108
#: src/components/chat/PendingItem.tsx:93
#: src/components/comment/CommentNode.tsx:256
#: src/components/playlist/PlaylistHeader.tsx:59
#: src/pages/Dump.tsx:564
#: src/components/playlist/PlaylistHeader.tsx:60
#: src/pages/Dump.tsx:548
msgid "Edit"
msgstr "Modifier"
#. placeholder {0}: category.name
#: src/components/category/CategoryManager.tsx:84
#: src/components/category/CategoryManager.tsx:94
msgid "Edit {0}"
msgstr "Modifier {0}"
@@ -799,7 +806,7 @@ msgid "Edit email"
msgstr "Modifier l'e-mail"
#: src/components/dump/create/TitleField.tsx:69
#: src/pages/Dump.tsx:462
#: src/pages/Dump.tsx:458
msgid "Edit title"
msgstr "Modifier le titre"
@@ -812,8 +819,8 @@ msgstr "modifié"
#. placeholder {0}: relativeTime(message.updatedAt)
#: src/components/chat/ChatMessageItem.tsx:132
#: src/components/comment/CommentNode.tsx:205
#: src/components/playlist/PlaylistHeader.tsx:94
#: src/pages/Dump.tsx:505
#: src/components/playlist/PlaylistHeader.tsx:95
#: src/pages/Dump.tsx:501
msgid "edited {0}"
msgstr "modifié {0}"
@@ -822,17 +829,17 @@ msgstr "modifié {0}"
#. placeholder {0}: message.updatedAt.toLocaleString()
#: src/components/chat/ChatMessageItem.tsx:128
#: src/components/comment/CommentNode.tsx:203
#: src/components/playlist/PlaylistHeader.tsx:92
#: src/pages/Dump.tsx:503
#: src/components/playlist/PlaylistHeader.tsx:93
#: src/pages/Dump.tsx:499
msgid "Edited {0}"
msgstr "Modifié le {0}"
#: src/pages/DumpEdit.tsx:257
#: src/pages/DumpEdit.tsx:258
msgid "Editing"
msgstr "Modification"
#. placeholder {0}: state.dump.title
#: src/pages/DumpEdit.tsx:80
#: src/pages/DumpEdit.tsx:81
msgid "Editing {0}"
msgstr "Édition de {0}"
@@ -844,7 +851,7 @@ msgstr "E-mail"
msgid "Email address"
msgstr "Adresse e-mail"
#: src/pages/Search.tsx:155
#: src/pages/Search.tsx:204
msgid "Enter a query to search."
msgstr "Saisissez une recherche."
@@ -852,15 +859,15 @@ msgstr "Saisissez une recherche."
msgid "Expand player"
msgstr "Agrandir le lecteur"
#: src/components/category/CategoryManager.tsx:246
#: src/components/category/CategoryManager.tsx:262
msgid "Failed to create category"
msgstr "Échec de la création de la catégorie"
#: src/components/playlist/PlaylistCreateForm.tsx:109
#: src/components/playlist/PlaylistCreateForm.tsx:119
msgid "Failed to create playlist"
msgstr "Impossible de créer la collection"
#: src/components/category/CategoryManager.tsx:169
#: src/components/category/CategoryManager.tsx:185
msgid "Failed to delete"
msgstr "Suppression échouée"
@@ -874,7 +881,7 @@ msgstr "Impossible de générer une invitation"
msgid "Failed to load"
msgstr "Chargement échoué"
#: src/components/dump/create/DumpCreateModal.tsx:555
#: src/components/dump/create/DumpCreateModal.tsx:548
msgid "Failed to post"
msgstr "Publication échouée"
@@ -888,13 +895,13 @@ msgstr "Impossible de publier la réponse"
#: src/components/federation/GrantList.tsx:98
#: src/components/federation/PeerManager.tsx:158
#: src/components/playlist/PlaylistEditForm.tsx:185
#: src/components/playlist/PlaylistEditForm.tsx:207
#: src/components/profile/DescriptionEditor.tsx:99
#: src/components/profile/EmailEditor.tsx:86
msgid "Failed to save"
msgstr "Enregistrement échoué"
#: src/components/category/CategoryManager.tsx:247
#: src/components/category/CategoryManager.tsx:263
msgid "Failed to save category"
msgstr "Échec de l’enregistrement de la catégorie"
@@ -914,11 +921,11 @@ msgstr "Erreur lors de la mise à jour du rôle"
msgid "Feeds"
msgstr "Flux"
#: src/components/dump/create/DumpCreateModal.tsx:594
#: src/components/dump/create/DumpCreateModal.tsx:587
msgid "Fetching preview…"
msgstr "Récupération de l'aperçu…"
#: src/components/dump/create/DumpCreateModal.tsx:417
#: src/components/dump/create/DumpCreateModal.tsx:410
msgid "File too large (max {max})."
msgstr "Fichier trop volumineux (max {max})."
@@ -1017,7 +1024,7 @@ msgstr "Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail. V
msgid "image"
msgstr "image"
#: src/pages/Dump.tsx:584
#: src/pages/Dump.tsx:568
msgid "In collections"
msgstr "Dans les collections"
@@ -1031,7 +1038,7 @@ msgstr "Dans la tribune"
msgid "Insert emoji"
msgstr "Insérer un emoji"
#: src/pages/Manage.tsx:49
#: src/pages/Manage.tsx:56
msgid "Instances"
msgstr "Instances"
@@ -1073,7 +1080,7 @@ msgstr "Invités"
msgid "Journal"
msgstr "Journal"
#: src/components/dump/create/DumpCreateModal.tsx:493
#: src/components/dump/create/DumpCreateModal.tsx:486
msgid "Keep editing"
msgstr "Continuer l'édition"
@@ -1094,7 +1101,7 @@ msgstr "Clair"
msgid "Like"
msgstr "Aimer"
#: src/components/dump/create/DumpCreateModal.tsx:569
#: src/components/dump/create/DumpCreateModal.tsx:562
#: src/components/form/SlugField.tsx:69
msgid "Link"
msgstr "Lien"
@@ -1119,8 +1126,8 @@ msgstr "Charger plus"
msgid "Load older messages"
msgstr "Charger les messages plus anciens"
#: src/pages/Dump.tsx:302
#: src/pages/DumpEdit.tsx:208
#: src/pages/Dump.tsx:301
#: src/pages/DumpEdit.tsx:209
msgid "Loading dump…"
msgstr "Chargement de la reco…"
@@ -1140,7 +1147,7 @@ msgstr "Chargement de la collection…"
msgid "Loading profile…"
msgstr "Chargement du profil…"
#: src/components/category/CategoryManager.tsx:60
#: src/components/category/CategoryManager.tsx:69
#: src/components/chat/ChatModal.tsx:763
#: src/components/editor/EmojiPickerPopup.tsx:125
#: src/components/federation/PeerManager.tsx:120
@@ -1173,7 +1180,7 @@ msgstr "Se connecter pour valider"
msgid "Log in with {0}"
msgstr "Se connecter avec {0}"
#: src/components/layout/UserMenu.tsx:127
#: src/components/layout/UserMenu.tsx:139
#: src/pages/UserPublicProfile.tsx:133
msgid "Log out"
msgstr "Se déconnecter"
@@ -1191,7 +1198,7 @@ msgstr "Connexion échouée"
msgid "Lowercase letters, numbers and single hyphens only"
msgstr "Minuscules, chiffres et tirets simples seulement"
#: src/components/layout/UserMenu.tsx:114
#: src/components/layout/UserMenu.tsx:126
#: src/pages/Manage.tsx:32
#: src/pages/Manage.tsx:40
#: src/pages/UserPublicProfile.tsx:166
@@ -1215,8 +1222,8 @@ msgstr "Récap mensuel"
msgid "Mute"
msgstr "Couper le son"
#: src/components/category/CategoryManager.tsx:192
#: src/components/category/CategoryManager.tsx:193
#: src/components/category/CategoryManager.tsx:208
#: src/components/category/CategoryManager.tsx:209
msgid "Name"
msgstr "Nom"
@@ -1226,7 +1233,7 @@ msgstr "Nom"
msgid "New"
msgstr "Nouveau"
#: src/components/dump/create/DumpCreateModal.tsx:465
#: src/components/dump/create/DumpCreateModal.tsx:458
#: src/components/dump/NewDumpButton.tsx:27
#: src/components/layout/DumpFab.tsx:66
#: src/components/layout/DumpFab.tsx:67
@@ -1253,7 +1260,7 @@ msgstr "Nouvelle collection"
msgid "digest.newPlaylists"
msgstr "Nouvelles collections publiques"
#: src/components/dump/create/DumpCreateModal.tsx:764
#: src/components/dump/create/DumpCreateModal.tsx:758
msgid "Next →"
msgstr "Suivant →"
@@ -1265,7 +1272,7 @@ msgstr "Piste suivante"
msgid "No dumps in this playlist yet."
msgstr "Aucune reco dans cette collection pour l'instant."
#: src/pages/Search.tsx:171
#: src/pages/Search.tsx:220
msgid "No dumps match \"{q}\"."
msgstr "Aucune reco ne correspond à « {q} »."
@@ -1294,12 +1301,13 @@ msgstr "Aucun message pour l'instant. Dites bonjour !"
msgid "No one yet."
msgstr "Personne pour le moment."
#: src/pages/Search.tsx:212
#: src/pages/Search.tsx:261
msgid "No playlists match \"{q}\"."
msgstr "Aucune collection ne correspond à « {q} »."
#: src/components/playlist/PlaylistMembershipPanel.tsx:51
#: src/components/profile/ProfilePlaylistsTab.tsx:133
#: src/components/profile/ProfilePlaylistsTab.tsx:184
#: src/pages/Playlists.tsx:118
msgid "No playlists yet."
msgstr "Pas encore de collections."
@@ -1307,7 +1315,7 @@ msgstr "Pas encore de collections."
msgid "No reply from the server — this message may not have been sent"
msgstr "Pas de réponse du serveur — ce message n'a peut-être pas été envoyé"
#: src/pages/Search.tsx:186
#: src/pages/Search.tsx:235
msgid "No users match \"{q}\"."
msgstr "Aucun utilisateur ne correspond à « {q} »."
@@ -1327,7 +1335,7 @@ msgstr "Rien de neuf ce mois-ci."
msgid "Nothing happened this week."
msgstr "Rien de neuf cette semaine."
#: src/components/profile/ProfileDumpsTab.tsx:96
#: src/components/profile/ProfileDumpsTab.tsx:97
#: src/components/profile/ProfileOverviewTab.tsx:47
#: src/pages/Notifications.tsx:522
msgid "Nothing here yet."
@@ -1361,7 +1369,7 @@ msgstr "Chaque semaine, vous recevrez un résumé de ce qui s'est passé ici, et
msgid "Only images can be dropped here."
msgstr "Seules les images peuvent être déposées ici."
#: src/pages/DumpEdit.tsx:118
#: src/pages/DumpEdit.tsx:119
msgid "Only its poster or a moderator can edit this dump."
msgstr "Seuls son auteur et les modérateurs peuvent modifier ce dump."
@@ -1432,7 +1440,7 @@ msgstr "Mot de passe mis à jour"
msgid "Passwords do not match"
msgstr "Les mots de passe ne correspondent pas"
#: src/components/dump/create/DumpCreateModal.tsx:587
#: src/components/dump/create/DumpCreateModal.tsx:580
msgid "Paste a link…"
msgstr "Collez un lien…"
@@ -1443,7 +1451,7 @@ msgstr "Collez un lien…"
msgid "Pause"
msgstr "Pause"
#: src/components/dump/create/DumpCreateModal.tsx:505
#: src/components/dump/create/DumpCreateModal.tsx:498
msgid "Picked up where you left off."
msgstr "Reprise de votre brouillon."
@@ -1455,12 +1463,18 @@ msgstr "Reprise de votre brouillon."
msgid "Play"
msgstr "Lecture"
#: src/pages/Manage.tsx:48
msgid "Playlist categories"
msgstr "Catégories de collections"
#: src/components/dump/create/StepIndicator.tsx:21
#: src/components/layout/AppHeader.tsx:172
#: src/components/layout/UserMenu.tsx:88
#: src/components/layout/UserMenu.tsx:92
#: src/components/profile/ProfileFollowTab.tsx:144
#: src/components/profile/ProfilePlaylistsTab.tsx:118
#: src/pages/Search.tsx:123
#: src/components/profile/ProfilePlaylistsTab.tsx:155
#: src/pages/Playlists.tsx:52
#: src/pages/Playlists.tsx:106
#: src/pages/Search.tsx:151
#: src/pages/UserPublicProfile.tsx:162
msgid "Playlists"
msgstr "Collections"
@@ -1475,7 +1489,7 @@ msgstr "Publier la réponse"
#: src/components/comment/CommentNode.tsx:290
#: src/components/comment/CommentThread.tsx:100
#: src/components/dump/create/DumpCreateModal.tsx:758
#: src/components/dump/create/DumpCreateModal.tsx:752
msgid "Posting…"
msgstr "Publication…"
@@ -1483,12 +1497,12 @@ msgstr "Publication…"
msgid "Previous track"
msgstr "Piste précédente"
#: src/components/dump/DumpCard.tsx:148
#: src/components/dump/DumpCard.tsx:144
#: src/components/dump/JournalCard.tsx:145
#: src/components/playlist/PlaylistCard.tsx:104
#: src/components/playlist/PlaylistHeader.tsx:76
#: src/components/playlist/PlaylistCard.tsx:106
#: src/components/playlist/PlaylistHeader.tsx:77
#: src/components/playlist/PlaylistMembershipPanel.tsx:81
#: src/pages/Dump.tsx:511
#: src/pages/Dump.tsx:507
msgid "private"
msgstr "privé"
@@ -1496,8 +1510,8 @@ msgstr "privé"
msgid "Private"
msgstr "Privé"
#: src/components/playlist/PlaylistCard.tsx:103
#: src/components/playlist/PlaylistHeader.tsx:76
#: src/components/playlist/PlaylistCard.tsx:105
#: src/components/playlist/PlaylistHeader.tsx:77
msgid "public"
msgstr "public"
@@ -1505,7 +1519,7 @@ msgstr "public"
msgid "Public"
msgstr "Public"
#: src/pages/DumpEdit.tsx:290
#: src/pages/DumpEdit.tsx:291
msgid "Refresh metadata"
msgstr "Actualiser les métadonnées"
@@ -1513,7 +1527,7 @@ msgstr "Actualiser les métadonnées"
msgid "Refreshing stream…"
msgstr "Actualisation du flux…"
#: src/pages/DumpEdit.tsx:289
#: src/pages/DumpEdit.tsx:290
msgid "Refreshing…"
msgstr "Actualisation…"
@@ -1531,7 +1545,7 @@ msgstr "Inscription…"
msgid "Registration failed"
msgstr "Inscription échouée"
#: src/pages/Dump.tsx:575
#: src/pages/Dump.tsx:559
msgid "Related"
msgstr "Connexe"
@@ -1565,7 +1579,7 @@ msgstr "Renommer"
msgid "Renaming…"
msgstr "Renommage…"
#: src/pages/DumpEdit.tsx:495
#: src/pages/DumpEdit.tsx:489
msgid "Replace file"
msgstr "Remplacer le fichier"
@@ -1596,8 +1610,8 @@ msgstr "Choisir un nouveau mot de passe"
msgid "Reset password"
msgstr "Réinitialiser le mot de passe"
#: src/pages/DumpEdit.tsx:471
#: src/pages/DumpEdit.tsx:526
#: src/pages/DumpEdit.tsx:465
#: src/pages/DumpEdit.tsx:520
msgid "Reset to default"
msgstr "Réinitialiser par défaut"
@@ -1617,8 +1631,8 @@ msgid "email.reset.title"
msgstr "Réinitialiser votre mot de passe"
#: src/components/chat/PendingItem.tsx:86
#: src/pages/Dump.tsx:319
#: src/pages/DumpEdit.tsx:225
#: src/pages/Dump.tsx:318
#: src/pages/DumpEdit.tsx:226
msgid "Retry"
msgstr "Réessayer"
@@ -1626,30 +1640,30 @@ msgstr "Réessayer"
msgid "Role"
msgstr "Rôle"
#: src/components/category/CategoryManager.tsx:216
#: src/components/category/CategoryManager.tsx:232
#: src/components/chat/ChatEditBox.tsx:85
#: src/components/comment/CommentNode.tsx:216
#: src/components/playlist/PlaylistEditForm.tsx:207
#: src/components/playlist/PlaylistEditForm.tsx:229
#: src/components/profile/DescriptionEditor.tsx:89
#: src/components/profile/EmailEditor.tsx:75
#: src/pages/Dump.tsx:437
#: src/pages/DumpEdit.tsx:568
#: src/pages/Dump.tsx:433
#: src/pages/DumpEdit.tsx:563
msgid "Save"
msgstr "Enregistrer"
#: src/components/comment/CommentNode.tsx:217
#: src/components/playlist/PlaylistEditForm.tsx:204
#: src/components/playlist/PlaylistEditForm.tsx:226
#: src/components/profile/ChangePasswordModal.tsx:119
#: src/components/profile/DescriptionEditor.tsx:86
#: src/components/profile/EmailEditor.tsx:72
#: src/pages/Dump.tsx:436
#: src/pages/Dump.tsx:432
#: src/pages/ResetPassword.tsx:139
msgid "Saving…"
msgstr "Enregistrement…"
#: src/components/layout/AppHeader.tsx:182
#: src/components/layout/SearchBar.tsx:83
#: src/pages/Search.tsx:69
#: src/pages/Search.tsx:87
msgid "Search"
msgstr "Rechercher"
@@ -1665,11 +1679,11 @@ msgstr "Rechercher un emoji"
msgid "Search emoji…"
msgstr "Rechercher un emoji…"
#: src/pages/Search.tsx:166
#: src/pages/Search.tsx:215
msgid "Search failed"
msgstr "Recherche échouée"
#: src/pages/Search.tsx:161
#: src/pages/Search.tsx:210
msgid "Searching…"
msgstr "Recherche…"
@@ -1709,7 +1723,7 @@ msgstr "Définir un nouveau mot de passe"
msgid "email.settings"
msgstr "Paramètres"
#: src/components/layout/UserMenu.tsx:104
#: src/components/layout/UserMenu.tsx:116
#: src/pages/Settings.tsx:75
#: src/pages/Settings.tsx:88
msgid "Settings"
@@ -1736,7 +1750,7 @@ msgstr "Connexion…"
msgid "Single use, valid for 7 days."
msgstr "Usage unique, valable 7 jours."
#: src/components/category/CategoryManager.tsx:205
#: src/components/category/CategoryManager.tsx:221
msgid "slug"
msgstr "identifiant"
@@ -1760,7 +1774,7 @@ msgstr "Une erreur est survenue"
msgid "Sounds"
msgstr "Sons"
#: src/components/dump/create/DumpCreateModal.tsx:512
#: src/components/dump/create/DumpCreateModal.tsx:505
msgid "Start fresh"
msgstr "Repartir de zéro"
@@ -1856,18 +1870,18 @@ msgstr "Ce lien de désabonnement est absent ou mal formé."
msgid "This week"
msgstr "Cette semaine"
#: src/pages/DumpEdit.tsx:455
#: src/pages/DumpEdit.tsx:449
msgid "Thumbnail"
msgstr "Miniature"
#: src/components/dump/create/TitleField.tsx:45
#: src/components/playlist/PlaylistCreateForm.tsx:96
#: src/components/playlist/PlaylistEditForm.tsx:160
#: src/pages/DumpEdit.tsx:516
#: src/components/playlist/PlaylistCreateForm.tsx:100
#: src/components/playlist/PlaylistEditForm.tsx:175
#: src/pages/DumpEdit.tsx:510
msgid "Title"
msgstr "Titre"
#: src/components/dump/create/DumpCreateModal.tsx:414
#: src/components/dump/create/DumpCreateModal.tsx:407
msgid "Title is required."
msgstr "Un titre est requis."
@@ -1967,7 +1981,7 @@ msgid "Upload failed"
msgstr "Envoi échoué"
#. placeholder {0}: Math.round((uploadProgress ?? 0) * 100)
#: src/components/dump/create/DumpCreateModal.tsx:449
#: src/components/dump/create/DumpCreateModal.tsx:442
msgid "Uploading {0}%"
msgstr "Envoi {0} %"
@@ -1975,7 +1989,7 @@ msgstr "Envoi {0} %"
msgid "Uploading image…"
msgstr "Envoi de l'image…"
#: src/components/dump/create/DumpCreateModal.tsx:761
#: src/components/dump/create/DumpCreateModal.tsx:755
msgid "Uploading…"
msgstr "Envoi…"
@@ -1993,15 +2007,15 @@ msgstr "Validé"
msgid "Upvoted ({0})"
msgstr "Validé ({0})"
#: src/pages/DumpEdit.tsx:487
#: src/pages/DumpEdit.tsx:481
msgid "URL"
msgstr "URL"
#: src/components/dump/create/DumpCreateModal.tsx:398
#: src/components/dump/create/DumpCreateModal.tsx:391
msgid "URL is required."
msgstr "L'URL est obligatoire."
#: src/components/category/CategoryManager.tsx:206
#: src/components/category/CategoryManager.tsx:222
msgid "URL slug"
msgstr "Identifiant d’URL"
@@ -2013,7 +2027,7 @@ msgstr "Utilisateur"
msgid "User management"
msgstr "Gestion utilisateur"
#: src/components/layout/UserMenu.tsx:63
#: src/components/layout/UserMenu.tsx:67
msgid "User menu"
msgstr "Menu utilisateur"
@@ -2026,7 +2040,7 @@ msgid "Username"
msgstr "Nom d'utilisateur"
#: src/components/profile/ProfileFollowTab.tsx:143
#: src/pages/Search.tsx:122
#: src/pages/Search.tsx:150
msgid "Users"
msgstr "Utilisateurs"
@@ -2046,7 +2060,7 @@ msgstr "Les utilisateurs d'une instance approuvée se connectent ici avec leur c
msgid "View all →"
msgstr "Tout voir →"
#: src/components/dump/create/DumpCreateModal.tsx:526
#: src/components/dump/create/DumpCreateModal.tsx:519
msgid "View dump →"
msgstr "Voir la reco →"
@@ -2064,8 +2078,8 @@ msgstr "Résumé hebdomadaire"
msgid "email.welcome.subject"
msgstr "Bienvenue sur {site}"
#: src/components/dump/create/DumpCreateModal.tsx:712
#: src/pages/DumpEdit.tsx:544
#: src/components/dump/create/DumpCreateModal.tsx:705
#: src/pages/DumpEdit.tsx:538
msgid "What makes it worth it?"
msgstr "Pourquoi on en voudrait ?"
@@ -2074,7 +2088,7 @@ msgstr "Pourquoi on en voudrait ?"
msgid "Who am I?"
msgstr "Qui suis-je ?"
#: src/components/dump/create/DumpCreateModal.tsx:727
#: src/components/dump/create/DumpCreateModal.tsx:721
msgid "Who can see it"
msgstr "Qui peut la voir"
@@ -2082,8 +2096,8 @@ msgstr "Qui peut la voir"
msgid "Why & where"
msgstr "Pourquoi & où"
#: src/components/dump/create/DumpCreateModal.tsx:711
#: src/pages/DumpEdit.tsx:543
#: src/components/dump/create/DumpCreateModal.tsx:704
#: src/pages/DumpEdit.tsx:537
msgid "Why?"
msgstr "Pourquoi ?"

View File

@@ -1,18 +1,40 @@
/**
* Categories file dumps by topic, and give each topic its own feed on the
* index. This module types them for the server and the client, with the bodies
* that create and change them. Only admins manage categories, through the API,
* since no page does it. The client lists them in its `CategoriesProvider`.
* Categories file dumps and playlists by topic, in two sets apart, one of each
* {@link CategoryKind}, and give each topic its own page. This module types
* them for the server and the client, with the bodies that create and change
* them. Those who hold `category:manage` manage both sets on `/manage`. The
* client lists them in its `CategoriesProvider`.
*
* @module
*/
/**
* Returned by the category routes. A category's feed lives at `/<slug>` on the
* client, and a dump names its categories by `id` in `categoryIds`.
* Lists what a category files: a dump category files dumps, a playlist
* category playlists. Each kind is a set of its own, whose slugs and names are
* unique within it.
*/
export const CATEGORY_KINDS = ["dump", "playlist"] as const;
/** One of the {@link CATEGORY_KINDS}. */
export type CategoryKind = typeof CATEGORY_KINDS[number];
/**
* Checks whether `value` is one of the {@link CATEGORY_KINDS}, for the
* category validator and the rows the category service reads.
*/
export function isCategoryKind(value: unknown): value is CategoryKind {
return (CATEGORY_KINDS as readonly unknown[]).includes(value);
}
/**
* Returned by the category routes. A dump category's feed lives at `/<slug>`
* on the client, a playlist category's at `/playlists/<slug>`, and a dump or
* playlist names its categories by `id` in `categoryIds`.
*/
export interface Category {
id: string;
/** What it files. It never changes. */
kind: CategoryKind;
slug: string;
name: string;
/** Sorts the categories, lowest first. */
@@ -22,10 +44,11 @@ export interface Category {
}
/**
* Passed to `POST /api/categories`. The slug must be free and not reserved by
* a route.
* Passed to `POST /api/categories`. The slug must be free within its kind, and
* a dump category's not reserved by a route.
*/
export interface CreateCategoryRequest {
kind: CategoryKind;
slug: string;
name: string;
/** 0 when left out. */
@@ -33,7 +56,8 @@ export interface CreateCategoryRequest {
}
/**
* Passed to `PUT /api/categories/:id`. A field left out keeps its value.
* Passed to `PUT /api/categories/:id`. A field left out keeps its value, and
* the kind is kept.
*/
export interface UpdateCategoryRequest {
slug?: string;

View File

@@ -2,7 +2,8 @@
* Builds and parses the site's page paths, by the one rule they all follow:
* the path is a hierarchy, `@name` is a user (`@name@host` one whose account
* lives on another instance), and any other word under the
* root is a category or an app page. Under a user each segment is a fixed tab
* root is a dump category or an app page, `/playlists` holding the playlist
* categories. Under a user each segment is a fixed tab
* name, and under `dumps` or `playlists` a slug. A view of a page is one more
* segment, and a default view is the bare path.
*
@@ -16,6 +17,7 @@
* @module
*/
import type { CategoryKind } from "./category.ts";
import { DIGEST_CADENCES, type DigestCadence } from "./settings.ts";
import { HANDLE_RE, splitHandle } from "./validation.ts";
@@ -117,6 +119,23 @@ export function feedPath(categorySlug?: string, tab?: FeedTab): string {
return tab ? `${base}/${tab}` : base || "/";
}
/**
* Returns the path of the page of every public playlist, `/playlists`, or of a
* playlist category's at `/playlists/<slug>`.
*/
export function playlistsPath(categorySlug?: string): string {
return categorySlug ? `/playlists/${seg(categorySlug)}` : "/playlists";
}
/**
* Returns the path of the page of the category of `kind` at `slug`: a dump
* category's feed ({@link feedPath}), or a playlist category's playlists
* ({@link playlistsPath}). The category chips link to it.
*/
export function categoryPath(kind: CategoryKind, slug: string): string {
return kind === "dump" ? feedPath(slug) : playlistsPath(slug);
}
/**
* Returns the path of the search page on `tab`, searching `query` when it is
* given.

View File

@@ -46,6 +46,11 @@ export interface Playlist {
dumpCount?: number;
/** The owner's current name, which the playlist's path starts with. */
ownerUsername: string;
/**
* The ids of the playlist categories it is filed under. The services fill
* them in on every playlist they return.
*/
categoryIds?: string[];
}
/**
@@ -72,6 +77,8 @@ export interface CreatePlaylistRequest {
title: string;
description?: string;
isPublic: boolean;
/** The playlist categories to file it under, none when left out. */
categoryIds?: string[];
}
/**
@@ -88,6 +95,8 @@ export interface UpdatePlaylistRequest {
/** `null` removes the description. */
description?: string | null;
isPublic?: boolean;
/** Replaces its playlist categories; `[]` takes it out of every one. */
categoryIds?: string[];
}
/**

View File

@@ -2237,11 +2237,11 @@ body.has-player .chat-fab {
font-size: var(--text-2xs);
}
.profile-columns .dump-card-categories {
.profile-columns .category-links {
gap: 0.25rem;
}
.profile-columns .dump-card-category-chip {
.profile-columns .category-link {
padding: 0.05rem 0.4rem;
font-size: var(--text-2xs);
}
@@ -2761,9 +2761,11 @@ body.has-player .chat-fab {
padding: 1rem;
}
/* A list of dump or playlist cards in a group fills the group's column, its
edges in line with the label's rule. */
.page-group .dump-feed {
/* A list of dump or playlist cards in a group, or right under a page's header
as on /playlists, fills the column, its edges in line with the label's rule
or the header. */
.page-group .dump-feed,
.page-content--narrow > .dump-feed {
padding: 0;
max-width: none;
}
@@ -7249,6 +7251,21 @@ body.has-player .chat-fab {
box-sizing: border-box;
}
/* The category menu of the dumps and playlists tabs, at the end of the
results' column, as at the end of a profile list's title row. */
.search-filter {
display: flex;
justify-content: flex-end;
width: 100%;
max-width: 860px;
padding: 0.75rem 1.25rem 0;
box-sizing: border-box;
}
.search-filter:empty {
display: none;
}
.search-page-empty {
padding: 3rem 1.25rem;
color: var(--color-text-secondary);
@@ -7532,15 +7549,15 @@ body.has-player .chat-fab {
color: var(--color-on-accent);
}
/* Read-only category chips on dump cards */
.dump-card-categories {
/* Read-only category chips on dump and playlist cards and pages */
.category-links {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-top: 0.35rem;
}
.dump-card-category-chip {
.category-link {
padding: 0.1rem 0.5rem;
border-radius: var(--radius-full);
background: var(--color-header-user-bg);
@@ -7549,7 +7566,7 @@ body.has-player .chat-fab {
font-size: var(--text-xs);
}
.dump-card-category-chip:hover {
.category-link:hover {
background: var(--color-accent);
color: var(--color-on-accent);
}

View File

@@ -21,11 +21,13 @@
* `federation:manage` (`Manage`).
* - `/community` and `/community/monthly`, the site's week and month
* (`Community`), signed in only.
* - `/playlists` is every public playlist, and `/playlists/:categorySlug` a
* playlist category's (`Playlists`).
* - `/@:username/*` is a user's (`UserRoutes`): their profile and its tabs,
* their dumps at `dumps/:slug` (`Dump`) and `dumps/:slug/edit` (`DumpEdit`,
* signed in only), and their playlists at `playlists/:slug`
* (`PlaylistDetail`).
* - `/:categorySlug` and `/:categorySlug/:feedTab` are a category's feed
* - `/:categorySlug` and `/:categorySlug/:feedTab` are a dump category's feed
* (`Index`).
* - Any other path is `NotFound`.
*
@@ -68,6 +70,9 @@ const Index = lazy(() =>
const Dump = lazy(() =>
import("./pages/Dump.tsx").then((m) => ({ default: m.Dump }))
);
const Playlists = lazy(() =>
import("./pages/Playlists.tsx").then((m) => ({ default: m.Playlists }))
);
const DumpEdit = lazy(() =>
import("./pages/DumpEdit.tsx").then((m) => ({ default: m.DumpEdit }))
);
@@ -240,6 +245,9 @@ function AppRoutes() {
}
/>
))}
{["/playlists", "/playlists/:categorySlug"].map((path) => (
<Route key={path} path={path} element={<Playlists />} />
))}
<Route
path="/settings"
element={

View File

@@ -0,0 +1,62 @@
/**
* Renders the categories a dump or playlist is filed under, on its card and
* its page.
*
* @module
*/
import { Link } from "react-router";
import type { CategoryKind } from "../../../shared/category.ts";
import { categoryPath } from "../../../shared/paths.ts";
import { useCategories } from "../../hooks/category/useCategories.ts";
/**
* Passed to {@link CategoryChips} by `DumpCard` and `Dump` for a dump, and by
* `PlaylistCard` and `PlaylistHeader` for a playlist.
*/
export interface CategoryChipsProps {
/** Whether `ids` name dump or playlist categories. */
kind: CategoryKind;
/** The dump's or playlist's `categoryIds`. */
ids?: string[];
/** Added to the row's own class, for a card's grid to place it. */
className?: string;
/**
* Called on a click in the row. A clickable card stops it there, or a
* chip's click would open the card too.
*/
onClick?: (e: React.MouseEvent) => void;
}
/**
* Renders each category `ids` names as a chip linking to its page, a dump
* category's feed or a playlist category's playlists, in the categories'
* order. Ids of categories since deleted are skipped, and it renders nothing
* when none is left.
*/
export function CategoryChips(
{ kind, ids, className, onClick }: CategoryChipsProps,
): React.JSX.Element | null {
const { categories } = useCategories(kind);
if (!ids?.length) return null;
const filed = new Set(ids);
const shown = categories.filter((c) => filed.has(c.id));
if (shown.length === 0) return null;
return (
<div
className={`category-links${className ? ` ${className}` : ""}`}
onClick={onClick}
>
{shown.map((category) => (
<Link
key={category.id}
to={categoryPath(kind, category.slug)}
className="category-link"
>
{category.name}
</Link>
))}
</div>
);
}

View File

@@ -1,30 +1,35 @@
/**
* Renders the category picker of the dump creation dialog and the dump editor.
* Renders the category picker of the dump creation dialog and the dump editor,
* and of the playlist forms.
*
* @module
*/
import { Trans } from "@lingui/react/macro";
import { useCategories } from "../../hooks/category/useCategories.ts";
import type { CategoryKind } from "../../../shared/category.ts";
/**
* Passed to {@link CategorySelect} by `DumpCreateModal` and `DumpEdit`, which
* hold the selection and send it as the dump's `categoryIds`.
* Passed to {@link CategorySelect} by `DumpCreateModal` and `DumpEdit`, and by
* `PlaylistCreateForm` and `PlaylistEditForm`, which hold the selection and
* send it as the dump's or playlist's `categoryIds`.
*/
export interface CategorySelectProps {
/** The set it offers: dump categories for a dump, playlist ones for a playlist. */
kind: CategoryKind;
selected: Set<string>;
onToggle: (id: string) => void;
disabled?: boolean;
}
/**
* Renders the categories as toggle chips, any number of which may be on. It
* renders nothing while the site has no categories.
* Renders the categories of `kind` as toggle chips, any number of which may be
* on. It renders nothing while the site has none.
*/
export function CategorySelect(
{ selected, onToggle, disabled }: CategorySelectProps,
{ kind, selected, onToggle, disabled }: CategorySelectProps,
): React.JSX.Element | null {
const { categories } = useCategories();
const { categories } = useCategories(kind);
if (categories.length === 0) return null;
return (

View File

@@ -1,6 +1,6 @@
/**
* Renders the menu that narrows a list of dumps to a category, on the index
* page and on a profile's lists.
* Renders the menu that narrows a list of dumps or playlists to a category, on
* the index and playlists pages, on a profile's lists and on the search page.
*
* @module
*/
@@ -15,13 +15,17 @@ import {
import { createPortal } from "react-dom";
import { t } from "@lingui/core/macro";
import { useCategories } from "../../hooks/category/useCategories.ts";
import type { CategoryKind } from "../../../shared/category.ts";
/**
* Passed to {@link CategorySwitcher} by `FeedTabBar` and `ProfileDumpsTab`,
* which keep the category in their URL.
* Passed to {@link CategorySwitcher} by `FeedTabBar`, `ProfileDumpsTab`,
* `ProfilePlaylistsTab`, `Playlists` and `Search`, which keep the category in
* their URL.
*/
export interface CategorySwitcherProps {
/** The category shown, or none for every dump. */
/** The set it offers: dump categories for dumps, playlist ones for playlists. */
kind: CategoryKind;
/** The category shown, or none for every dump or playlist. */
categorySlug?: string;
/** Called with the slug of the category picked, or undefined for "All". */
onSelect: (slug: string | undefined) => void;
@@ -29,13 +33,13 @@ export interface CategorySwitcherProps {
/**
* Renders a button naming the category shown, "All" without one, that opens
* the menu of every category. It renders nothing while the site has no
* categories.
* the menu of every category of `kind`. It renders nothing while the site has
* none.
*/
export function CategorySwitcher(
{ categorySlug, onSelect }: CategorySwitcherProps,
{ kind, categorySlug, onSelect }: CategorySwitcherProps,
): React.JSX.Element | null {
const { categories } = useCategories();
const { categories } = useCategories(kind);
const [open, setOpen] = useState(false);
const [menuStyle, setMenuStyle] = useState<CSSProperties>({});
@@ -93,7 +97,7 @@ export function CategorySwitcher(
if (categories.length === 0) return null;
// "" stands for every dump, which no category slug can be.
// "" stands for every dump or playlist, which no category slug can be.
const value = categorySlug ?? "";
const currentLabel = categorySlug
? categories.find((c) => c.slug === categorySlug)?.name ?? categorySlug

View File

@@ -12,7 +12,7 @@ import type { Dump } from "../../../shared/dump.ts";
import { relativeTime } from "../../utils/shared/relativeTime.ts";
import { dumpThumbnailUrl, dumpUrl } from "../../utils/shared/urls.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useCategories } from "../../hooks/category/useCategories.ts";
import { CategoryChips } from "../category/CategoryChips.tsx";
import { useDumpCardLink } from "../../hooks/dump/useDumpCardLink.ts";
import {
isDumpVisited,
@@ -57,10 +57,6 @@ export const DumpCard: React.MemoExoticComponent<
) {
const { cardProps, handledAsNewTab } = useDumpCardLink(dump);
const { mediaToken } = useAuth();
const { byId } = useCategories();
const dumpCategories = (dump.categoryIds ?? [])
.map((id) => byId.get(id))
.filter((c) => c !== undefined);
// Posted in the last 7 days and never opened on this device.
const unread = !isOwner && isRecent(dump.createdAt) &&
!isDumpVisited(dump.id);
@@ -149,22 +145,12 @@ export const DumpCard: React.MemoExoticComponent<
</span>
)}
</div>
{dumpCategories.length > 0 && (
<div
className="dump-card-categories"
onClick={(e) => e.stopPropagation()}
>
{dumpCategories.map((category) => (
<Link
key={category.id}
to={`/${category.slug}`}
className="dump-card-category-chip"
>
{category.name}
</Link>
))}
</div>
)}
<CategoryChips
kind="dump"
ids={dump.categoryIds}
className="dump-card-categories"
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>
</li>

View File

@@ -28,6 +28,7 @@ import {
deserializeDumpUrlMatch,
} from "../../../utils/shared/deserialize.ts";
import { useAuth } from "../../../hooks/auth/useAuth.ts";
import { useCategorySelection } from "../../../hooks/category/useCategorySelection.ts";
import { useWS } from "../../../hooks/ws/useWS.ts";
import { usePlaylistMemberships } from "../../../hooks/playlist/usePlaylistMemberships.ts";
import { dumpUrl, normalizeUrl } from "../../../utils/shared/urls.ts";
@@ -172,16 +173,8 @@ export function DumpCreateModal(
// link dump, and clearing either offers both again.
const mode: "url" | "file" = file ? "file" : "url";
const [selectedCategoryIds, setSelectedCategoryIds] = useState<Set<string>>(
() => new Set(draft.categoryIds),
);
const toggleCategory = (id: string) =>
setSelectedCategoryIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const [selectedCategoryIds, toggleCategory, setSelectedCategoryIds] =
useCategorySelection(draft.categoryIds);
const selectFile = useCallback((f: File | null) => {
setValue("file", f);
@@ -717,6 +710,7 @@ export function DumpCreateModal(
/>
<CategorySelect
kind="dump"
selected={selectedCategoryIds}
onToggle={toggleCategory}
disabled={submitting}

View File

@@ -53,6 +53,7 @@ export function FeedTabBar(
navigate(feedPath(categorySlug, t === defaultTab ? undefined : t))}
leading={
<CategorySwitcher
kind="dump"
categorySlug={categorySlug}
onSelect={(slug) =>
navigate(feedPath(slug, tab === defaultTab ? undefined : tab))}

View File

@@ -1,18 +1,24 @@
/**
* Renders the title row of the pages the user menu and the bell lead to:
* settings, manage and notifications.
* settings, manage, notifications and every playlist.
*
* @module
*/
import type { ReactNode } from "react";
/** Passed to {@link PageHeader} by `Settings`, `Manage` and `Notifications`. */
/**
* Passed to {@link PageHeader} by `Settings`, `Manage`, `Notifications` and
* `Playlists`.
*/
export interface PageHeaderProps {
/** An emoji before the title, hidden from screen readers. */
icon: string;
title: ReactNode;
/** It goes at the end of the row, such as the notifications' unread count. */
/**
* It goes at the end of the row, such as the notifications' unread count or
* the playlists' category menu.
*/
children?: ReactNode;
}

View File

@@ -15,7 +15,11 @@ import { DIGEST_MESSAGES } from "../../../shared/digest-messages.ts";
import { Avatar } from "../shared/Avatar.tsx";
import type { SelfUser } from "../../../shared/user.ts";
import { can } from "../../utils/auth/permissions.ts";
import { communityPath, userPath } from "../../../shared/paths.ts";
import {
communityPath,
playlistsPath,
userPath,
} from "../../../shared/paths.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
/** Passed to {@link UserMenu} by `AppHeader`, for a signed-in user. */
@@ -95,6 +99,14 @@ export function UserMenu({ user }: UserMenuProps): React.JSX.Element {
>
{i18n._(DIGEST_MESSAGES.community)}
</Link>
<Link
to={playlistsPath()}
className="user-menu-item"
role="menuitem"
onClick={() => setOpen(false)}
>
<Trans>All playlists</Trans>
</Link>
<Link
to="/settings"
className="user-menu-item"

View File

@@ -1,6 +1,6 @@
/**
* Renders the card that shows a playlist in profiles, search results and the
* dump page's "In collections" section.
* Renders the card that shows a playlist in profiles, search results, the
* playlists page and the dump page's "In collections" section.
*
* @module
*/
@@ -18,6 +18,7 @@ import {
markPlaylistVisited,
} from "../../utils/shared/visited.ts";
import { Tooltip } from "../shared/Tooltip.tsx";
import { CategoryChips } from "../category/CategoryChips.tsx";
import { userPath } from "../../../shared/paths.ts";
/** Passed to {@link PlaylistCard} by each list of playlists. */
@@ -39,9 +40,10 @@ export interface PlaylistCardProps {
}
/**
* Renders a playlist as a card that opens the playlist when clicked. Another
* user's recent playlist shows an unread dot until it is opened on this
* device. The card is an `<li>`, for a `<ul>`.
* Renders a playlist as a card that opens the playlist when clicked, with the
* playlist categories it is filed under. Another user's recent playlist shows
* an unread dot until it is opened on this device. The card is an `<li>`, for
* a `<ul>`.
*/
export function PlaylistCard(
{ playlist, onDelete, isOwner, privateBadgeOnly }: PlaylistCardProps,
@@ -128,6 +130,11 @@ export function PlaylistCard(
</time>
</Tooltip>
</div>
<CategoryChips
kind="playlist"
ids={playlist.categoryIds}
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>
{onDelete && (

View File

@@ -1,6 +1,6 @@
/**
* Renders the form that creates a playlist, in NewPlaylistButton's modal and in
* PlaylistMembershipPanel.
* PlaylistMembershipPanel, with the playlist categories to file it under.
*
* @module
*/
@@ -25,6 +25,8 @@ import { SubmitButton } from "../form/SubmitButton.tsx";
import { TextField } from "../form/TextField.tsx";
import { useApiForm } from "../../hooks/form/useApiForm.ts";
import { VisibilityToggle } from "../form/SegmentedField.tsx";
import { CategorySelect } from "../category/CategorySelect.tsx";
import { useCategorySelection } from "../../hooks/category/useCategorySelection.ts";
/**
* Passed to {@link PlaylistCreateForm} by NewPlaylistButton and
@@ -57,6 +59,7 @@ export function PlaylistCreateForm(
const form = useApiForm<Values>({
defaultValues: { title: "", description: "", isPublic: true },
});
const [categoryIds, toggleCategory] = useCategorySelection();
const onSubmit = form.submit(async ({ title, description, isPublic }) => {
const playlist = deserializePlaylist(
@@ -69,6 +72,7 @@ export function PlaylistCreateForm(
title: title.trim(),
description: description.trim() || undefined,
isPublic,
categoryIds: [...categoryIds],
} satisfies CreatePlaylistRequest,
),
}),
@@ -106,6 +110,12 @@ export function PlaylistCreateForm(
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
/>
<VisibilityToggle<Values> name="isPublic" />
<CategorySelect
kind="playlist"
selected={categoryIds}
onToggle={toggleCategory}
disabled={form.formState.isSubmitting}
/>
<FormError title={t`Failed to create playlist`} />
<FormActions onCancel={onCancel}>
<SubmitButton pendingLabel={<Trans>Creating…</Trans>}>

View File

@@ -1,6 +1,6 @@
/**
* Renders the form that edits a playlist's details and cover, in place of the
* header of its page.
* Renders the form that edits a playlist's details, categories and cover, in
* place of the header of its page.
*
* @module
*/
@@ -31,6 +31,15 @@ import { FormError } from "../form/FormError.tsx";
import { SubmitButton } from "../form/SubmitButton.tsx";
import { SlugField } from "../form/SlugField.tsx";
import { userPath } from "../../../shared/paths.ts";
import { CategorySelect } from "../category/CategorySelect.tsx";
import { useCategorySelection } from "../../hooks/category/useCategorySelection.ts";
/** Checks whether `picked` holds exactly the ids of `saved`. */
function sameIds(picked: Set<string>, saved: string[] = []): boolean {
const savedSet = new Set(saved);
return picked.size === savedSet.size &&
[...picked].every((id) => savedSet.has(id));
}
/** Passed to {@link PlaylistEditForm} by `PlaylistDetail`. */
export interface PlaylistEditFormProps {
@@ -53,8 +62,8 @@ interface EditValues {
/**
* Renders the form that edits the playlist, in place of the page's header. It
* saves the details with `PUT /api/playlists/:playlistId`, sending only the
* title, slug and description that changed, then a new cover, if one was
* picked, with `PUT /api/playlists/:playlistId/cover`.
* title, slug, description and categories that changed, then a new cover, if
* one was picked, with `PUT /api/playlists/:playlistId/cover`.
*/
export function PlaylistEditForm(
{ playlist, onCancel, onRequestDelete, onSaved }: PlaylistEditFormProps,
@@ -77,6 +86,9 @@ export function PlaylistEditForm(
},
});
const title = form.watch("title");
const [categoryIds, toggleCategory] = useCategorySelection(
playlist.categoryIds,
);
const onSubmit = form.submit(
async ({ title, slug, description, isPublic, image }) => {
@@ -92,6 +104,9 @@ export function PlaylistEditForm(
...(description !== (playlist.description ?? "")
? { description: description || null }
: {}),
...(!sameIds(categoryIds, playlist.categoryIds)
? { categoryIds: [...categoryIds] }
: {}),
isPublic,
} satisfies UpdatePlaylistRequest,
),
@@ -182,6 +197,13 @@ export function PlaylistEditForm(
<VisibilityToggle<EditValues> name="isPublic" />
<CategorySelect
kind="playlist"
selected={categoryIds}
onToggle={toggleCategory}
disabled={form.formState.isSubmitting}
/>
<FormError title={t`Failed to save`} />
</div>

View File

@@ -1,6 +1,6 @@
/**
* Renders the header of a playlist's page, with its cover, title, description,
* visibility, owner and dates.
* visibility, owner, dates and categories.
*
* @module
*/
@@ -15,6 +15,7 @@ import { useAuth } from "../../hooks/auth/useAuth.ts";
import { Markdown } from "../editor/Markdown.tsx";
import { FollowPlaylistButton } from "../social/FollowButton.tsx";
import { Tooltip } from "../shared/Tooltip.tsx";
import { CategoryChips } from "../category/CategoryChips.tsx";
import { userPath } from "../../../shared/paths.ts";
/** Passed to {@link PlaylistHeader} by `PlaylistDetail`. */
@@ -96,6 +97,8 @@ export function PlaylistHeader(
</Tooltip>
)}
</div>
<CategoryChips kind="playlist" ids={playlist.categoryIds} />
</div>
</>
);

View File

@@ -47,6 +47,7 @@ export function ProfileDumpsTab(
actions={
<div className="profile-section-actions">
<CategorySwitcher
kind="dump"
categorySlug={categorySlug}
onSelect={(slug) =>
setParams((p) => {

View File

@@ -1,11 +1,12 @@
/**
* Renders a profile's "playlists" tab, the playlists the user made. Those they
* follow are the "follow" tab's.
* Renders a profile's "playlists" tab, the playlists the user made, which a
* playlist category may narrow. Those they follow are the "follow" tab's.
*
* @module
*/
import { useCallback, useState } from "react";
import { useSearchParams } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../../config/api.ts";
@@ -33,6 +34,9 @@ import {
useScrollSave,
} from "../../hooks/feed/useScrollSave.ts";
import { usePlaylistListSync } from "../../hooks/playlist/usePlaylistListSync.ts";
import { useCategories } from "../../hooks/category/useCategories.ts";
import { useCategoryFilter } from "../../hooks/category/useCategoryFilter.ts";
import { CategorySwitcher } from "../category/CategorySwitcher.tsx";
import { NewPlaylistButton } from "../playlist/NewPlaylistButton.tsx";
import { PlaylistCardList } from "../playlist/PlaylistCardList.tsx";
import { FeedFooter } from "../feed/FeedFooter.tsx";
@@ -48,22 +52,51 @@ export interface ProfilePlaylistsTabProps {
}
/**
* 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 socket keeps them live, and going back restores the list and the
* scroll. The owner can make one, which goes first, and delete one.
* Renders the tab's list for the playlist category the URL's `?category=`
* picks, or every playlist without one. Each category's list is another,
* which the tab mounts afresh.
*/
export function ProfilePlaylistsTab(
{ user, isOwnProfile }: ProfilePlaylistsTabProps,
props: ProfilePlaylistsTabProps,
): React.JSX.Element {
const [params] = useSearchParams();
const categorySlug = params.get("category") || undefined;
return (
<PlaylistList
key={categorySlug ?? ""}
{...props}
categorySlug={categorySlug}
/>
);
}
/**
* Renders the playlists `user` made, from `GET /api/users/:userId/playlists`,
* the private ones to their owner only, a page at a time as the list scrolls;
* with `categorySlug`, only those in that playlist category, which the menu in
* the title row switches. The socket keeps them live, and going back restores
* the list and the scroll. The owner can make one, which goes first, and
* delete one.
*/
function PlaylistList(
{ user, isOwnProfile, categorySlug }: ProfilePlaylistsTabProps & {
categorySlug?: string;
},
): React.JSX.Element {
const [, setParams] = useSearchParams();
const { user: me, authFetch } = useAuth();
const { lastPlaylistEvent } = useWS();
const { categories } = useCategories("playlist");
const { cached, saveState } = useFeedCache<Playlist>(
`feed:profile-playlists:${user.id}`,
`feed:profile-playlists:${user.id}${
categorySlug ? `:${categorySlug}` : ""
}`,
hydratePlaylist,
);
const url = `${API_URL}/api/users/${user.id}/playlists`;
const { state, setItems, loadMore } = usePagedList<Playlist>(
const url = `${API_URL}/api/users/${user.id}/playlists${
categorySlug ? `?category=${encodeURIComponent(categorySlug)}` : ""
}`;
const { state, setItems: setAllItems, loadMore } = usePagedList<Playlist>(
`${url}:${me?.id ?? "guest"}`,
useCallback(
(page: number, signal?: AbortSignal) =>
@@ -74,6 +107,10 @@ export function ProfilePlaylistsTab(
cached,
);
const loaded = state.status === "loaded" ? state.data : null;
// A slug the categories don't know yet lets everything through.
const categoryId = categorySlug &&
categories.find((c) => c.slug === categorySlug)?.id;
const setItems = useCategoryFilter(setAllItems, categoryId || undefined);
const lastPlaylistItem = lastPlaylistEvent?.type === "updated"
? (lastPlaylistEvent.playlist ?? null)
@@ -116,11 +153,25 @@ export function ProfilePlaylistsTab(
return (
<ProfileSection
title={<Trans>Playlists</Trans>}
actions={isOwnProfile && (
<NewPlaylistButton
onCreated={(p) => setItems((items) => withFirst(items, p))}
/>
)}
actions={
<div className="profile-section-actions">
<CategorySwitcher
kind="playlist"
categorySlug={categorySlug}
onSelect={(slug) =>
setParams((p) => {
if (slug) p.set("category", slug);
else p.delete("category");
return p;
})}
/>
{isOwnProfile && (
<NewPlaylistButton
onCreated={(p) => setItems((items) => withFirst(items, p))}
/>
)}
</div>
}
>
{deleteError && (
<ErrorCard title={t`Could not delete`} message={deleteError} />

View File

@@ -1,6 +1,7 @@
/**
* Shares the site's categories with every component. `CategoriesProvider` fills
* this context and `useCategories` reads it.
* Shares the site's categories, of both kinds, with every component.
* `CategoriesProvider` fills this context and `useCategories` reads one kind's
* from it.
*
* @module
*/
@@ -10,13 +11,15 @@ import type { Category } from "../../../shared/category.ts";
/**
* Provided by `CategoriesProvider` to `useCategories`, for the category
* switcher and picker, the dump pages and cards, and the category manager.
* switchers and pickers, the dump and playlist pages and cards, and the
* category manager.
*/
export interface CategoriesContextValue {
/** Every category, in the server's order, by position then slug. */
/**
* Every category, dump and playlist ones, in the server's order, by kind,
* position then slug.
*/
categories: Category[];
/** Resolves a dump's `categoryIds` to its category chips. */
byId: Map<string, Category>;
/**
* Whether the first fetch has ended. It is also true after a network error,
* with no categories.
@@ -35,7 +38,6 @@ export const CategoriesContext: React.Context<CategoriesContextValue> =
CategoriesContextValue
>({
categories: [],
byId: new Map(),
isLoaded: false,
refresh: async () => {},
});

View File

@@ -1,6 +1,7 @@
/**
* Spares every page fetching the site's categories: it loads them once for the
* whole app, and again when the category manager changes them. `App` mounts it.
* Spares every page fetching the site's categories: it loads both kinds once
* for the whole app, and again when the category manager changes them. `App`
* mounts it.
*
* @module
*/
@@ -26,7 +27,7 @@ import { expectOk } from "../../utils/shared/api.ts";
const RETRY_MS = 10_000;
/**
* Returns every category.
* Returns every category, of both kinds.
*
* @throws {Error} When the request fails or the API answers a failure.
*/
@@ -81,14 +82,9 @@ export function CategoriesProvider(
const refresh = useCallback(() => load().catch(() => {}), [load]);
const byId = useMemo(
() => new Map(categories.map((c) => [c.id, c])),
[categories],
);
const value: CategoriesContextValue = useMemo(
() => ({ categories, byId, isLoaded, refresh }),
[categories, byId, isLoaded, refresh],
() => ({ categories, isLoaded, refresh }),
[categories, isLoaded, refresh],
);
return (

View File

@@ -1,20 +1,45 @@
/**
* Gives components the categories that `CategoriesProvider` loads once for the
* whole app.
* Gives components the categories of one kind, of those `CategoriesProvider`
* loads once for the whole app.
*
* @module
*/
import { useContext } from "react";
import {
CategoriesContext,
type CategoriesContextValue,
} from "../../contexts/category/CategoriesContext.ts";
import { useContext, useMemo } from "react";
import { CategoriesContext } from "../../contexts/category/CategoriesContext.ts";
import type { Category, CategoryKind } from "../../../shared/category.ts";
/** Returned by {@link useCategories}: one kind's categories. */
export interface KindCategories {
/** The categories of the kind, by position then slug. */
categories: Category[];
/** Resolves a dump's or playlist's `categoryIds` to its category chips. */
byId: Map<string, Category>;
/**
* Whether the first fetch has ended. It is also true after a network error,
* with no categories.
*/
isLoaded: boolean;
/** Refetches both kinds. The category manager calls it after each change. */
refresh: () => Promise<void>;
}
/**
* Returns every category and a function that refetches them. Under no
* `CategoriesProvider`, the list is empty.
* Returns the categories of `kind`, dump or playlist, and a function that
* refetches them. Under no `CategoriesProvider`, the list is empty.
*/
export function useCategories(): CategoriesContextValue {
return useContext(CategoriesContext);
export function useCategories(kind: CategoryKind): KindCategories {
const { categories: all, isLoaded, refresh } = useContext(CategoriesContext);
const categories = useMemo(
() => all.filter((c) => c.kind === kind),
[all, kind],
);
const byId = useMemo(
() => new Map(categories.map((c) => [c.id, c])),
[categories],
);
return useMemo(
() => ({ categories, byId, isLoaded, refresh }),
[categories, byId, isLoaded, refresh],
);
}

View File

@@ -0,0 +1,30 @@
/**
* Keeps a list narrowed to one category, whatever the socket puts in it.
*
* @module
*/
import { useCallback } from "react";
/**
* Returns `setItems` wrapped so that every list it sets keeps only the items
* filed under `categoryId`, or `setItems` as it is without one. A list of one
* category's dumps or playlists passes it to the hooks that apply the
* socket's messages, so that one created outside the category isn't added,
* and one edited out of it leaves.
*/
export function useCategoryFilter<T extends { categoryIds?: string[] }>(
setItems: (fn: (prev: T[]) => T[]) => void,
categoryId?: string,
): (fn: (prev: T[]) => T[]) => void {
return useCallback(
(fn: (prev: T[]) => T[]) =>
setItems((prev) => {
const next = fn(prev);
return categoryId
? next.filter((item) => item.categoryIds?.includes(categoryId))
: next;
}),
[setItems, categoryId],
);
}

View File

@@ -0,0 +1,26 @@
/**
* Holds the categories picked in a form's `CategorySelect`.
*
* @module
*/
import { useCallback, useState } from "react";
/**
* Returns the ids picked, starting from `initial`, a function that turns one
* on or off, and one that replaces them all. The dump creation dialog, the
* dump editor and the playlist forms send `[...selected]` as `categoryIds`.
*/
export function useCategorySelection(
initial: Iterable<string> = [],
): [Set<string>, (id: string) => void, (ids: Set<string>) => void] {
const [selected, setSelected] = useState<Set<string>>(() => new Set(initial));
const toggle = useCallback((id: string) =>
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
}), []);
return [selected, toggle, setSelected];
}

View File

@@ -16,6 +16,7 @@ import {
} from "../../utils/shared/deserialize.ts";
import { useAuth } from "../auth/useAuth.ts";
import { useCategories } from "../category/useCategories.ts";
import { useCategoryFilter } from "../category/useCategoryFilter.ts";
import { useWS } from "../ws/useWS.ts";
import { useFeedCache } from "../feed/useFeedCache.ts";
import { usePagedList, type UsePagedListResult } from "../feed/usePagedList.ts";
@@ -64,7 +65,7 @@ export function useUserDumpFeed(
): UseUserDumpFeedResult {
const { user, authFetch } = useAuth();
const { lastDumpEvent } = useWS();
const { categories } = useCategories();
const { categories } = useCategories("dump");
const { cached, saveState } = useFeedCache<Dump>(
`feed:profile-${list}:${userId}${categorySlug ? `:${categorySlug}` : ""}`,
hydrateDump,
@@ -91,16 +92,7 @@ export function useUserDumpFeed(
// the categories don't know yet lets everything through.
const categoryId = categorySlug &&
categories.find((c) => c.slug === categorySlug)?.id;
const setItems = useCallback(
(fn: (prev: Dump[]) => Dump[]) =>
setAllItems((prev) => {
const next = fn(prev);
return categoryId
? next.filter((d) => d.categoryIds?.includes(categoryId))
: next;
}),
[setAllItems, categoryId],
);
const setItems = useCategoryFilter(setAllItems, categoryId || undefined);
const upvoted = useUpvotedList({
profileUserId: list === "upvoted" ? userId : null,

View File

@@ -11,6 +11,7 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { dumpThumbnailUrl, dumpUrl, isAt } from "../utils/shared/urls.ts";
import { can } from "../utils/auth/permissions.ts";
import { CategoryChips } from "../components/category/CategoryChips.tsx";
import { AddToPlaylistModal } from "../components/playlist/AddToPlaylistModal.tsx";
import { API_URL } from "../config/api.ts";
@@ -30,7 +31,6 @@ import {
import { friendlyFetchError, parseAPIResponse } from "../utils/shared/api.ts";
import { useAuth } from "../hooks/auth/useAuth.ts";
import { useCategories } from "../hooks/category/useCategories.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { relativeTime } from "../utils/shared/relativeTime.ts";
import { useHeightTransition } from "../hooks/shared/useHeightTransition.ts";
@@ -116,7 +116,6 @@ export function Dump(): React.JSX.Element {
const [titleError, setTitleError] = useState<string | null>(null);
const { user, mediaToken, authFetch } = useAuth();
const { byId } = useCategories();
const {
voteCounts,
myVotes,
@@ -334,9 +333,6 @@ export function Dump(): React.JSX.Element {
const { dump } = dumpState;
const canEdit = !!user &&
(dump.userId === user.id || can(user, "dump:moderate"));
const dumpCategories = (dump.categoryIds ?? [])
.map((id) => byId.get(id))
.filter((c) => c !== undefined);
const startTitleEdit = () => {
setTitleDraft(dump.title);
@@ -512,19 +508,7 @@ export function Dump(): React.JSX.Element {
</span>
)}
</div>
{dumpCategories.length > 0 && (
<div className="dump-card-categories">
{dumpCategories.map((category) => (
<Link
key={category.id}
to={`/${category.slug}`}
className="dump-card-category-chip"
>
{category.name}
</Link>
))}
</div>
)}
<CategoryChips kind="dump" ids={dump.categoryIds} />
</div>
</div>

View File

@@ -39,6 +39,7 @@ import { RichContentCard } from "../components/dump/RichContentCard.tsx";
import { FilePreview } from "../components/dump/FilePreview.tsx";
import { ImagePicker } from "../components/shared/ImagePicker.tsx";
import { CategorySelect } from "../components/category/CategorySelect.tsx";
import { useCategorySelection } from "../hooks/category/useCategorySelection.ts";
import { FileField } from "../components/form/FileField.tsx";
import { FormError } from "../components/form/FormError.tsx";
import { FormProvider } from "react-hook-form";
@@ -362,16 +363,9 @@ function DumpEditForm({
onSaved,
}: DumpEditFormProps) {
const { authFetch } = useAuth();
const [selectedCategoryIds, setSelectedCategoryIds] = useState<Set<string>>(
() => new Set(dump.categoryIds ?? []),
const [selectedCategoryIds, toggleCategory] = useCategorySelection(
dump.categoryIds ?? [],
);
const toggleCategory = (id: string) =>
setSelectedCategoryIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const form = useApiForm<EditValues>({
defaultValues: {
title: dump.title,
@@ -547,6 +541,7 @@ function DumpEditForm({
/>
<CategorySelect
kind="dump"
selected={selectedCategoryIds}
onToggle={toggleCategory}
/>

View File

@@ -65,7 +65,7 @@ export function Index(
const { user, authFetch } = useAuth();
const { recentDumps, deletedDumpIds, connectionEpoch } = useWS();
const { categories, isLoaded: categoriesLoaded } = useCategories();
const { categories, isLoaded: categoriesLoaded } = useCategories("dump");
const category = categorySlug
? categories.find((c) => c.slug === categorySlug)
: undefined;

View File

@@ -23,10 +23,10 @@ import {
import { NotFound } from "./NotFound.tsx";
/**
* Renders the category manager and the instances the site trusts or blocks,
* each to those who hold its permission, which the API checks again, the
* instances only while federated sign-in goes at least one way; without
* either, the page isn't found.
* Renders the managers of the dump and the playlist categories, and the
* instances the site trusts or blocks, each to those who hold its permission,
* which the API checks again, the instances only while federated sign-in goes
* at least one way; without either, the page isn't found.
*/
export function Manage(): React.JSX.Element {
useDocumentTitle(t`Manage`);
@@ -39,11 +39,18 @@ export function Manage(): React.JSX.Element {
<PageShell narrow>
<PageHeader icon="🛠️" title={<Trans>Manage</Trans>} />
{categories && (
<PageGroup label={<Trans>Categories</Trans>}>
<div className="page-card">
<CategoryManager />
</div>
</PageGroup>
<>
<PageGroup label={<Trans>Dump categories</Trans>}>
<div className="page-card">
<CategoryManager kind="dump" />
</div>
</PageGroup>
<PageGroup label={<Trans>Playlist categories</Trans>}>
<div className="page-card">
<CategoryManager kind="playlist" />
</div>
</PageGroup>
</>
)}
{instances && (
<PageGroup label={<Trans>Instances</Trans>}>

135
src/pages/Playlists.tsx Normal file
View File

@@ -0,0 +1,135 @@
/**
* Renders every public playlist, `/playlists`, and those of one playlist
* category, `/playlists/:categorySlug`, newest first.
*
* @module
*/
import { useCallback } from "react";
import { useNavigate, useParams } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts";
import type { Playlist } from "../../shared/playlist.ts";
import { playlistsPath } from "../../shared/paths.ts";
import { canLoadMore, pageUrl, readPage } from "../utils/feed/pagedList.ts";
import {
deserializePlaylist,
hydratePlaylist,
} from "../utils/shared/deserialize.ts";
import { useAuth } from "../hooks/auth/useAuth.ts";
import { useCategories } from "../hooks/category/useCategories.ts";
import { useCategoryFilter } from "../hooks/category/useCategoryFilter.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { useFeedCache } from "../hooks/feed/useFeedCache.ts";
import { usePagedList } from "../hooks/feed/usePagedList.ts";
import { useInfiniteScroll } from "../hooks/feed/useInfiniteScroll.ts";
import {
useScrollRestore,
useScrollSave,
} from "../hooks/feed/useScrollSave.ts";
import { usePlaylistListSync } from "../hooks/playlist/usePlaylistListSync.ts";
import { PageShell } from "../components/layout/PageShell.tsx";
import { PageHeader } from "../components/layout/PageHeader.tsx";
import { CategorySwitcher } from "../components/category/CategorySwitcher.tsx";
import { PlaylistCardList } from "../components/playlist/PlaylistCardList.tsx";
import { FeedFooter } from "../components/feed/FeedFooter.tsx";
import { ListState } from "../components/shared/ListState.tsx";
import { NotFound } from "./NotFound.tsx";
/**
* Renders the list for the route's category, or every public playlist
* without one. Each category's list is another, which the page mounts
* afresh. A slug that names no playlist category, once the categories have
* loaded, is a page not found.
*/
export function Playlists(): React.JSX.Element {
const { categorySlug } = useParams();
const { categories, isLoaded } = useCategories("playlist");
const category = categorySlug
? categories.find((c) => c.slug === categorySlug)
: undefined;
useDocumentTitle(category ? category.name : t`Playlists`);
if (categorySlug && isLoaded && !category) return <NotFound />;
return (
<PlaylistsList
key={categorySlug ?? ""}
categorySlug={categorySlug}
categoryId={category?.id}
/>
);
}
/**
* Renders the public playlists, from `GET /api/playlists`, a page at a time
* as the list scrolls; with `categorySlug`, only those in that playlist
* category, which the menu in the title row switches. The socket keeps them
* live, and going back restores the list and the scroll.
*/
function PlaylistsList(
{ categorySlug, categoryId }: { categorySlug?: string; categoryId?: string },
) {
const navigate = useNavigate();
const { user, authFetch } = useAuth();
const { cached, saveState } = useFeedCache<Playlist>(
`feed:playlists:${categorySlug ?? "all"}`,
hydratePlaylist,
);
const url = `${API_URL}/api/playlists${
categorySlug ? `?category=${encodeURIComponent(categorySlug)}` : ""
}`;
const { state, setItems: setAllItems, loadMore } = usePagedList<Playlist>(
`${url}:${user?.id ?? "guest"}`,
useCallback(
(page: number, signal?: AbortSignal) =>
authFetch(pageUrl(url, page), { signal })
.then((res) => readPage(res, deserializePlaylist)),
[authFetch, url],
),
cached,
);
const loaded = state.status === "loaded" ? state.data : null;
usePlaylistListSync(useCategoryFilter(setAllItems, categoryId));
const sentinelRef = useInfiniteScroll(loadMore, canLoadMore(loaded));
useScrollSave(
!!loaded,
useCallback((y) => {
if (loaded) saveState(loaded, y);
}, [loaded, saveState]),
);
useScrollRestore(cached?.scrollY, !!loaded);
return (
<PageShell narrow>
<PageHeader icon="📋" title={<Trans>Playlists</Trans>}>
<CategorySwitcher
kind="playlist"
categorySlug={categorySlug}
onSelect={(slug) => navigate(playlistsPath(slug))}
/>
</PageHeader>
<ListState
state={state}
isEmpty={({ data }) => data.items.length === 0}
empty={
<p className="empty-state">
<Trans>No playlists yet.</Trans>
</p>
}
>
{({ data }) => (
<>
<PlaylistCardList playlists={data.items} />
<FeedFooter
hasMore={data.hasMore}
loadingMore={data.loadingMore}
sentinelRef={sentinelRef}
/>
</>
)}
</ListState>
</PageShell>
);
}

View File

@@ -1,6 +1,7 @@
/**
* Renders the results of a search, in a tab for dumps, one for users and one
* for playlists.
* for playlists, the dumps and the playlists narrowed to a category of their
* own.
*
* @module
*/
@@ -14,6 +15,7 @@ import { TabBar } from "../components/shared/TabBar.tsx";
import { DumpCardList } from "../components/dump/DumpCardList.tsx";
import { PlaylistCardList } from "../components/playlist/PlaylistCardList.tsx";
import { FeedFooter } from "../components/feed/FeedFooter.tsx";
import { CategorySwitcher } from "../components/category/CategorySwitcher.tsx";
import { ErrorCard } from "../components/shared/ErrorCard.tsx";
import { useAuth } from "../hooks/auth/useAuth.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
@@ -29,6 +31,7 @@ import type { Playlist } from "../../shared/playlist.ts";
import type { PublicUser } from "../../shared/user.ts";
import type { Wire } from "../../shared/api.ts";
import type { SearchResults } from "../../shared/search.ts";
import type { CategoryKind } from "../../shared/category.ts";
import { SEARCH_URL } from "../config/api.ts";
import { expectOk } from "../utils/shared/api.ts";
import {
@@ -59,29 +62,54 @@ interface Results {
playlists: Playlist[];
}
/**
* Names, for each tab a category narrows, the kind of its categories and the
* parameter of `GET /api/search` that takes one.
*/
const TAB_CATEGORIES: Partial<
Record<Tab, { kind: CategoryKind; param: string }>
> = {
dumps: { kind: "dump", param: "category" },
playlists: { kind: "playlist", param: "playlistCategory" },
};
/**
* Renders `/search?q=` and `/search/:searchTab?q=`, for anyone. The results come
* from `GET /api/search`, signed in when the viewer is, so they find their own
* private dumps and playlists too. The dumps load a page at a time as the
* "dumps" tab scrolls. A new `q` aborts the search under way.
*
* On the "dumps" and "playlists" tabs, a menu narrows the results to a
* category of their kind, kept in the URL's `?category=`, which another tab
* drops.
*/
export function Search(): React.JSX.Element {
useDocumentTitle(t`Search`);
const [searchParams] = useSearchParams();
const [searchParams, setSearchParams] = useSearchParams();
const q = searchParams.get("q") ?? "";
const categorySlug = searchParams.get("category") || undefined;
const [tab, setTab] = useTabParam<Tab>(searchPath(), SEARCH_TABS, "dumps");
const filter = tab ? TAB_CATEGORIES[tab] : undefined;
const categoryParam = filter && categorySlug ? filter.param : undefined;
const { user, authFetch } = useAuth();
const search = useCallback(async (page: number, signal?: AbortSignal) => {
const url = new URL(SEARCH_URL, location.origin);
url.searchParams.set("q", q);
if (categoryParam && categorySlug) {
url.searchParams.set(categoryParam, categorySlug);
}
return expectOk<Wire<SearchResults>>(
await authFetch(pageUrl(url.toString(), page), { signal }),
);
}, [authFetch, q]);
}, [authFetch, q, categoryParam, categorySlug]);
const { state, setData } = useFetchState<Results>(
q.trim() ? `${q}:${user?.id ?? "guest"}` : undefined,
q.trim()
? `${q}:${user?.id ?? "guest"}:${categoryParam ?? ""}:${
categorySlug ?? ""
}`
: undefined,
(signal) =>
search(1, signal).then(({ dumps, users, playlists }) => ({
dumps: firstPage(dumps.items.map(deserializeDump), dumps.hasMore),
@@ -144,12 +172,33 @@ export function Search(): React.JSX.Element {
),
}))}
activeTab={tab}
onChange={setTab}
onChange={(next) => {
// Each tab's categories are its own.
const params = new URLSearchParams(searchParams);
params.delete("category");
const query = params.toString();
setTab(next, query ? `?${query}` : "");
}}
className="search-tabs"
innerClassName="search-tabs-inner"
/>
)}
{q.trim() && filter && (
<div className="search-filter">
<CategorySwitcher
kind={filter.kind}
categorySlug={categorySlug}
onSelect={(slug) =>
setSearchParams((p) => {
if (slug) p.set("category", slug);
else p.delete("category");
return p;
})}
/>
</div>
)}
{!q.trim() && (
<p className="search-page-empty">
<Trans>Enter a query to search.</Trans>

View File

@@ -16,7 +16,11 @@ describe("CategorySelect", () => {
const onToggle = vi.fn();
const user = userEvent.setup();
renderWithContexts(
<CategorySelect selected={new Set(["c2"])} onToggle={onToggle} />,
<CategorySelect
kind="dump"
selected={new Set(["c2"])}
onToggle={onToggle}
/>,
{
categories,
},
@@ -35,7 +39,7 @@ describe("CategorySelect", () => {
test("nothing without categories", () => {
renderWithContexts(
<CategorySelect selected={new Set()} onToggle={() => {}} />,
<CategorySelect kind="dump" selected={new Set()} onToggle={() => {}} />,
);
expect(screen.queryByText("Categories")).not.toBeInTheDocument();
});

View File

@@ -13,7 +13,7 @@ const categories: Category[] = [
describe("CategorySwitcher", () => {
test("hidden with no categories", () => {
renderWithContexts(<CategorySwitcher onSelect={() => {}} />);
renderWithContexts(<CategorySwitcher kind="dump" onSelect={() => {}} />);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
@@ -21,7 +21,7 @@ describe("CategorySwitcher", () => {
const user = userEvent.setup();
const onSelect = vi.fn();
renderWithContexts(
<CategorySwitcher categorySlug="music" onSelect={onSelect} />,
<CategorySwitcher kind="dump" categorySlug="music" onSelect={onSelect} />,
{ categories },
);
await user.click(screen.getByRole("button", { name: "Music" }));
@@ -38,7 +38,7 @@ describe("CategorySwitcher", () => {
const user = userEvent.setup();
const onSelect = vi.fn();
renderWithContexts(
<CategorySwitcher categorySlug="gone" onSelect={onSelect} />,
<CategorySwitcher kind="dump" categorySlug="gone" onSelect={onSelect} />,
{ categories },
);
await user.click(screen.getByRole("button", { name: "gone" }));
@@ -48,7 +48,7 @@ describe("CategorySwitcher", () => {
test("Escape closes the menu", async () => {
const user = userEvent.setup();
renderWithContexts(<CategorySwitcher onSelect={() => {}} />, {
renderWithContexts(<CategorySwitcher kind="dump" onSelect={() => {}} />, {
categories,
});
await user.click(screen.getByRole("button", { name: "All" }));

View File

@@ -14,16 +14,32 @@ describe("CategoriesProvider", () => {
test("loads and indexes categories", async () => {
const music = makeCategory({ id: "c1", slug: "music" });
route("GET /api/categories", () => wire([music]));
const { result } = renderHook(useCategories, { wrapper });
const { result } = renderHook(() => useCategories("dump"), { wrapper });
await waitFor(() => expect(result.current.isLoaded).toBe(true));
expect(result.current.byId.get("c1")?.slug).toBe("music");
expect(result.current.categories[0].createdAt).toBeInstanceOf(Date);
});
test("gives each kind its own set", async () => {
route("GET /api/categories", () =>
wire([
makeCategory({ id: "d", slug: "music" }),
makeCategory({ id: "p", slug: "music", kind: "playlist" }),
]));
const { result } = renderHook(
() => ({ dump: useCategories("dump"), list: useCategories("playlist") }),
{ wrapper },
);
await waitFor(() => expect(result.current.dump.isLoaded).toBe(true));
expect(result.current.dump.categories.map((c) => c.id)).toEqual(["d"]);
expect(result.current.list.categories.map((c) => c.id)).toEqual(["p"]);
expect(result.current.dump.byId.has("p")).toBe(false);
});
test("refresh refetches", async () => {
let list = [makeCategory({ id: "a" })];
route("GET /api/categories", () => wire(list));
const { result } = renderHook(useCategories, { wrapper });
const { result } = renderHook(() => useCategories("dump"), { wrapper });
await waitFor(() => expect(result.current.categories).toHaveLength(1));
list = [...list, makeCategory({ id: "b" })];
await act(() => result.current.refresh());
@@ -34,7 +50,7 @@ describe("CategoriesProvider", () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
try {
route("GET /api/categories", () => fail("x", 500));
const { result } = renderHook(useCategories, { wrapper });
const { result } = renderHook(() => useCategories("dump"), { wrapper });
await act(async () => {});
expect(result.current.isLoaded).toBe(false);
route("GET /api/categories", () => wire([makeCategory({ id: "a" })]));

View File

@@ -119,6 +119,7 @@ export function makeCategory(over: Partial<Category> = {}): Category {
const id = over.id ?? nextId("cat");
return {
id,
kind: "dump",
slug: over.slug ?? `cat${seq}`,
name: over.name ?? `Category ${seq}`,
position: 0,

View File

@@ -22,6 +22,12 @@ vi.mock("../../config/instance.ts", async (importOriginal) => ({
const admin = makeSession({ id: "ann", username: "ann", role: "admin" });
/** Queries within the `/manage` group headed `label`. */
async function group(label: string) {
const heading = await screen.findByRole("heading", { name: label });
return within(heading.closest("section")!);
}
function open(session = admin) {
route("GET /api/federation/peers", () => [], { fallback: true });
const user = userEvent.setup();
@@ -49,8 +55,9 @@ describe("Manage", () => {
return null;
});
const user = open();
const dumps = await group("Dump categories");
await user.click(
await screen.findByRole("button", { name: "Add category" }),
await dumps.findByRole("button", { name: "Add category" }),
);
await user.type(screen.getByRole("textbox", { name: "Name" }), "Cinema");
await user.type(
@@ -61,6 +68,7 @@ describe("Manage", () => {
expect(await screen.findByRole("button", { name: "Cinema" }))
.toBeInTheDocument();
expect(calls("POST /api/categories")[0].body).toEqual({
kind: "dump",
name: "Cinema",
slug: "cinema",
});
@@ -82,8 +90,9 @@ describe("Manage", () => {
route("GET /api/categories", () => []);
route("POST /api/categories", () => fail("Slug already taken", 409));
const user = open();
const dumps = await group("Dump categories");
await user.click(
await screen.findByRole("button", { name: "Add category" }),
await dumps.findByRole("button", { name: "Add category" }),
);
await user.type(screen.getByRole("textbox", { name: "Name" }), "X");
await user.type(
@@ -94,10 +103,51 @@ describe("Manage", () => {
"Slug already taken",
);
await user.type(screen.getByRole("textbox", { name: "Name" }), "{Escape}");
expect(screen.getByRole("button", { name: "Add category" }))
expect(dumps.getByRole("button", { name: "Add category" }))
.toBeInTheDocument();
});
test("the playlist categories are a set of their own", async () => {
let list = [
makeCategory({ id: "d1", slug: "music", name: "Music" }),
makeCategory({
id: "p1",
kind: "playlist",
slug: "mixes",
name: "Mixes",
}),
];
route("GET /api/categories", () => wire(list));
route("POST /api/categories", (req) => {
const c = makeCategory({ id: "p2", ...(req.body as object) });
list = [...list, c];
return wire(c);
});
const user = open();
const dumps = await group("Dump categories");
const playlists = await group("Playlist categories");
expect(await dumps.findByRole("button", { name: "Music" }))
.toBeInTheDocument();
expect(dumps.queryByRole("button", { name: "Mixes" })).toBeNull();
expect(playlists.getByRole("button", { name: "Mixes" }))
.toBeInTheDocument();
expect(playlists.queryByRole("button", { name: "Music" })).toBeNull();
await user.click(playlists.getByRole("button", { name: "Add category" }));
await user.type(screen.getByRole("textbox", { name: "Name" }), "Live");
await user.type(
screen.getByRole("textbox", { name: "URL slug" }),
"live{Enter}",
);
expect(await playlists.findByRole("button", { name: "Live" }))
.toBeInTheDocument();
expect(calls("POST /api/categories")[0].body).toEqual({
kind: "playlist",
name: "Live",
slug: "live",
});
});
test("an admin trusts, blocks and removes other instances", async () => {
route("GET /api/categories", () => []);
const music = {
@@ -189,7 +239,7 @@ describe("Manage", () => {
instance.federationOutgoing = false;
route("GET /api/categories", () => []);
open();
expect(await screen.findByText("Categories")).toBeInTheDocument();
expect(await screen.findByText("Dump categories")).toBeInTheDocument();
expect(screen.queryByText("Instances")).not.toBeInTheDocument();
});

View File

@@ -12,7 +12,13 @@ import { PlaylistDetail } from "../../pages/PlaylistDetail.tsx";
import type { Dump } from "../../../shared/dump.ts";
import type { Playlist } from "../../../shared/playlist.ts";
import { calls, fail, route } from "../fetch.ts";
import { makeDump, makePlaylist, makeSession, wire } from "../fixtures.ts";
import {
makeCategory,
makeDump,
makePlaylist,
makeSession,
wire,
} from "../fixtures.ts";
import { currentPath, renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
@@ -31,6 +37,7 @@ function open(
playlist = {} as Partial<Playlist>,
items = dumps() as Dump[],
url = "/@owner/playlists/mix",
categories = [] as ReturnType<typeof makeCategory>[],
} = {},
) {
const pl = makePlaylist({
@@ -51,6 +58,7 @@ function open(
route: url,
path: "/:handle/playlists/:slug",
session,
categories,
extraRoutes: <Route path="/" element={<p>home</p>} />,
});
const socket = FakeWebSocket.last;
@@ -142,6 +150,56 @@ describe("PlaylistDetail", () => {
expect(currentPath()).toBe("/@owner/playlists/mix");
});
test("its categories show, and editing them sends the new set", async () => {
const mixes = makeCategory({
id: "pc1",
kind: "playlist",
slug: "mixes",
name: "Mixes",
});
const live = makeCategory({
id: "pc2",
kind: "playlist",
slug: "live",
name: "Live",
});
const music = makeCategory({ id: "dc1", slug: "music", name: "Music" });
route(
"PUT /api/playlists/:id",
(req) =>
wire(
makePlaylist({
id: "p1",
slug: "mix",
title: "Mix",
userId: "owner",
...(req.body as object),
}),
),
);
const { user } = open({
playlist: { categoryIds: ["pc1"] },
categories: [music, mixes, live],
});
expect(await screen.findByRole("link", { name: "Mixes" }))
.toHaveAttribute("href", "/playlists/mixes");
await user.click(screen.getByRole("button", { name: "Edit" }));
// The dump categories aren't offered.
expect(screen.queryByRole("button", { name: "Music" })).toBeNull();
expect(screen.getByRole("button", { name: "Mixes" }))
.toHaveAttribute("aria-pressed", "true");
await user.click(screen.getByRole("button", { name: "Mixes" }));
await user.click(screen.getByRole("button", { name: "Live" }));
await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByRole("link", { name: "Live" }))
.toHaveAttribute("href", "/playlists/live");
expect(calls("PUT /api/playlists/:id")[0].body).toEqual({
categoryIds: ["pc2"],
isPublic: true,
});
});
test("a new title proposes a new link, which saving moves to", async () => {
route(
"PUT /api/playlists/:id",

View File

@@ -0,0 +1,94 @@
import { describe, expect, test } from "vitest";
import { screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { Playlists } from "../../pages/Playlists.tsx";
import { calls, route } from "../fetch.ts";
import { makeCategory, makePlaylist, page, wire } from "../fixtures.ts";
import { currentPath, renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
const mixes = makeCategory({
id: "pc1",
kind: "playlist",
slug: "mixes",
name: "Mixes",
});
const music = makeCategory({ id: "dc1", slug: "music", name: "Music" });
function open(url: string) {
const user = userEvent.setup();
renderApp(<Playlists />, {
route: url,
path: "/playlists/:categorySlug?",
categories: [music, mixes],
});
return { user, socket: FakeWebSocket.last };
}
describe("Playlists", () => {
test("every public playlist; the menu offers the playlist categories only", async () => {
route(
"GET /api/playlists",
() => page([makePlaylist({ title: "Summer", categoryIds: ["pc1"] })]),
);
const { user } = open("/playlists");
expect(await screen.findByText("Summer")).toBeInTheDocument();
expect(calls("GET /api/playlists")[0].url.searchParams.get("category"))
.toBeNull();
// Its chip leads to its category's playlists.
expect(screen.getByRole("link", { name: "Mixes" })).toHaveAttribute(
"href",
"/playlists/mixes",
);
await user.click(screen.getByRole("button", { name: "All" }));
expect(screen.getByRole("option", { name: "Mixes" })).toBeInTheDocument();
expect(screen.queryByRole("option", { name: "Music" })).toBeNull();
await user.click(screen.getByRole("option", { name: "Mixes" }));
expect(currentPath()).toBe("/playlists/mixes");
});
test("a category's playlists, kept to it as the socket brings more", async () => {
route(
"GET /api/playlists",
() =>
page([
makePlaylist({ id: "p1", title: "Filed", categoryIds: ["pc1"] }),
]),
);
const { socket } = open("/playlists/mixes");
expect(await screen.findByText("Filed")).toBeInTheDocument();
expect(calls("GET /api/playlists")[0].url.searchParams.get("category"))
.toBe("mixes");
socket.welcome();
socket.receive({
type: "playlist_created",
playlist: wire(makePlaylist({ title: "Elsewhere", categoryIds: [] })),
});
socket.receive({
type: "playlist_created",
playlist: wire(
makePlaylist({ title: "Also filed", categoryIds: ["pc1"] }),
),
});
expect(screen.getByText("Also filed")).toBeInTheDocument();
expect(screen.queryByText("Elsewhere")).toBeNull();
// Taken out of the category, it leaves.
socket.receive({
type: "playlist_updated",
playlist: wire(
makePlaylist({ id: "p1", title: "Filed", categoryIds: [] }),
),
});
expect(screen.queryByText("Filed")).toBeNull();
});
test("a slug that names no playlist category is not found", async () => {
route("GET /api/playlists", () => page([]));
open("/playlists/music");
expect(await screen.findByText("This page does not exist."))
.toBeInTheDocument();
});
});

View File

@@ -1,9 +1,10 @@
import { describe, expect, test } from "vitest";
import { act, screen } from "@testing-library/react";
import { act, screen, waitFor } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { Search } from "../../pages/Search.tsx";
import { calls, fail, route } from "../fetch.ts";
import {
makeCategory,
makeDump,
makePlaylist,
makeSession,
@@ -35,9 +36,10 @@ function results(
function open(
url: string,
session: ReturnType<typeof makeSession> | null = null,
categories: ReturnType<typeof makeCategory>[] = [],
) {
const user = userEvent.setup();
renderApp(<Search />, { route: url, path: "/search/*", session });
renderApp(<Search />, { route: url, path: "/search/*", session, categories });
return user;
}
@@ -70,6 +72,50 @@ describe("Search", () => {
expect(screen.getByText("Cats")).toBeInTheDocument();
});
test("each tab narrows to a category of its own kind", async () => {
route("GET /api/search", () =>
results({
dumps: [makeDump({ title: "Cat song" })],
playlists: [makePlaylist({ title: "Cat mix" })],
}));
const user = open("/search?q=cat", null, [
makeCategory({ id: "dc1", slug: "music", name: "Music" }),
makeCategory({
id: "pc1",
kind: "playlist",
slug: "mixes",
name: "Mixes",
}),
]);
await screen.findByText("Cat song");
await user.click(screen.getByRole("button", { name: "All" }));
expect(screen.queryByRole("option", { name: "Mixes" })).toBeNull();
await user.click(screen.getByRole("option", { name: "Music" }));
expect(currentPath()).toBe("/search?q=cat&category=music");
await waitFor(() =>
expect(
calls("GET /api/search").at(-1)!.url.searchParams.get("category"),
).toBe("music")
);
// Another tab drops it, and offers its own.
await user.click(screen.getByRole("button", { name: /^Playlists/ }));
expect(currentPath()).toBe("/search/playlists?q=cat");
await user.click(await screen.findByRole("button", { name: "All" }));
expect(screen.queryByRole("option", { name: "Music" })).toBeNull();
await user.click(screen.getByRole("option", { name: "Mixes" }));
await waitFor(() => {
const req = calls("GET /api/search").at(-1)!;
expect(req.url.searchParams.get("playlistCategory")).toBe("mixes");
expect(req.url.searchParams.get("category")).toBeNull();
});
// The users tab has none.
await user.click(screen.getByRole("button", { name: /^Users/ }));
expect(screen.queryByRole("button", { name: "All" })).toBeNull();
});
test("nothing found", async () => {
route("GET /api/search", () => results());
const user = open("/search?q=zzz");

View File

@@ -217,6 +217,47 @@ describe("UserPublicProfile", () => {
expect(currentPath()).toBe("/@ann/playlists");
});
test("the playlists tab narrows to a playlist category, in its URL", async () => {
const music = makeCategory({ id: "c1", slug: "music", name: "Music" });
const mixes = makeCategory({
id: "pc1",
kind: "playlist",
slug: "mixes",
name: "Mixes",
});
const filed = makePlaylist({
id: "p1",
title: "Filed mix",
userId: "ann",
categoryIds: ["pc1"],
});
const { user, socket } = open({
url: "/@ann/playlists",
categories: [music, mixes],
});
route("GET /api/users/:u/playlists", (req) =>
page(
req.url.searchParams.get("category") === "mixes"
? [filed]
: [filed, makePlaylist({ id: "p2", title: "Loose", userId: "ann" })],
));
await screen.findByText("Loose");
await user.click(screen.getByRole("button", { name: "All" }));
expect(screen.queryByRole("option", { name: "Music" })).toBeNull();
await user.click(screen.getByRole("option", { name: "Mixes" }));
expect(currentPath()).toBe("/@ann/playlists?category=mixes");
await waitFor(() =>
expect(screen.queryByText("Loose")).not.toBeInTheDocument()
);
expect(screen.getByText("Filed mix")).toBeInTheDocument();
socket.welcome();
socket.receive({
type: "playlist_updated",
playlist: wire({ ...filed, categoryIds: [] }),
});
expect(screen.queryByText("Filed mix")).not.toBeInTheDocument();
});
test("the dumps tab loads more as its end scrolls into view", async () => {
open({ url: "/@ann/dumps" });
// After open's, so that it wins.

View File

@@ -130,7 +130,6 @@ function categoriesValue(
const list = Array.isArray(c) ? c : c?.categories ?? [];
return {
categories: list,
byId: new Map(list.map((x) => [x.id, x])),
isLoaded: true,
refresh: async () => {},
...(Array.isArray(c) ? {} : c),