v3: added site-wide categories, added admin category management, various visual fixes
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 2m52s

This commit is contained in:
khannurien
2026-06-28 16:23:44 +00:00
parent c8c7b05c25
commit fae25f3e6c
44 changed files with 2144 additions and 399 deletions

View File

@@ -86,6 +86,8 @@ export const VALIDATION = {
PLAYLIST_DESCRIPTION_MAX: 2000, PLAYLIST_DESCRIPTION_MAX: 2000,
COMMENT_BODY_MAX: 5000, COMMENT_BODY_MAX: 5000,
USER_DESCRIPTION_MAX: 2000, USER_DESCRIPTION_MAX: 2000,
CATEGORY_NAME_MAX: 50,
CATEGORY_SLUG_MAX: 50,
} as const; } as const;
// SEO/OG // SEO/OG

View File

@@ -4,6 +4,7 @@ import { up as up0002DumpThumbnail } from "./migrations/0002_dump_thumbnail.ts";
import { up as up0003DumpBacklinks } from "./migrations/0003_dump_backlinks.ts"; import { up as up0003DumpBacklinks } from "./migrations/0003_dump_backlinks.ts";
import { up as up0004UserRoles } from "./migrations/0004_user_roles.ts"; import { up as up0004UserRoles } from "./migrations/0004_user_roles.ts";
import { up as up0005DropIsAdmin } from "./migrations/0005_drop_is_admin.ts"; import { up as up0005DropIsAdmin } from "./migrations/0005_drop_is_admin.ts";
import { up as up0006Categories } from "./migrations/0006_categories.ts";
interface Migration { interface Migration {
name: string; name: string;
@@ -19,6 +20,7 @@ const MIGRATIONS: Migration[] = [
{ name: "0003_dump_backlinks", up: up0003DumpBacklinks }, { name: "0003_dump_backlinks", up: up0003DumpBacklinks },
{ name: "0004_user_roles", up: up0004UserRoles }, { name: "0004_user_roles", up: up0004UserRoles },
{ name: "0005_drop_is_admin", up: up0005DropIsAdmin }, { name: "0005_drop_is_admin", up: up0005DropIsAdmin },
{ name: "0006_categories", up: up0006Categories },
]; ];
export function runMigrations(db: DatabaseSync): void { export function runMigrations(db: DatabaseSync): void {

View File

@@ -0,0 +1,39 @@
import type { DatabaseSync } from "node:sqlite";
// Idempotent: safe to run against a fresh db (already created from schema.sql
// with the categories tables) or an existing one that predates them.
export function up(db: DatabaseSync): void {
const tables = new Set(
(db.prepare(
`SELECT name FROM sqlite_master WHERE type = 'table';`,
).all() as { name: string }[]).map((r) => r.name),
);
if (!tables.has("categories")) {
db.exec(
`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
);`,
);
}
if (!tables.has("dump_categories")) {
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);`,
);
}
}

View File

@@ -63,6 +63,23 @@ CREATE TABLE playlist_dumps (
FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE
); );
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
);
CREATE TABLE comments ( CREATE TABLE comments (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
dump_id TEXT NOT NULL, dump_id TEXT NOT NULL,
@@ -108,6 +125,7 @@ CREATE INDEX idx_comments_dump ON comments(dump_id, created_at);
CREATE INDEX idx_comment_likes_user ON comment_likes(user_id); 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_to ON dump_backlinks(to_dump_id);
CREATE INDEX idx_dump_backlinks_from ON dump_backlinks(from_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 TABLE follows ( CREATE TABLE follows (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,

View File

@@ -6,13 +6,15 @@ export type Permission =
| "dump:moderate" | "dump:moderate"
| "comment:moderate" | "comment:moderate"
| "playlist:moderate" | "playlist:moderate"
| "user:manage"; | "user:manage"
| "category:manage";
const ALL_PERMISSIONS: readonly Permission[] = [ const ALL_PERMISSIONS: readonly Permission[] = [
"dump:moderate", "dump:moderate",
"comment:moderate", "comment:moderate",
"playlist:moderate", "playlist:moderate",
"user:manage", "user:manage",
"category:manage",
]; ];
// Roles are fixed in code. `admin` is a superset of every permission (current // Roles are fixed in code. `admin` is a superset of every permission (current

33
api/lib/reserved-slugs.ts Normal file
View File

@@ -0,0 +1,33 @@
// Top-level path segments that already have meaning in the SPA router
// (see src/App.tsx) plus a few reserved for infrastructure. Category slugs
// live at the URL root (e.g. /music), so they must never collide with these.
export const RESERVED_SLUGS: readonly string[] = [
"~",
"dumps",
"users",
"playlists",
"search",
"login",
"register",
"notifications",
"reset-password",
"manage",
"api",
"assets",
];
// Lowercase alphanumerics, hyphen-separated, no leading/trailing/double hyphen.
export const CATEGORY_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
export function isValidSlugFormat(slug: string): boolean {
return CATEGORY_SLUG_RE.test(slug);
}
export function isReservedSlug(slug: string): boolean {
return RESERVED_SLUGS.includes(slug);
}
/** A slug is available when it is well-formed and not reserved. */
export function isSlugAvailable(slug: string): boolean {
return isValidSlugFormat(slug) && !isReservedSlug(slug);
}

View File

@@ -15,6 +15,7 @@ import followsRouter from "./routes/follows.ts";
import notificationsRouter from "./routes/notifications.ts"; import notificationsRouter from "./routes/notifications.ts";
import invitesRouter from "./routes/invites.ts"; import invitesRouter from "./routes/invites.ts";
import searchRouter from "./routes/search.ts"; import searchRouter from "./routes/search.ts";
import categoriesRouter from "./routes/categories.ts";
import { ALLOWED_ORIGINS, LISTEN_HOST, PORT, PUBLIC_URL } from "./config.ts"; import { ALLOWED_ORIGINS, LISTEN_HOST, PORT, PUBLIC_URL } from "./config.ts";
import { errorMiddleware } from "./middleware/error.ts"; import { errorMiddleware } from "./middleware/error.ts";
@@ -93,6 +94,10 @@ app.use(
searchRouter.routes(), searchRouter.routes(),
searchRouter.allowedMethods(), searchRouter.allowedMethods(),
); );
app.use(
categoriesRouter.routes(),
categoriesRouter.allowedMethods(),
);
app.use(routeStaticFilesFrom([ app.use(routeStaticFilesFrom([
`${Deno.cwd()}/dist`, `${Deno.cwd()}/dist`,
`${Deno.cwd()}/public`, `${Deno.cwd()}/public`,

View File

@@ -1,6 +1,7 @@
import { randomBytes, scryptSync } from "node:crypto"; import { randomBytes, scryptSync } from "node:crypto";
import { DatabaseSync, type SQLOutputValue } from "node:sqlite"; import { DatabaseSync, type SQLOutputValue } from "node:sqlite";
import { import {
type Category,
type Comment, type Comment,
Dump, Dump,
isRole, isRole,
@@ -352,6 +353,40 @@ export function playlistRowToApi(row: PlaylistRow): Playlist {
}; };
} }
// ── Categories ────────────────────────────────────────────────────────────────
export interface CategoryRow {
id: string;
slug: string;
name: string;
position: number;
created_at: string;
updated_at: string | null;
[key: string]: SQLOutputValue;
}
export function isCategoryRow(obj: unknown): obj is CategoryRow {
return !!obj && typeof obj === "object" &&
"id" in obj && typeof obj.id === "string" &&
"slug" in obj && typeof obj.slug === "string" &&
"name" in obj && typeof obj.name === "string" &&
"position" in obj && typeof obj.position === "number" &&
"created_at" in obj && typeof obj.created_at === "string" &&
"updated_at" in obj &&
(typeof obj.updated_at === "string" || obj.updated_at === null);
}
export function categoryRowToApi(row: CategoryRow): Category {
return {
id: row.id,
slug: row.slug,
name: row.name,
position: row.position,
createdAt: new Date(row.created_at),
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
};
}
export interface FollowRow { export interface FollowRow {
id: string; id: string;
follower_id: string; follower_id: string;

View File

@@ -33,6 +33,54 @@ export interface Dump {
commentCount: number; commentCount: number;
isPrivate: boolean; isPrivate: boolean;
thumbnailMime?: string; thumbnailMime?: string;
categoryIds?: string[];
}
/**
* Categories
*/
export interface Category {
id: string;
slug: string;
name: string;
position: number;
createdAt: Date;
updatedAt?: Date;
}
export interface CreateCategoryRequest {
slug: string;
name: string;
position?: number;
}
export interface UpdateCategoryRequest {
slug?: string;
name?: string;
position?: number;
}
export function isCreateCategoryRequest(
obj: unknown,
): obj is CreateCategoryRequest {
if (!obj || typeof obj !== "object") return false;
const o = obj as Record<string, unknown>;
if (typeof o.slug !== "string") return false;
if (typeof o.name !== "string") return false;
if ("position" in o && typeof o.position !== "number") return false;
return true;
}
export function isUpdateCategoryRequest(
obj: unknown,
): obj is UpdateCategoryRequest {
if (!obj || typeof obj !== "object") return false;
const o = obj as Record<string, unknown>;
if ("slug" in o && typeof o.slug !== "string") return false;
if ("name" in o && typeof o.name !== "string") return false;
if ("position" in o && typeof o.position !== "number") return false;
return true;
} }
/** /**
@@ -390,10 +438,15 @@ export function isReorderPlaylistRequest(
* Request DTOs * Request DTOs
*/ */
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((v) => typeof v === "string");
}
export interface CreateUrlDumpRequest { export interface CreateUrlDumpRequest {
url: string; url: string;
comment?: string; comment?: string;
isPrivate?: boolean; isPrivate?: boolean;
categoryIds?: string[];
} }
export function isCreateUrlDumpRequest( export function isCreateUrlDumpRequest(
@@ -412,6 +465,7 @@ export function isCreateUrlDumpRequest(
(o.comment as string).length > VALIDATION.DUMP_COMMENT_MAX (o.comment as string).length > VALIDATION.DUMP_COMMENT_MAX
) return false; ) return false;
if ("isPrivate" in o && typeof o.isPrivate !== "boolean") return false; if ("isPrivate" in o && typeof o.isPrivate !== "boolean") return false;
if ("categoryIds" in o && !isStringArray(o.categoryIds)) return false;
return true; return true;
} }
@@ -420,6 +474,7 @@ export interface UpdateDumpRequest {
title?: string; title?: string;
comment?: string; comment?: string;
isPrivate?: boolean; isPrivate?: boolean;
categoryIds?: string[];
} }
export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest { export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest {
@@ -439,6 +494,7 @@ export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest {
(o.comment as string).length > VALIDATION.DUMP_COMMENT_MAX (o.comment as string).length > VALIDATION.DUMP_COMMENT_MAX
) return false; ) return false;
if ("isPrivate" in o && typeof o.isPrivate !== "boolean") return false; if ("isPrivate" in o && typeof o.isPrivate !== "boolean") return false;
if ("categoryIds" in o && !isStringArray(o.categoryIds)) return false;
return true; return true;
} }

117
api/routes/categories.ts Normal file
View File

@@ -0,0 +1,117 @@
import { Router } from "@oak/oak";
import {
APIErrorCode,
APIException,
type APIResponse,
type Category,
type Dump,
isCreateCategoryRequest,
isUpdateCategoryRequest,
type PaginatedData,
} from "../model/interfaces.ts";
import { authMiddleware, requirePermission } from "../middleware/auth.ts";
import { parseOptionalAuth } from "../lib/auth.ts";
import { parsePagination } from "../lib/pagination.ts";
import {
createCategory,
deleteCategory,
getCategoryBySlug,
listCategories,
listDumpsByCategory,
updateCategory,
} from "../services/category-service.ts";
const router = new Router({ prefix: "/api/categories" });
router.get("/", (ctx) => {
const responseBody: APIResponse<Category[]> = {
success: true,
data: listCategories(),
};
ctx.response.body = responseBody;
});
router.get("/:slug", (ctx) => {
const category = getCategoryBySlug(ctx.params.slug);
const responseBody: APIResponse<Category> = {
success: true,
data: category,
};
ctx.response.body = responseBody;
});
router.get("/:slug/dumps", async (ctx) => {
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, total } = listDumpsByCategory(
ctx.params.slug,
page,
limit,
requestingUserId,
);
const responseBody: APIResponse<PaginatedData<Dump>> = {
success: true,
data: { items, total, hasMore: page * limit < total },
};
ctx.response.body = responseBody;
});
router.post(
"/",
authMiddleware,
requirePermission("category:manage"),
async (ctx) => {
const body = await ctx.request.body.json();
if (!isCreateCategoryRequest(body)) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Invalid category data",
);
}
const category = createCategory(body);
const responseBody: APIResponse<Category> = {
success: true,
data: category,
};
ctx.response.status = 201;
ctx.response.body = responseBody;
},
);
router.put(
"/:id",
authMiddleware,
requirePermission("category:manage"),
async (ctx) => {
const body = await ctx.request.body.json();
if (!isUpdateCategoryRequest(body)) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Invalid category data",
);
}
const category = updateCategory(ctx.params.id, body);
const responseBody: APIResponse<Category> = {
success: true,
data: category,
};
ctx.response.body = responseBody;
},
);
router.delete(
"/:id",
authMiddleware,
requirePermission("category:manage"),
(ctx) => {
deleteCategory(ctx.params.id);
const responseBody: APIResponse<null> = { success: true, data: null };
ctx.response.body = responseBody;
},
);
export default router;

View File

@@ -33,6 +33,19 @@ import { getRelatedDumps } from "../services/backlink-service.ts";
const router = new Router({ prefix: "/api/dumps" }); const router = new Router({ prefix: "/api/dumps" });
// Categories arrive on the multipart create path as a JSON-encoded string field.
function parseCategoryIds(value: FormDataEntryValue | null): string[] {
if (typeof value !== "string" || !value) return [];
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed)
? parsed.filter((v): v is string => typeof v === "string")
: [];
} catch {
return [];
}
}
router.post( router.post(
"/", "/",
authMiddleware, authMiddleware,
@@ -63,6 +76,7 @@ router.post(
userId, userId,
isPrivate, isPrivate,
typeof title === "string" && title ? title : undefined, typeof title === "string" && title ? title : undefined,
parseCategoryIds(formData.get("categoryIds")),
); );
} else { } else {
const body = await ctx.request.body.json(); const body = await ctx.request.body.json();
@@ -156,6 +170,9 @@ router.put("/:dumpId/file", authMiddleware, async (ctx) => {
file, file,
typeof comment === "string" && comment ? comment : undefined, typeof comment === "string" && comment ? comment : undefined,
typeof title === "string" && title ? title : undefined, typeof title === "string" && title ? title : undefined,
formData.has("categoryIds")
? parseCategoryIds(formData.get("categoryIds"))
: undefined,
); );
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump }; const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
ctx.response.body = responseBody; ctx.response.body = responseBody;

View File

@@ -16,6 +16,20 @@ import {
unfollowPlaylist, unfollowPlaylist,
unfollowUser, unfollowUser,
} from "../services/follow-service.ts"; } from "../services/follow-service.ts";
import { getCategoryIdBySlug } from "../services/category-service.ts";
// Resolves the optional ?category=<slug> filter. Returns the matching category
// id, or "missing" when a slug was given but matches nothing (so the feed comes
// back empty rather than unfiltered), or undefined when no filter was requested.
function resolveCategoryFilter(
params: URLSearchParams,
): string | "missing" | undefined {
const slug = params.get("category");
if (!slug) return undefined;
return getCategoryIdBySlug(slug) ?? "missing";
}
const EMPTY_FEED: { items: Dump[]; total: number } = { items: [], total: 0 };
const router = new Router({ prefix: "/api/follows" }); const router = new Router({ prefix: "/api/follows" });
@@ -31,11 +45,10 @@ router.get("/status", authMiddleware, (ctx) => {
// GET /api/follows/feed/users?page=&limit= // GET /api/follows/feed/users?page=&limit=
router.get("/feed/users", authMiddleware, (ctx) => { router.get("/feed/users", authMiddleware, (ctx) => {
const { page, limit } = parsePagination(ctx.request.url.searchParams); const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, total } = getFollowedUsersDumpFeed( const categoryId = resolveCategoryFilter(ctx.request.url.searchParams);
ctx.state.user.userId, const { items, total } = categoryId === "missing"
page, ? EMPTY_FEED
limit, : getFollowedUsersDumpFeed(ctx.state.user.userId, page, limit, categoryId);
);
const data: PaginatedData<Dump> = { const data: PaginatedData<Dump> = {
items, items,
total, total,
@@ -48,11 +61,15 @@ router.get("/feed/users", authMiddleware, (ctx) => {
// GET /api/follows/feed/playlists?page=&limit= // GET /api/follows/feed/playlists?page=&limit=
router.get("/feed/playlists", authMiddleware, (ctx) => { router.get("/feed/playlists", authMiddleware, (ctx) => {
const { page, limit } = parsePagination(ctx.request.url.searchParams); const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, total } = getFollowedPlaylistsDumpFeed( const categoryId = resolveCategoryFilter(ctx.request.url.searchParams);
ctx.state.user.userId, const { items, total } = categoryId === "missing"
page, ? EMPTY_FEED
limit, : getFollowedPlaylistsDumpFeed(
); ctx.state.user.userId,
page,
limit,
categoryId,
);
const data: PaginatedData<Dump> = { const data: PaginatedData<Dump> = {
items, items,
total, total,

View File

@@ -0,0 +1,269 @@
import {
APIErrorCode,
APIException,
type Category,
type CreateCategoryRequest,
type Dump,
type UpdateCategoryRequest,
} from "../model/interfaces.ts";
import {
categoryRowToApi,
db,
DUMP_SELECT_COLUMNS_ALIASED as SELECT_COLS_ALIASED,
dumpRowToApi,
isCategoryRow,
isDumpRow,
} from "../model/db.ts";
import { isReservedSlug, isValidSlugFormat } from "../lib/reserved-slugs.ts";
import { VALIDATION } from "../config.ts";
function validateSlug(slug: string): void {
if (!isValidSlugFormat(slug)) {
throw new APIException(
APIErrorCode.BAD_REQUEST,
400,
"Slug must be lowercase letters, numbers and hyphens",
);
}
if (slug.length > VALIDATION.CATEGORY_SLUG_MAX) {
throw new APIException(
APIErrorCode.BAD_REQUEST,
400,
`Slug must be at most ${VALIDATION.CATEGORY_SLUG_MAX} characters`,
);
}
if (isReservedSlug(slug)) {
throw new APIException(
APIErrorCode.BAD_REQUEST,
400,
`Slug "${slug}" is reserved`,
);
}
}
function requireName(name: string): string {
const trimmed = name.trim();
if (!trimmed) {
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Name is required");
}
if (trimmed.length > VALIDATION.CATEGORY_NAME_MAX) {
throw new APIException(
APIErrorCode.BAD_REQUEST,
400,
`Name must be at most ${VALIDATION.CATEGORY_NAME_MAX} characters`,
);
}
return trimmed;
}
// Maps SQLite UNIQUE violations to a friendly 409 naming the offending field.
function uniqueConflict(err: unknown, slug: string, name: string): never {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("categories.name")) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
409,
`Name "${name}" is already in use`,
);
}
if (msg.includes("UNIQUE") || msg.includes("unique")) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
409,
`Slug "${slug}" is already in use`,
);
}
throw err as Error;
}
export function getCategoryById(id: string): Category {
const row = db.prepare(`SELECT * FROM categories WHERE id = ?;`).get(id);
if (!row || !isCategoryRow(row)) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Category not found");
}
return categoryRowToApi(row);
}
export function getCategoryBySlug(slug: string): Category {
const row = db.prepare(`SELECT * FROM categories WHERE slug = ?;`).get(slug);
if (!row || !isCategoryRow(row)) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Category not found");
}
return categoryRowToApi(row);
}
/** Resolve a slug to its category id, or undefined if no such category. */
export function getCategoryIdBySlug(slug: string): string | undefined {
const row = db.prepare(`SELECT id FROM categories WHERE slug = ?;`).get(slug);
return row && typeof (row as { id?: unknown }).id === "string"
? (row as { id: string }).id
: undefined;
}
export function listCategories(): Category[] {
const rows = db.prepare(
`SELECT * FROM categories ORDER BY position ASC, slug ASC;`,
).all();
return rows.filter(isCategoryRow).map(categoryRowToApi);
}
export function createCategory(req: CreateCategoryRequest): Category {
validateSlug(req.slug);
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 (?, ?, ?, ?, ?);`,
).run(
id,
req.slug,
name,
req.position ?? 0,
createdAt.toISOString(),
);
} catch (err) {
uniqueConflict(err, req.slug, name);
}
return {
id,
slug: req.slug,
name,
position: req.position ?? 0,
createdAt,
};
}
export function updateCategory(
id: string,
req: UpdateCategoryRequest,
): Category {
const existing = getCategoryById(id);
const newSlug = req.slug ?? existing.slug;
if (req.slug !== undefined && req.slug !== existing.slug) {
validateSlug(req.slug);
}
const newName = req.name !== undefined ? requireName(req.name) : existing.name;
const newPosition = req.position ?? existing.position;
const now = new Date();
try {
db.prepare(
`UPDATE categories SET slug = ?, name = ?, position = ?, updated_at = ? WHERE id = ?;`,
).run(
newSlug,
newName,
newPosition,
now.toISOString(),
id,
);
} catch (err) {
uniqueConflict(err, newSlug, newName);
}
return {
...existing,
slug: newSlug,
name: newName,
position: newPosition,
updatedAt: now,
};
}
export function deleteCategory(id: string): void {
const result = db.prepare(`DELETE FROM categories WHERE id = ?;`).run(id);
if (result.changes === 0) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Category not found");
}
}
/**
* Replace the set of categories a dump belongs to. Unknown category ids are
* rejected. An empty list clears all category assignments.
*/
export function setDumpCategories(
dumpId: string,
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;
if ((found?.count ?? 0) !== unique.length) {
throw new APIException(
APIErrorCode.BAD_REQUEST,
400,
"One or more categories do not exist",
);
}
}
db.prepare(`DELETE FROM dump_categories WHERE dump_id = ?;`).run(dumpId);
const insert = db.prepare(
`INSERT INTO dump_categories (dump_id, category_id) VALUES (?, ?);`,
);
for (const categoryId of unique) {
insert.run(dumpId, categoryId);
}
}
/** Fill `categoryIds` on each dump with a single batched lookup. */
export function attachCategoryIds(dumps: Dump[]): void {
if (dumps.length === 0) return;
const ids = dumps.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 }>;
const byDump = new Map<string, string[]>();
for (const { dump_id, category_id } of rows) {
const list = byDump.get(dump_id) ?? [];
list.push(category_id);
byDump.set(dump_id, list);
}
for (const dump of dumps) {
dump.categoryIds = byDump.get(dump.id) ?? [];
}
}
export function listDumpsByCategory(
slug: string,
page: number,
limit: number,
requestingUserId?: string,
): { items: Dump[]; total: number } {
const category = getCategoryBySlug(slug);
const offset = (page - 1) * limit;
const privacyClause = requestingUserId
? "(d.is_private = 0 OR d.user_id = ?)"
: "d.is_private = 0";
const privacyParams = requestingUserId ? [requestingUserId] : [];
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}
ORDER BY d.created_at DESC LIMIT ? OFFSET ?;`,
).all(category.id, ...privacyParams, limit, offset);
const totalRow = db.prepare(
`SELECT COUNT(*) as count
FROM dumps d
INNER JOIN dump_categories dc ON dc.dump_id = d.id
WHERE dc.category_id = ? AND ${privacyClause};`,
).get(category.id, ...privacyParams) as { count: number } | undefined;
const items = rows.filter(isDumpRow).map(dumpRowToApi);
attachCategoryIds(items);
return { items, total: totalRow?.count ?? 0 };
}

View File

@@ -33,6 +33,7 @@ import {
} from "../config.ts"; } from "../config.ts";
import { linkAttachments } from "./attachment-service.ts"; import { linkAttachments } from "./attachment-service.ts";
import { relinkBacklinks } from "./backlink-service.ts"; import { relinkBacklinks } from "./backlink-service.ts";
import { attachCategoryIds, setDumpCategories } from "./category-service.ts";
function isAllowedMime(mime: string): boolean { function isAllowedMime(mime: string): boolean {
return DUMP_ALLOWED_MIME_PREFIXES.some((p) => mime.startsWith(p)) || return DUMP_ALLOWED_MIME_PREFIXES.some((p) => mime.startsWith(p)) ||
@@ -78,6 +79,9 @@ export async function createUrlDump(
isPrivate ? 1 : 0, isPrivate ? 1 : 0,
); );
const categoryIds = [...new Set(request.categoryIds ?? [])];
setDumpCategories(dumpId, categoryIds);
const dump: Dump = { const dump: Dump = {
id: dumpId, id: dumpId,
kind: "url", kind: "url",
@@ -91,6 +95,7 @@ export async function createUrlDump(
voteCount: 0, voteCount: 0,
commentCount: 0, commentCount: 0,
isPrivate, isPrivate,
categoryIds,
}; };
if (!isPrivate) { if (!isPrivate) {
broadcastNewDump(dump); broadcastNewDump(dump);
@@ -110,6 +115,7 @@ export async function createFileDump(
userId: string, userId: string,
isPrivate = false, isPrivate = false,
title?: string, title?: string,
categoryIds: string[] = [],
): Promise<Dump> { ): Promise<Dump> {
if (!isAllowedMime(file.type)) { if (!isAllowedMime(file.type)) {
throw new APIException( throw new APIException(
@@ -130,6 +136,7 @@ export async function createFileDump(
const createdAt = new Date(); const createdAt = new Date();
const finalTitle = title?.trim() || file.name; const finalTitle = title?.trim() || file.name;
const slug = makeSlug(finalTitle, dumpId); const slug = makeSlug(finalTitle, dumpId);
const dedupedCategoryIds = [...new Set(categoryIds)];
await Deno.mkdir(DUMPS_DIR, { recursive: true }); await Deno.mkdir(DUMPS_DIR, { recursive: true });
const data = new Uint8Array(await file.arrayBuffer()); const data = new Uint8Array(await file.arrayBuffer());
@@ -153,6 +160,7 @@ export async function createFileDump(
file.size, file.size,
isPrivate ? 1 : 0, isPrivate ? 1 : 0,
); );
setDumpCategories(dumpId, dedupedCategoryIds);
} catch (err) { } catch (err) {
// Roll back the file if DB insert fails // Roll back the file if DB insert fails
await Deno.remove(`${DUMPS_DIR}/${dumpId}`).catch(() => {}); await Deno.remove(`${DUMPS_DIR}/${dumpId}`).catch(() => {});
@@ -173,6 +181,7 @@ export async function createFileDump(
voteCount: 0, voteCount: 0,
commentCount: 0, commentCount: 0,
isPrivate, isPrivate,
categoryIds: dedupedCategoryIds,
}; };
if (!isPrivate) { if (!isPrivate) {
broadcastNewDump(dump); broadcastNewDump(dump);
@@ -205,6 +214,7 @@ export function getDump(dumpId: string, requestingUserId?: string): Dump {
if (dump.isPrivate && dump.userId !== requestingUserId) { if (dump.isPrivate && dump.userId !== requestingUserId) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found"); throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
} }
attachCategoryIds([dump]);
return dump; return dump;
} }
@@ -229,10 +239,9 @@ export function searchDumps(
const totalRow = db.prepare( const totalRow = db.prepare(
`SELECT COUNT(*) as count FROM dumps WHERE (is_private = 0 OR user_id = ?) AND ${searchClause};`, `SELECT COUNT(*) as count FROM dumps WHERE (is_private = 0 OR user_id = ?) AND ${searchClause};`,
).get(requestingUserId, ...searchParams) as { count: number } | undefined; ).get(requestingUserId, ...searchParams) as { count: number } | undefined;
return { const items = rows.filter(isDumpRow).map(dumpRowToApi);
items: rows.filter(isDumpRow).map(dumpRowToApi), attachCategoryIds(items);
total: totalRow?.count ?? 0, return { items, total: totalRow?.count ?? 0 };
};
} else { } else {
const rows = db.prepare( const rows = db.prepare(
`SELECT ${SELECT_COLS} FROM dumps WHERE is_private = 0 AND ${searchClause} ORDER BY created_at DESC LIMIT ? OFFSET ?;`, `SELECT ${SELECT_COLS} FROM dumps WHERE is_private = 0 AND ${searchClause} ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
@@ -240,10 +249,9 @@ export function searchDumps(
const totalRow = db.prepare( const totalRow = db.prepare(
`SELECT COUNT(*) as count FROM dumps WHERE is_private = 0 AND ${searchClause};`, `SELECT COUNT(*) as count FROM dumps WHERE is_private = 0 AND ${searchClause};`,
).get(...searchParams) as { count: number } | undefined; ).get(...searchParams) as { count: number } | undefined;
return { const items = rows.filter(isDumpRow).map(dumpRowToApi);
items: rows.filter(isDumpRow).map(dumpRowToApi), attachCategoryIds(items);
total: totalRow?.count ?? 0, return { items, total: totalRow?.count ?? 0 };
};
} }
} }
@@ -277,7 +285,9 @@ export function listDumps(
); );
} }
return { items: rows.map(dumpRowToApi), total: totalRow?.count ?? 0 }; const items = rows.map(dumpRowToApi);
attachCategoryIds(items);
return { items, total: totalRow?.count ?? 0 };
} }
export async function updateDump( export async function updateDump(
@@ -314,6 +324,13 @@ export async function updateDump(
now.toISOString(), now.toISOString(),
dumpId, dumpId,
); );
if (request.categoryIds !== undefined) {
const ids = [...new Set(request.categoryIds)];
setDumpCategories(dumpId, ids);
updatedDump.categoryIds = ids;
} else {
attachCategoryIds([updatedDump]);
}
if (updatedDump.isPrivate && !dump.isPrivate) broadcastDumpDeleted(dumpId); if (updatedDump.isPrivate && !dump.isPrivate) broadcastDumpDeleted(dumpId);
else if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump); else if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
if (updatedDump.comment) { if (updatedDump.comment) {
@@ -382,6 +399,14 @@ export async function updateDump(
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found"); throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
} }
if (request.categoryIds !== undefined) {
const ids = [...new Set(request.categoryIds)];
setDumpCategories(dumpId, ids);
updatedDump.categoryIds = ids;
} else {
attachCategoryIds([updatedDump]);
}
if (updatedDump.isPrivate && !dump.isPrivate) broadcastDumpDeleted(dumpId); if (updatedDump.isPrivate && !dump.isPrivate) broadcastDumpDeleted(dumpId);
else if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump); else if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
if (updatedDump.comment) { if (updatedDump.comment) {
@@ -403,6 +428,7 @@ export async function replaceFileDump(
file: File, file: File,
comment: string | undefined, comment: string | undefined,
title?: string, title?: string,
categoryIds?: string[],
): Promise<Dump> { ): Promise<Dump> {
if (!isAllowedMime(file.type)) { if (!isAllowedMime(file.type)) {
throw new APIException( throw new APIException(
@@ -455,6 +481,14 @@ export async function replaceFileDump(
throw err; throw err;
} }
if (categoryIds !== undefined) {
const ids = [...new Set(categoryIds)];
setDumpCategories(dumpId, ids);
dump.categoryIds = ids;
} else {
attachCategoryIds([dump]);
}
if (comment) { if (comment) {
notifyMentions(dump.userId, comment, "dump", dumpId, finalTitle); notifyMentions(dump.userId, comment, "dump", dumpId, finalTitle);
linkAttachments(comment, dumpId); linkAttachments(comment, dumpId);
@@ -493,7 +527,9 @@ export function getDumpsByUser(
"Malformed dump data", "Malformed dump data",
); );
} }
return { items: rows.map(dumpRowToApi), total: totalRow?.count ?? 0 }; const items = rows.map(dumpRowToApi);
attachCategoryIds(items);
return { items, total: totalRow?.count ?? 0 };
} }
export function getVotedDumpsByUser( export function getVotedDumpsByUser(
@@ -544,7 +580,9 @@ export function getVotedDumpsByUser(
"Malformed dump data", "Malformed dump data",
); );
} }
return { items: rows.map(dumpRowToApi), total: totalRow?.count ?? 0 }; const items = rows.map(dumpRowToApi);
attachCategoryIds(items);
return { items, total: totalRow?.count ?? 0 };
} }
export async function refreshDumpMetadata(dumpId: string): Promise<Dump> { export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {

View File

@@ -21,6 +21,7 @@ import {
playlistRowToApi, playlistRowToApi,
userRowToApi, userRowToApi,
} from "../model/db.ts"; } from "../model/db.ts";
import { attachCategoryIds } from "./category-service.ts";
// ── Follow / unfollow a user ────────────────────────────────────────────────── // ── Follow / unfollow a user ──────────────────────────────────────────────────
@@ -139,26 +140,35 @@ export function getFollowedUsersDumpFeed(
followerId: string, followerId: string,
page: number, page: number,
limit: number, limit: number,
categoryId?: string,
): { items: Dump[]; total: number } { ): { items: Dump[]; total: number } {
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
// Optional category filter: join the membership table and constrain to it.
const catJoin = categoryId
? "INNER JOIN dump_categories dc ON dc.dump_id = d.id AND dc.category_id = ?"
: "";
const catParams = categoryId ? [categoryId] : [];
const rawRows = db.prepare( const rawRows = db.prepare(
`SELECT ${SELECT_COLS_ALIASED} `SELECT ${SELECT_COLS_ALIASED}
FROM dumps d FROM dumps d
INNER JOIN follows f ON f.followed_user_id = d.user_id INNER JOIN follows f ON f.followed_user_id = d.user_id
${catJoin}
WHERE f.follower_id = ? WHERE f.follower_id = ?
AND d.is_private = 0 AND d.is_private = 0
ORDER BY d.created_at DESC ORDER BY d.created_at DESC
LIMIT ? OFFSET ?;`, LIMIT ? OFFSET ?;`,
).all(followerId, limit, offset); ).all(...catParams, followerId, limit, offset);
const totalRow = db.prepare( const totalRow = db.prepare(
`SELECT COUNT(*) as count `SELECT COUNT(*) as count
FROM dumps d FROM dumps d
INNER JOIN follows f ON f.followed_user_id = d.user_id INNER JOIN follows f ON f.followed_user_id = d.user_id
${catJoin}
WHERE f.follower_id = ? WHERE f.follower_id = ?
AND d.is_private = 0;`, AND d.is_private = 0;`,
).get(followerId) as { count: number } | undefined; ).get(...catParams, followerId) as { count: number } | undefined;
const userFeedRows = rawRows as Parameters<typeof isDumpRow>[0][]; const userFeedRows = rawRows as Parameters<typeof isDumpRow>[0][];
if (!userFeedRows.every(isDumpRow)) { if (!userFeedRows.every(isDumpRow)) {
@@ -168,7 +178,9 @@ export function getFollowedUsersDumpFeed(
"Malformed dump data", "Malformed dump data",
); );
} }
return { items: userFeedRows.map(dumpRowToApi), total: totalRow?.count ?? 0 }; const items = userFeedRows.map(dumpRowToApi);
attachCategoryIds(items);
return { items, total: totalRow?.count ?? 0 };
} }
// ── Followed-playlists dump feed ────────────────────────────────────────────── // ── Followed-playlists dump feed ──────────────────────────────────────────────
@@ -177,22 +189,29 @@ export function getFollowedPlaylistsDumpFeed(
followerId: string, followerId: string,
page: number, page: number,
limit: number, limit: number,
categoryId?: string,
): { items: Dump[]; total: number } { ): { items: Dump[]; total: number } {
const offset = (page - 1) * limit; 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 rawRows = db.prepare( const rawRows = db.prepare(
`SELECT ${SELECT_COLS_ALIASED} `SELECT ${SELECT_COLS_ALIASED}
FROM dumps d FROM dumps d
INNER JOIN playlist_dumps pd ON pd.dump_id = d.id INNER JOIN playlist_dumps pd ON pd.dump_id = d.id
INNER JOIN playlists p ON p.id = pd.playlist_id INNER JOIN playlists p ON p.id = pd.playlist_id
INNER JOIN follows f ON f.followed_playlist_id = p.id INNER JOIN follows f ON f.followed_playlist_id = p.id
${catJoin}
WHERE f.follower_id = ? WHERE f.follower_id = ?
AND p.is_public = 1 AND p.is_public = 1
AND d.is_private = 0 AND d.is_private = 0
GROUP BY d.id GROUP BY d.id
ORDER BY MAX(pd.added_at) DESC ORDER BY MAX(pd.added_at) DESC
LIMIT ? OFFSET ?;`, LIMIT ? OFFSET ?;`,
).all(followerId, limit, offset); ).all(...catParams, followerId, limit, offset);
const totalRow = db.prepare( const totalRow = db.prepare(
`SELECT COUNT(DISTINCT d.id) as count `SELECT COUNT(DISTINCT d.id) as count
@@ -200,10 +219,11 @@ export function getFollowedPlaylistsDumpFeed(
INNER JOIN playlist_dumps pd ON pd.dump_id = d.id INNER JOIN playlist_dumps pd ON pd.dump_id = d.id
INNER JOIN playlists p ON p.id = pd.playlist_id INNER JOIN playlists p ON p.id = pd.playlist_id
INNER JOIN follows f ON f.followed_playlist_id = p.id INNER JOIN follows f ON f.followed_playlist_id = p.id
${catJoin}
WHERE f.follower_id = ? WHERE f.follower_id = ?
AND p.is_public = 1 AND p.is_public = 1
AND d.is_private = 0;`, AND d.is_private = 0;`,
).get(followerId) as { count: number } | undefined; ).get(...catParams, followerId) as { count: number } | undefined;
if (!rawRows.every(isDumpRow)) { if (!rawRows.every(isDumpRow)) {
throw new APIException( throw new APIException(
@@ -212,10 +232,9 @@ export function getFollowedPlaylistsDumpFeed(
"Malformed dump data", "Malformed dump data",
); );
} }
return { const items = rawRows.map(dumpRowToApi);
items: rawRows.map(dumpRowToApi), attachCategoryIds(items);
total: totalRow?.count ?? 0, return { items, total: totalRow?.count ?? 0 };
};
} }
// ── Followed playlists (as playlist objects) ────────────────────────────────── // ── Followed playlists (as playlist objects) ──────────────────────────────────

View File

@@ -1570,12 +1570,36 @@ body.has-fab .page-content {
} }
/* ── Profile (own) page ── */ /* ── Profile (own) page ── */
.profile-username-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.profile-username { .profile-username {
margin: 0; margin: 0;
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600; font-weight: 600;
} }
/* Role badge — elevated roles only (see RoleChip) */
.profile-role-chip {
padding: 0.1rem 0.55rem;
border-radius: 999px;
background: var(--color-accent);
color: var(--color-on-accent);
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
}
.profile-role-chip--moderator {
background: var(--color-success);
}
.profile-header .btn-border { .profile-header .btn-border {
margin-top: 0.5rem; margin-top: 0.5rem;
} }
@@ -3840,30 +3864,46 @@ body.has-fab .page-content {
padding-bottom: 0; padding-bottom: 0;
} }
/* Child list: indented, left edge aligns with parent avatar center */ /* Child list: indented so connector lines have room on the left.
The vertical line sits at the list's left:0, centered under the parent
avatar (avatar center 12px - 1px half-line = 11px). */
.invite-tree-node > .invite-tree { .invite-tree-node > .invite-tree {
position: relative; margin-left: 11px;
margin-left: 11px; /* center 2px line on 24px avatar: 12px - 1px */
padding-left: calc(12px + 0.5rem); padding-left: calc(12px + 0.5rem);
} }
/* Vertical connector: starts below parent avatar, stops at last child avatar center */ /* Connectors are owned per child node (not by the list) so they always anchor
.invite-tree-node > .invite-tree::before { to that child's own avatar center, regardless of how deep its subtree is.
content: ""; --avatar-center = node top padding (0.2rem) + half the 24px avatar (12px). */
position: absolute; .invite-tree-node > .invite-tree > .invite-tree-node {
left: 0; --avatar-center: calc(0.2rem + 12px);
top: 0;
bottom: 12px;
width: 2px;
background: var(--color-border-subtle);
} }
/* Horizontal connector: from vertical line to each child's avatar left edge */ /* Vertical segment: drops from the top of the child node to its avatar center,
joining the line coming down from the parent / previous sibling. */
.invite-tree-node > .invite-tree > .invite-tree-node::before { .invite-tree-node > .invite-tree > .invite-tree-node::before {
content: ""; content: "";
position: absolute; position: absolute;
left: calc(-12px - 0.5rem); left: calc(-12px - 0.5rem);
top: calc(0.2rem + 11px); /* centered on 24px avatar */ top: 0;
height: var(--avatar-center);
width: 2px;
background: var(--color-border-subtle);
}
/* Non-last children extend the vertical segment through their full height
(including any subtree) so it reaches the next sibling. */
.invite-tree-node > .invite-tree > .invite-tree-node:not(:last-child)::before {
height: auto;
bottom: 0;
}
/* Horizontal connector: from the vertical line to the child's avatar. */
.invite-tree-node > .invite-tree > .invite-tree-node::after {
content: "";
position: absolute;
left: calc(-12px - 0.5rem);
top: calc(var(--avatar-center) - 1px); /* straddle center with the 2px bar */
width: calc(12px + 0.5rem); width: calc(12px + 0.5rem);
height: 2px; height: 2px;
background: var(--color-border-subtle); background: var(--color-border-subtle);
@@ -4836,3 +4876,325 @@ body.has-fab .page-content {
font-weight: 600; font-weight: 600;
opacity: 0.7; opacity: 0.7;
} }
/* ── Categories ─────────────────────────────────────────────────────────── */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* Scope switcher — the leading item of the feed-sort tab row. The trigger
reuses .feed-sort-btn so it shares the tabs' exact pill metrics; a thin
divider sets it apart from the sort tabs that follow. */
.category-switcher {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.4rem;
flex-shrink: 0;
}
.category-switcher-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.category-switcher-btn:hover,
.category-switcher-btn--open {
border-color: var(--color-accent);
color: var(--color-text);
}
.category-switcher-label {
max-width: 10rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Caret tracks the open state. */
.category-switcher-caret {
width: 0;
height: 0;
border-left: 0.26rem solid transparent;
border-right: 0.26rem solid transparent;
border-top: 0.3rem solid currentColor;
transition: transform 0.15s ease;
}
.category-switcher-btn--open .category-switcher-caret {
transform: rotate(180deg);
}
/* Vertical rule separating the scope selector from the sort tabs. */
.feed-sort-sep {
width: 2px;
height: 1.05rem;
background: var(--color-border);
border-radius: 1px;
flex-shrink: 0;
}
/* Themed popover (fixed-positioned so the row's overflow can't clip it). */
.category-switcher-menu {
position: fixed;
z-index: 200;
min-width: 11rem;
max-height: 60vh;
overflow-y: auto;
margin: 0;
padding: 0.35rem;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.15rem;
background: var(--color-surface);
border: 2px solid var(--color-border-subtle);
border-radius: 10px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.category-switcher-option {
display: block;
width: 100%;
text-align: left;
padding: 0.45rem 0.75rem;
border: none;
border-radius: 7px;
background: transparent;
color: var(--color-text);
font-size: 0.9rem;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
transition: background 0.12s;
}
.category-switcher-option:hover {
background: var(--color-header-user-bg-hover);
}
.category-switcher-option--active {
background: var(--color-accent);
color: var(--color-on-accent);
}
/* Toggle chips used in the dump create/edit forms */
.category-select {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.category-chip-toggle {
padding: 0.25rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-bg);
color: var(--color-text-secondary);
font-size: 0.85rem;
cursor: pointer;
}
.category-chip-toggle:hover {
border-color: var(--color-accent);
}
.category-chip-toggle--active {
background: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-on-accent);
}
/* Read-only category chips on dump cards */
.dump-card-categories {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-top: 0.35rem;
}
.dump-card-category-chip {
padding: 0.1rem 0.5rem;
border-radius: 999px;
background: var(--color-header-user-bg);
color: var(--color-text-secondary);
text-decoration: none;
font-size: 0.75rem;
}
.dump-card-category-chip:hover {
background: var(--color-accent);
color: var(--color-on-accent);
}
/* Admin category management — chips that expand into an inline editor */
.category-chips {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 0.5rem;
}
.category-chip {
display: inline-flex;
align-items: center;
padding: 0.35rem 0.85rem;
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-bg);
color: var(--color-text);
font-size: 0.9rem;
font-family: inherit;
line-height: 1.4;
cursor: pointer;
transition: border-color 0.15s, background 0.15s, color 0.15s, transform 0.1s;
}
.category-chip:hover:not(:disabled) {
border-color: var(--color-accent);
color: var(--color-accent);
}
.category-chip:active:not(:disabled) {
transform: scale(0.96);
}
.category-chip:disabled {
opacity: 0.4;
cursor: default;
}
/* Inherits the base chip's font-size/padding so its height matches exactly;
only the glyph weight/color set it apart. */
.category-chip--add {
font-weight: 700;
color: var(--color-text-secondary);
}
.category-chip--add:hover:not(:disabled) {
background: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-on-accent);
}
/* Inline editor: the expanded form of a chip */
.category-editor-wrap {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 0;
}
.category-editor {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem 0.3rem 0.2rem 0.7rem;
border: 1px solid var(--color-accent);
border-radius: 999px;
background: var(--color-bg);
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--color-accent) 16%, transparent);
transform-origin: left center;
animation: category-editor-in 0.18s ease-out;
}
@keyframes category-editor-in {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
.category-editor-input {
min-width: 0;
border: none;
background: transparent;
color: var(--color-text);
font-size: 0.9rem;
font-family: inherit;
padding: 0.2rem 0;
outline: none;
}
.category-editor-input--name {
width: 8.5rem;
font-weight: 600;
}
.category-editor-input--slug {
width: 6.5rem;
font-family: var(--font-mono, monospace);
color: var(--color-text-secondary);
}
.category-editor-sep {
color: var(--color-text-secondary);
opacity: 0.5;
}
.category-editor-actions {
display: inline-flex;
align-items: center;
gap: 0.1rem;
}
.category-editor-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
padding: 0;
border: none;
border-radius: 999px;
background: transparent;
color: var(--color-text-secondary);
font-size: 0.8rem;
line-height: 1;
cursor: pointer;
transition: background 0.15s, color 0.15s, filter 0.15s;
}
.category-editor-btn:hover:not(:disabled) {
background: var(--color-header-user-bg);
}
.category-editor-btn:disabled {
opacity: 0.4;
cursor: default;
}
.category-editor-btn--save {
background: var(--color-accent);
color: var(--color-on-accent);
}
.category-editor-btn--save:hover:not(:disabled) {
filter: brightness(1.1);
}
.category-editor-btn--save:disabled {
background: var(--color-border);
color: var(--color-text-secondary);
opacity: 1;
}
.category-editor-btn--delete:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-danger, #c0392b) 18%, transparent);
color: var(--color-danger, #c0392b);
}

View File

@@ -1,5 +1,11 @@
import { lazy, Suspense } from "react"; import { lazy, Suspense } from "react";
import { BrowserRouter, Navigate, Route, Routes } from "react-router"; import {
BrowserRouter,
Navigate,
Route,
Routes,
useParams,
} from "react-router";
import { RestrictedGuest } from "./pages/RestrictedGuest.tsx"; import { RestrictedGuest } from "./pages/RestrictedGuest.tsx";
import { RestrictedLoggedIn } from "./pages/RestrictedLoggedIn.tsx"; import { RestrictedLoggedIn } from "./pages/RestrictedLoggedIn.tsx";
@@ -10,6 +16,7 @@ import { AuthProvider } from "./contexts/AuthProvider.tsx";
import { PlayerProvider } from "./contexts/PlayerProvider.tsx"; import { PlayerProvider } from "./contexts/PlayerProvider.tsx";
import { WSProvider } from "./contexts/WSProvider.tsx"; import { WSProvider } from "./contexts/WSProvider.tsx";
import { FollowProvider } from "./contexts/FollowProvider.tsx"; import { FollowProvider } from "./contexts/FollowProvider.tsx";
import { CategoriesProvider } from "./contexts/CategoriesProvider.tsx";
import { ThemeProvider } from "./contexts/ThemeProvider.tsx"; import { ThemeProvider } from "./contexts/ThemeProvider.tsx";
import { GlobalPlayer } from "./components/GlobalPlayer.tsx"; import { GlobalPlayer } from "./components/GlobalPlayer.tsx";
import { DumpFab } from "./components/DumpFab.tsx"; import { DumpFab } from "./components/DumpFab.tsx";
@@ -69,12 +76,29 @@ const NotFound = lazy(() =>
import("./pages/NotFound.tsx").then((m) => ({ default: m.NotFound })) import("./pages/NotFound.tsx").then((m) => ({ default: m.NotFound }))
); );
function IndexRedirect() { // Default feed tab for the current viewer ("followed" needs a session).
function useResolvedDefaultTab() {
const { user } = useAuth(); const { user } = useAuth();
const [preferredTab] = useDefaultFeedTab(); const [preferredTab] = useDefaultFeedTab();
// "followed" requires a session, so fall back to "hot" for guests. return preferredTab === "followed" && !user ? "hot" : preferredTab;
const tab = preferredTab === "followed" && !user ? "hot" : preferredTab; }
return <Navigate to={`/~/${tab}`} replace />;
function IndexRedirect() {
return <Navigate to={`/~/${useResolvedDefaultTab()}`} replace />;
}
// Bare `/<slug>` lands on that category's default feed tab.
function CategoryRedirect() {
const { categorySlug } = useParams();
return <Navigate to={`/${categorySlug}/${useResolvedDefaultTab()}`} replace />;
}
// Both `/~/:feedTab` (all) and `/:categorySlug/:feedTab` render the same feed.
// Keying by scope remounts the feed when switching categories so it refetches,
// while switching tabs within a scope keeps it mounted (client-side re-sort).
function ScopedIndex() {
const { categorySlug } = useParams();
return <Index key={categorySlug ?? "~"} categorySlug={categorySlug} />;
} }
function AppRoutes() { function AppRoutes() {
@@ -83,7 +107,7 @@ function AppRoutes() {
<Suspense> <Suspense>
<Routes> <Routes>
<Route path="/" element={<IndexRedirect />} /> <Route path="/" element={<IndexRedirect />} />
<Route path="/~/:feedTab" element={<Index />} /> <Route path="/~/:feedTab" element={<ScopedIndex />} />
<Route path="/dumps/:selectedDump" element={<Dump />} /> <Route path="/dumps/:selectedDump" element={<Dump />} />
<Route <Route
path="/dumps/:selectedDump/edit" path="/dumps/:selectedDump/edit"
@@ -132,6 +156,8 @@ function AppRoutes() {
</RestrictedLoggedIn> </RestrictedLoggedIn>
} }
/> />
<Route path="/:categorySlug/:feedTab" element={<ScopedIndex />} />
<Route path="/:categorySlug" element={<CategoryRedirect />} />
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>
</Suspense> </Suspense>
@@ -147,9 +173,11 @@ function App() {
<PlayerProvider> <PlayerProvider>
<WSProvider> <WSProvider>
<FollowProvider> <FollowProvider>
<BrowserRouter> <CategoriesProvider>
<AppRoutes /> <BrowserRouter>
</BrowserRouter> <AppRoutes />
</BrowserRouter>
</CategoriesProvider>
</FollowProvider> </FollowProvider>
</WSProvider> </WSProvider>
<GlobalPlayer /> <GlobalPlayer />

Binary file not shown.

View File

@@ -0,0 +1,45 @@
import { Trans } from "@lingui/react/macro";
import { useCategories } from "../hooks/useCategories.ts";
/**
* Multi-select of categories rendered as toggle chips. Renders nothing when no
* categories are configured, so dump forms stay unchanged until an admin adds
* the first category.
*/
export function CategorySelect(
{ selected, onToggle, disabled }: {
selected: Set<string>;
onToggle: (id: string) => void;
disabled?: boolean;
},
) {
const { categories } = useCategories();
if (categories.length === 0) return null;
return (
<div className="form-field">
<span className="form-label">
<Trans>Categories</Trans>
</span>
<div className="category-select">
{categories.map((category) => {
const isActive = selected.has(category.id);
return (
<button
type="button"
key={category.id}
className={`category-chip-toggle${
isActive ? " category-chip-toggle--active" : ""
}`}
onClick={() => onToggle(category.id)}
disabled={disabled}
aria-pressed={isActive}
>
{category.name}
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,137 @@
import {
type CSSProperties,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { useNavigate, useParams } from "react-router";
import { t } from "@lingui/core/macro";
import { useCategories } from "../hooks/useCategories.ts";
/**
* Header dropdown that switches the feed scope between "All" and a category,
* preserving the active feed tab (e.g. /music/new → /cinema/new). Rendered as
* the leading item of the feed-sort tab row so it reads as part of the bar.
*
* A custom popover (not a native <select>) keeps it on-theme; the menu uses
* fixed positioning so the row's `overflow-x: auto` can't clip it. `~` is the
* sentinel value for the all-categories feed. Hidden when no categories exist.
*/
export function CategorySwitcher() {
const { categories } = useCategories();
const navigate = useNavigate();
const { categorySlug, feedTab } = useParams();
const [open, setOpen] = useState(false);
const [menuStyle, setMenuStyle] = useState<CSSProperties>({});
const wrapRef = useRef<HTMLDivElement>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLUListElement>(null);
// Close on outside click / Escape. The menu is portaled out of the wrapper,
// so check it separately or selecting an option would close before navigating.
useEffect(() => {
if (!open) return;
function onDown(e: MouseEvent) {
const target = e.target as Node;
if (
wrapRef.current && !wrapRef.current.contains(target) &&
menuRef.current && !menuRef.current.contains(target)
) {
setOpen(false);
}
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
// Anchor the fixed-position menu to the trigger; keep it aligned on scroll.
useLayoutEffect(() => {
if (!open) return;
const update = () => {
const r = btnRef.current?.getBoundingClientRect();
if (r) setMenuStyle({ top: r.bottom + 6, left: r.left });
};
update();
globalThis.addEventListener("scroll", update, true);
globalThis.addEventListener("resize", update);
return () => {
globalThis.removeEventListener("scroll", update, true);
globalThis.removeEventListener("resize", update);
};
}, [open]);
if (categories.length === 0) return null;
const value = categorySlug ?? "~";
const tab = feedTab ?? "hot";
const currentLabel = categorySlug
? categories.find((c) => c.slug === categorySlug)?.name ?? categorySlug
: t`All`;
const options = [
{ value: "~", label: t`All` },
...categories.map((c) => ({ value: c.slug, label: c.name })),
];
function select(slug: string) {
setOpen(false);
navigate(`/${slug}/${tab}`);
}
return (
<div className="category-switcher" ref={wrapRef}>
<button
ref={btnRef}
type="button"
className={`feed-sort-btn category-switcher-btn${
open ? " category-switcher-btn--open" : ""
}`}
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
title={t`Category`}
>
<span className="category-switcher-label">{currentLabel}</span>
<span className="category-switcher-caret" aria-hidden="true" />
</button>
{open && createPortal(
<ul
ref={menuRef}
className="category-switcher-menu"
role="listbox"
style={menuStyle}
>
{options.map((o) => (
<li key={o.value}>
<button
type="button"
role="option"
aria-selected={value === o.value}
className={`category-switcher-option${
value === o.value ? " category-switcher-option--active" : ""
}`}
onClick={() => select(o.value)}
>
{o.label}
</button>
</li>
))}
</ul>,
document.body,
)}
<span className="feed-sort-sep" aria-hidden="true" />
</div>
);
}

View File

@@ -4,6 +4,7 @@ import type { Dump } from "../model.ts";
import { relativeTime } from "../utils/relativeTime.ts"; import { relativeTime } from "../utils/relativeTime.ts";
import { dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts"; import { dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { useCategories } from "../hooks/useCategories.ts";
import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts"; import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts";
import FilePreview from "./FilePreview.tsx"; import FilePreview from "./FilePreview.tsx";
import RichContentCard from "./RichContentCard.tsx"; import RichContentCard from "./RichContentCard.tsx";
@@ -28,6 +29,10 @@ export function DumpCard(
) { ) {
const navigate = useNavigate(); const navigate = useNavigate();
const { token } = useAuth(); const { token } = useAuth();
const { byId } = useCategories();
const dumpCategories = (dump.categoryIds ?? [])
.map((id) => byId.get(id))
.filter((c) => c !== undefined);
const unread = !isOwner && isRecent(dump.createdAt) && const unread = !isOwner && isRecent(dump.createdAt) &&
!isDumpVisited(dump.id); !isDumpVisited(dump.id);
@@ -115,6 +120,22 @@ export function DumpCard(
</span> </span>
)} )}
</div> </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>
)}
</div> </div>
</div> </div>
</li> </li>

View File

@@ -25,6 +25,7 @@ import { MediaPlayer } from "./MediaPlayer.tsx";
import type { RichContent } from "../model.ts"; import type { RichContent } from "../model.ts";
import { FileDropZone } from "./FileDropZone.tsx"; import { FileDropZone } from "./FileDropZone.tsx";
import { Modal } from "./Modal.tsx"; import { Modal } from "./Modal.tsx";
import { CategorySelect } from "./CategorySelect.tsx";
import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx"; import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx";
import { import {
expectOk, expectOk,
@@ -117,6 +118,16 @@ export function DumpCreateModal(
const url = watch("url"); const url = watch("url");
const file = watch("file"); const file = watch("file");
const [selectedCategoryIds, setSelectedCategoryIds] = useState<Set<string>>(
new Set(),
);
const toggleCategory = (id: string) =>
setSelectedCategoryIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
// Playlist phase state // Playlist phase state
const [memberships, setMemberships] = useState<PlaylistMembership[]>([]); const [memberships, setMemberships] = useState<PlaylistMembership[]>([]);
const [playlistsLoading, setPlaylistsLoading] = useState(false); const [playlistsLoading, setPlaylistsLoading] = useState(false);
@@ -219,6 +230,7 @@ export function DumpCreateModal(
url: normalizedUrl, url: normalizedUrl,
comment: comment.trim() || undefined, comment: comment.trim() || undefined,
isPrivate, isPrivate,
categoryIds: [...selectedCategoryIds],
}; };
res = await authFetch(`${API_URL}/api/dumps`, { res = await authFetch(`${API_URL}/api/dumps`, {
method: "POST", method: "POST",
@@ -235,6 +247,7 @@ export function DumpCreateModal(
if (comment.trim()) formData.append("comment", comment.trim()); if (comment.trim()) formData.append("comment", comment.trim());
if (title.trim()) formData.append("title", title.trim()); if (title.trim()) formData.append("title", title.trim());
formData.append("isPrivate", String(isPrivate)); formData.append("isPrivate", String(isPrivate));
formData.append("categoryIds", JSON.stringify([...selectedCategoryIds]));
res = await authFetch(`${API_URL}/api/dumps`, { res = await authFetch(`${API_URL}/api/dumps`, {
method: "POST", method: "POST",
body: formData, body: formData,
@@ -435,6 +448,12 @@ export function DumpCreateModal(
disabled={submitting} disabled={submitting}
/> />
<CategorySelect
selected={selectedCategoryIds}
onToggle={toggleCategory}
disabled={submitting}
/>
<VisibilityToggle<CreateValues> <VisibilityToggle<CreateValues>
name="isPublic" name="isPublic"
disabled={submitting} disabled={submitting}

View File

@@ -1,17 +1,30 @@
import { useNavigate, useParams } from "react-router";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { FEED_TABS, type FeedTab } from "../config/feedTabs.ts"; import { FEED_TABS, type FeedTab } from "../config/feedTabs.ts";
import { useTabParam } from "../hooks/useTabParam.ts";
import { useDefaultFeedTab } from "../hooks/useDefaultFeedTab.ts"; import { useDefaultFeedTab } from "../hooks/useDefaultFeedTab.ts";
import { TabBar } from "./TabBar.tsx"; import { TabBar } from "./TabBar.tsx";
import { CategorySwitcher } from "./CategorySwitcher.tsx";
/**
* Feed sort tabs (hot/new/journal/followed). Navigation stays within the
* current scope: `~` for all categories, or the active category slug — so the
* tabs link to `/~/new`, `/music/new`, etc.
*/
export function FeedTabBar() { export function FeedTabBar() {
const { user } = useAuth(); const { user } = useAuth();
const navigate = useNavigate();
const { categorySlug, feedTab } = useParams();
const [preferredTab] = useDefaultFeedTab(); const [preferredTab] = useDefaultFeedTab();
const defaultTab: FeedTab = preferredTab === "followed" && !user const defaultTab: FeedTab = preferredTab === "followed" && !user
? "hot" ? "hot"
: preferredTab; : preferredTab;
const [tab, setTab] = useTabParam<FeedTab>(FEED_TABS, defaultTab); const active: FeedTab = (FEED_TABS as readonly string[]).includes(feedTab ?? "")
? (feedTab as FeedTab)
: defaultTab;
const scope = categorySlug ?? "~";
const tabs = [ const tabs = [
{ key: "hot" as FeedTab, label: <Trans>Hot</Trans> }, { key: "hot" as FeedTab, label: <Trans>Hot</Trans> },
@@ -25,8 +38,9 @@ export function FeedTabBar() {
return ( return (
<TabBar <TabBar
tabs={tabs} tabs={tabs}
activeTab={tab} activeTab={active}
onChange={setTab} onChange={(t) => navigate(`/${scope}/${t}`)}
leading={<CategorySwitcher />}
/> />
); );
} }

View File

@@ -4,7 +4,7 @@ import { Plural, Trans } from "@lingui/react/macro";
import type { Dump } from "../model.ts"; import type { Dump } from "../model.ts";
import { API_URL } from "../config/api.ts"; import { API_URL } from "../config/api.ts";
import { relativeTime } from "../utils/relativeTime.ts"; import { relativeTime } from "../utils/relativeTime.ts";
import { dumpFileUrl, dumpUrl } from "../utils/urls.ts"; import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts"; import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts";
import { hasQuote, hasThumbnail, type JournalShape } from "../utils/journalLayout.ts"; import { hasQuote, hasThumbnail, type JournalShape } from "../utils/journalLayout.ts";
@@ -44,6 +44,8 @@ export function JournalCard(
const rawThumbnail = const rawThumbnail =
dump.kind === "file" && dump.fileMime?.startsWith("image/") dump.kind === "file" && dump.fileMime?.startsWith("image/")
? dumpFileUrl(dump, token) ? dumpFileUrl(dump, token)
: dump.thumbnailMime
? dumpThumbnailUrl(dump, token)
: (dump.richContent?.thumbnailUrl ?? null); : (dump.richContent?.thumbnailUrl ?? null);
// Route external HTTP thumbnails through the server proxy to avoid // Route external HTTP thumbnails through the server proxy to avoid

View File

@@ -11,14 +11,19 @@ interface TabBarProps<T extends string> {
onChange: (key: T) => void; onChange: (key: T) => void;
className?: string; className?: string;
innerClassName?: string; innerClassName?: string;
// Optional content rendered inside the row, before the tabs (e.g. a scope
// selector). Sits in the same flex line so it scrolls and aligns with them.
leading?: ReactNode;
} }
export function TabBar<T extends string>( export function TabBar<T extends string>(
{ tabs, activeTab, onChange, className, innerClassName }: TabBarProps<T>, { tabs, activeTab, onChange, className, innerClassName, leading }:
TabBarProps<T>,
) { ) {
return ( return (
<div className={`feed-sort-scroller${className ? ` ${className}` : ""}`}> <div className={`feed-sort-scroller${className ? ` ${className}` : ""}`}>
<div className={`feed-sort${innerClassName ? ` ${innerClassName}` : ""}`}> <div className={`feed-sort${innerClassName ? ` ${innerClassName}` : ""}`}>
{leading}
{tabs.map(({ key, label }) => ( {tabs.map(({ key, label }) => (
<button <button
key={key} key={key}

View File

@@ -38,4 +38,6 @@ export const VALIDATION = {
PLAYLIST_DESCRIPTION_MAX: 2000, PLAYLIST_DESCRIPTION_MAX: 2000,
COMMENT_BODY_MAX: 5000, COMMENT_BODY_MAX: 5000,
USER_DESCRIPTION_MAX: 2000, USER_DESCRIPTION_MAX: 2000,
CATEGORY_NAME_MAX: 50,
CATEGORY_SLUG_MAX: 50,
} as const; } as const;

View File

@@ -0,0 +1,18 @@
import { createContext } from "react";
import type { Category } from "../model.ts";
export interface CategoriesContextValue {
categories: Category[];
// Lookup by id for resolving dump category chips.
byId: Map<string, Category>;
isLoaded: boolean;
// Re-fetch the list (used after admin create/update/delete).
refresh: () => Promise<void>;
}
export const CategoriesContext = createContext<CategoriesContextValue>({
categories: [],
byId: new Map(),
isLoaded: false,
refresh: async () => {},
});

View File

@@ -0,0 +1,53 @@
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import {
CategoriesContext,
type CategoriesContextValue,
} from "./CategoriesContext.ts";
import { API_URL } from "../config/api.ts";
import {
type Category,
deserializeCategory,
type RawCategory,
} from "../model.ts";
export function CategoriesProvider({ children }: { children: ReactNode }) {
const [categories, setCategories] = useState<Category[]>([]);
const [isLoaded, setIsLoaded] = useState(false);
const load = useCallback(async (signal?: AbortSignal) => {
try {
const res = await fetch(`${API_URL}/api/categories`, { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
if (!body.success) return;
setCategories((body.data as RawCategory[]).map(deserializeCategory));
setIsLoaded(true);
} catch (err) {
if ((err as Error).name !== "AbortError") setIsLoaded(true);
}
}, []);
useEffect(() => {
const controller = new AbortController();
load(controller.signal);
return () => controller.abort();
}, [load]);
const refresh = useCallback(() => load(), [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],
);
return (
<CategoriesContext.Provider value={value}>
{children}
</CategoriesContext.Provider>
);
}

View File

@@ -0,0 +1,4 @@
import { useContext } from "react";
import { CategoriesContext } from "../contexts/CategoriesContext.ts";
export const useCategories = () => useContext(CategoriesContext);

File diff suppressed because one or more lines are too long

View File

@@ -18,8 +18,8 @@ msgid "[deleted]"
msgstr "[deleted]" msgstr "[deleted]"
#. placeholder {0}: dump.commentCount #. placeholder {0}: dump.commentCount
#: src/components/DumpCard.tsx:105 #: src/components/DumpCard.tsx:110
#: src/components/JournalCard.tsx:110 #: src/components/JournalCard.tsx:112
msgid "{0, plural, one {# comment} other {# comments}}" msgid "{0, plural, one {# comment} other {# comments}}"
msgstr "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, one {# comment} other {# comments}}"
@@ -43,13 +43,13 @@ msgid "{visibleCount, plural, one {# comment} other {# comments}}"
msgstr "{visibleCount, plural, one {# comment} other {# comments}}" msgstr "{visibleCount, plural, one {# comment} other {# comments}}"
#: src/pages/PlaylistDetail.tsx:570 #: src/pages/PlaylistDetail.tsx:570
#: src/pages/UserPublicProfile.tsx:741 #: src/pages/UserPublicProfile.tsx:749
msgid "← Back" msgid "← Back"
msgstr "← Back" msgstr "← Back"
#: src/pages/Dump.tsx:259 #: src/pages/Dump.tsx:261
#: src/pages/Dump.tsx:472 #: src/pages/Dump.tsx:490
#: src/pages/DumpEdit.tsx:178 #: src/pages/DumpEdit.tsx:179
msgid "← Back to all dumps" msgid "← Back to all dumps"
msgstr "← Back to all dumps" msgstr "← Back to all dumps"
@@ -59,7 +59,7 @@ msgstr "← Back to all dumps"
msgid "← Back to profile" msgid "← Back to profile"
msgstr "← Back to profile" msgstr "← Back to profile"
#: src/pages/UserPublicProfile.tsx:114 #: src/pages/UserPublicProfile.tsx:115
msgid "+ Invite someone" msgid "+ Invite someone"
msgstr "+ Invite someone" msgstr "+ Invite someone"
@@ -67,7 +67,7 @@ msgstr "+ Invite someone"
msgid "+ New playlist" msgid "+ New playlist"
msgstr "+ New playlist" msgstr "+ New playlist"
#: src/pages/Dump.tsx:327 #: src/pages/Dump.tsx:332
msgid "+ Playlist" msgid "+ Playlist"
msgstr "+ Playlist" msgstr "+ Playlist"
@@ -125,7 +125,7 @@ msgstr "a comment"
msgid "a post" msgid "a post"
msgstr "a post" msgstr "a post"
#: src/pages/UserPublicProfile.tsx:1120 #: src/pages/UserPublicProfile.tsx:1131
msgid "Account" msgid "Account"
msgstr "Account" msgstr "Account"
@@ -133,24 +133,29 @@ msgstr "Account"
msgid "Add a comment…" msgid "Add a comment…"
msgstr "Add a comment…" msgstr "Add a comment…"
#: src/pages/UserPublicProfile.tsx:816 #: src/pages/UserPublicProfile.tsx:827
msgid "Add email…" msgid "Add email…"
msgstr "Add email…" msgstr "Add email…"
#: src/components/AddToPlaylistModal.tsx:64 #: src/components/AddToPlaylistModal.tsx:64
#: src/components/DumpCreateModal.tsx:292 #: src/components/DumpCreateModal.tsx:305
msgid "Add to playlist" msgid "Add to playlist"
msgstr "Add to playlist" msgstr "Add to playlist"
#: src/pages/UserPublicProfile.tsx:1529 #: src/pages/UserPublicProfile.tsx:1549
msgid "Admin" msgid "Admin"
msgstr "Admin" msgstr "Admin"
#: src/components/CategorySwitcher.tsx:79
#: src/components/CategorySwitcher.tsx:82
msgid "All"
msgstr "All"
#: src/pages/UserRegister.tsx:156 #: src/pages/UserRegister.tsx:156
msgid "Already have an account? <0>Log in</0>" msgid "Already have an account? <0>Log in</0>"
msgstr "Already have an account? <0>Log in</0>" msgstr "Already have an account? <0>Log in</0>"
#: src/pages/UserPublicProfile.tsx:1139 #: src/pages/UserPublicProfile.tsx:1150
msgid "Appearance" msgid "Appearance"
msgstr "Appearance" msgstr "Appearance"
@@ -161,7 +166,7 @@ msgstr "Appearance"
msgid "At least {0} characters" msgid "At least {0} characters"
msgstr "At least {0} characters" msgstr "At least {0} characters"
#: src/pages/UserPublicProfile.tsx:1187 #: src/pages/UserPublicProfile.tsx:1198
msgid "Auto" msgid "Auto"
msgstr "Auto" msgstr "Auto"
@@ -178,11 +183,11 @@ msgstr "Can't connect to the live updates server. Upvotes and notifications may
#: src/components/CommentThread.tsx:124 #: src/components/CommentThread.tsx:124
#: src/components/ConfirmModal.tsx:32 #: src/components/ConfirmModal.tsx:32
#: src/components/form/FormActions.tsx:32 #: src/components/form/FormActions.tsx:32
#: src/pages/Dump.tsx:368 #: src/pages/Dump.tsx:373
#: src/pages/DumpEdit.tsx:443 #: src/pages/DumpEdit.tsx:463
#: src/pages/PlaylistDetail.tsx:920 #: src/pages/PlaylistDetail.tsx:920
#: src/pages/UserPublicProfile.tsx:1646 #: src/pages/UserPublicProfile.tsx:1674
#: src/pages/UserPublicProfile.tsx:1716 #: src/pages/UserPublicProfile.tsx:1744
msgid "Cancel" msgid "Cancel"
msgstr "Cancel" msgstr "Cancel"
@@ -194,7 +199,16 @@ msgstr "Cancel removal"
msgid "Cancel search" msgid "Cancel search"
msgstr "Cancel search" msgstr "Cancel search"
#: src/pages/UserPublicProfile.tsx:768 #: src/components/CategorySelect.tsx:22
#: src/pages/UserPublicProfile.tsx:1281
msgid "Categories"
msgstr "Categories"
#: src/components/CategorySwitcher.tsx:102
msgid "Category"
msgstr "Category"
#: src/pages/UserPublicProfile.tsx:776
msgid "Change avatar" msgid "Change avatar"
msgstr "Change avatar" msgstr "Change avatar"
@@ -203,7 +217,7 @@ msgstr "Change avatar"
msgid "Change password" msgid "Change password"
msgstr "Change password" msgstr "Change password"
#: src/pages/UserPublicProfile.tsx:1132 #: src/pages/UserPublicProfile.tsx:1143
msgid "Change password…" msgid "Change password…"
msgstr "Change password…" msgstr "Change password…"
@@ -216,7 +230,7 @@ msgstr "Checking invite…"
msgid "Close" msgid "Close"
msgstr "Close" msgstr "Close"
#: src/pages/UserPublicProfile.tsx:1179 #: src/pages/UserPublicProfile.tsx:1190
msgid "Color scheme" msgid "Color scheme"
msgstr "Color scheme" msgstr "Color scheme"
@@ -225,11 +239,11 @@ msgstr "Color scheme"
msgid "Confirm new password" msgid "Confirm new password"
msgstr "Confirm new password" msgstr "Confirm new password"
#: src/pages/UserPublicProfile.tsx:105 #: src/pages/UserPublicProfile.tsx:106
msgid "Copied!" msgid "Copied!"
msgstr "Copied!" msgstr "Copied!"
#: src/pages/UserPublicProfile.tsx:105 #: src/pages/UserPublicProfile.tsx:106
msgid "Copy" msgid "Copy"
msgstr "Copy" msgstr "Copy"
@@ -241,10 +255,11 @@ msgstr "Could not change password"
msgid "Could not load." msgid "Could not load."
msgstr "Could not load." msgstr "Could not load."
#: src/pages/DumpEdit.tsx:344 #: src/pages/DumpEdit.tsx:359
msgid "Could not save" msgid "Could not save"
msgstr "Could not save" msgstr "Could not save"
#: src/components/CategoryManager.tsx:106
#: src/components/PlaylistCreateForm.tsx:87 #: src/components/PlaylistCreateForm.tsx:87
msgid "Create" msgid "Create"
msgstr "Create" msgstr "Create"
@@ -259,6 +274,7 @@ msgstr "Create & Add"
msgid "Created ({0}{1})" msgid "Created ({0}{1})"
msgstr "Created ({0}{1})" msgstr "Created ({0}{1})"
#: src/components/CategoryManager.tsx:103
#: src/components/PlaylistCreateForm.tsx:86 #: src/components/PlaylistCreateForm.tsx:86
msgid "Creating…" msgid "Creating…"
msgstr "Creating…" msgstr "Creating…"
@@ -267,14 +283,16 @@ msgstr "Creating…"
msgid "Current password" msgid "Current password"
msgstr "Current password" msgstr "Current password"
#: src/pages/UserPublicProfile.tsx:1201 #: src/pages/UserPublicProfile.tsx:1212
msgid "Dark" msgid "Dark"
msgstr "Dark" msgstr "Dark"
#: src/pages/UserPublicProfile.tsx:1214 #: src/pages/UserPublicProfile.tsx:1225
msgid "Default tab" msgid "Default tab"
msgstr "Default tab" msgstr "Default tab"
#: src/components/CategoryManager.tsx:191
#: src/components/CategoryManager.tsx:192
#: src/components/CommentThread.tsx:376 #: src/components/CommentThread.tsx:376
#: src/components/CommentThread.tsx:382 #: src/components/CommentThread.tsx:382
#: src/components/ConfirmModal.tsx:16 #: src/components/ConfirmModal.tsx:16
@@ -282,8 +300,17 @@ msgstr "Default tab"
msgid "Delete" msgid "Delete"
msgstr "Delete" msgstr "Delete"
#: src/pages/DumpEdit.tsx:252 #: src/components/CategoryManager.tsx:202
#: src/pages/DumpEdit.tsx:439 msgid "Delete category"
msgstr "Delete category"
#. placeholder {0}: category.name
#: src/components/CategoryManager.tsx:201
msgid "Delete category \"{0}\"? This cannot be undone."
msgstr "Delete category \"{0}\"? This cannot be undone."
#: src/pages/DumpEdit.tsx:253
#: src/pages/DumpEdit.tsx:459
msgid "Delete dump" msgid "Delete dump"
msgstr "Delete dump" msgstr "Delete dump"
@@ -297,7 +324,7 @@ msgstr "Delete playlist"
msgid "Delete this comment?" msgid "Delete this comment?"
msgstr "Delete this comment?" msgstr "Delete this comment?"
#: src/pages/DumpEdit.tsx:251 #: src/pages/DumpEdit.tsx:252
msgid "Delete this dump? This cannot be undone." msgid "Delete this dump? This cannot be undone."
msgstr "Delete this dump? This cannot be undone." msgstr "Delete this dump? This cannot be undone."
@@ -311,7 +338,7 @@ msgstr "Delete this playlist? This cannot be undone."
msgid "Description (optional)" msgid "Description (optional)"
msgstr "Description (optional)" msgstr "Description (optional)"
#: src/components/DumpCreateModal.tsx:479 #: src/components/DumpCreateModal.tsx:498
msgid "Done" msgid "Done"
msgstr "Done" msgstr "Done"
@@ -319,7 +346,7 @@ msgstr "Done"
msgid "Drop a file here" msgid "Drop a file here"
msgstr "Drop a file here" msgstr "Drop a file here"
#: src/pages/DumpEdit.tsx:414 #: src/pages/DumpEdit.tsx:429
msgid "Drop a replacement here" msgid "Drop a replacement here"
msgstr "Drop a replacement here" msgstr "Drop a replacement here"
@@ -327,23 +354,23 @@ msgstr "Drop a replacement here"
msgid "Dump" msgid "Dump"
msgstr "Dump" msgstr "Dump"
#: src/components/DumpCreateModal.tsx:449 #: src/components/DumpCreateModal.tsx:468
msgid "Dump it" msgid "Dump it"
msgstr "Dump it" msgstr "Dump it"
#: src/components/DumpCreateModal.tsx:460 #: src/components/DumpCreateModal.tsx:479
msgid "Dumped!" msgid "Dumped!"
msgstr "Dumped!" msgstr "Dumped!"
#: src/pages/Search.tsx:174 #: src/pages/Search.tsx:174
#: src/pages/UserDumps.tsx:80 #: src/pages/UserDumps.tsx:80
#: src/pages/UserPublicProfile.tsx:889 #: src/pages/UserPublicProfile.tsx:900
msgid "Dumps" msgid "Dumps"
msgstr "Dumps" msgstr "Dumps"
#. placeholder {0}: dumps.items.length #. placeholder {0}: dumps.items.length
#. placeholder {1}: dumps.hasMore ? "+" : "" #. placeholder {1}: dumps.hasMore ? "+" : ""
#: src/pages/UserPublicProfile.tsx:909 #: src/pages/UserPublicProfile.tsx:920
msgid "Dumps ({0}{1})" msgid "Dumps ({0}{1})"
msgstr "Dumps ({0}{1})" msgstr "Dumps ({0}{1})"
@@ -352,12 +379,12 @@ msgid "Earlier"
msgstr "Earlier" msgstr "Earlier"
#: src/components/CommentThread.tsx:367 #: src/components/CommentThread.tsx:367
#: src/pages/Dump.tsx:468 #: src/pages/Dump.tsx:486
#: src/pages/PlaylistDetail.tsx:625 #: src/pages/PlaylistDetail.tsx:625
msgid "Edit" msgid "Edit"
msgstr "Edit" msgstr "Edit"
#: src/components/DumpCreateModal.tsx:417 #: src/components/DumpCreateModal.tsx:430
msgid "Edit title" msgid "Edit title"
msgstr "Edit title" msgstr "Edit title"
@@ -365,7 +392,7 @@ msgstr "Edit title"
#. placeholder {0}: relativeTime(dump.updatedAt) #. placeholder {0}: relativeTime(dump.updatedAt)
#. placeholder {0}: relativeTime(playlist.updatedAt) #. placeholder {0}: relativeTime(playlist.updatedAt)
#: src/components/CommentThread.tsx:317 #: src/components/CommentThread.tsx:317
#: src/pages/Dump.tsx:421 #: src/pages/Dump.tsx:426
#: src/pages/PlaylistDetail.tsx:664 #: src/pages/PlaylistDetail.tsx:664
msgid "edited {0}" msgid "edited {0}"
msgstr "edited {0}" msgstr "edited {0}"
@@ -374,17 +401,17 @@ msgstr "edited {0}"
#. placeholder {0}: dump.updatedAt.toLocaleString() #. placeholder {0}: dump.updatedAt.toLocaleString()
#. placeholder {0}: playlist.updatedAt.toLocaleString() #. placeholder {0}: playlist.updatedAt.toLocaleString()
#: src/components/CommentThread.tsx:315 #: src/components/CommentThread.tsx:315
#: src/pages/Dump.tsx:419 #: src/pages/Dump.tsx:424
#: src/pages/PlaylistDetail.tsx:661 #: src/pages/PlaylistDetail.tsx:661
msgid "Edited {0}" msgid "Edited {0}"
msgstr "Edited {0}" msgstr "Edited {0}"
#: src/pages/DumpEdit.tsx:203 #: src/pages/DumpEdit.tsx:204
msgid "Editing" msgid "Editing"
msgstr "Editing" msgstr "Editing"
#. placeholder {0}: state.dump.title #. placeholder {0}: state.dump.title
#: src/pages/DumpEdit.tsx:48 #: src/pages/DumpEdit.tsx:49
msgid "Editing {0}" msgid "Editing {0}"
msgstr "Editing {0}" msgstr "Editing {0}"
@@ -396,28 +423,36 @@ msgstr "Email address"
msgid "Enter a query to search." msgid "Enter a query to search."
msgstr "Enter a query to search." msgstr "Enter a query to search."
#: src/components/CategoryManager.tsx:108
msgid "Failed to create category"
msgstr "Failed to create category"
#: src/components/PlaylistCreateForm.tsx:84 #: src/components/PlaylistCreateForm.tsx:84
msgid "Failed to create playlist" msgid "Failed to create playlist"
msgstr "Failed to create playlist" msgstr "Failed to create playlist"
#: src/pages/UserPublicProfile.tsx:86 #: src/components/CategoryManager.tsx:153
#: src/pages/UserPublicProfile.tsx:89 msgid "Failed to delete"
#: src/pages/UserPublicProfile.tsx:117 msgstr "Failed to delete"
#: src/pages/UserPublicProfile.tsx:87
#: src/pages/UserPublicProfile.tsx:90
#: src/pages/UserPublicProfile.tsx:118
msgid "Failed to generate invite" msgid "Failed to generate invite"
msgstr "Failed to generate invite" msgstr "Failed to generate invite"
#: src/pages/index/FollowedFeed.tsx:82 #: src/pages/index/FollowedFeed.tsx:83
#: src/pages/index/HotFeed.tsx:36 #: src/pages/index/HotFeed.tsx:36
#: src/pages/index/JournalFeed.tsx:33 #: src/pages/index/JournalFeed.tsx:33
#: src/pages/index/NewFeed.tsx:36 #: src/pages/index/NewFeed.tsx:36
#: src/pages/Notifications.tsx:369 #: src/pages/Notifications.tsx:369
#: src/pages/UserPublicProfile.tsx:1011 #: src/pages/UserPublicProfile.tsx:1022
#: src/pages/UserPublicProfile.tsx:1053 #: src/pages/UserPublicProfile.tsx:1064
#: src/pages/UserPublicProfile.tsx:1098 #: src/pages/UserPublicProfile.tsx:1109
msgid "Failed to load" msgid "Failed to load"
msgstr "Failed to load" msgstr "Failed to load"
#: src/components/DumpCreateModal.tsx:328 #: src/components/DumpCreateModal.tsx:341
msgid "Failed to post" msgid "Failed to post"
msgstr "Failed to post" msgstr "Failed to post"
@@ -430,41 +465,45 @@ msgid "Failed to post reply"
msgstr "Failed to post reply" msgstr "Failed to post reply"
#: src/pages/PlaylistDetail.tsx:908 #: src/pages/PlaylistDetail.tsx:908
#: src/pages/UserPublicProfile.tsx:1649 #: src/pages/UserPublicProfile.tsx:1677
#: src/pages/UserPublicProfile.tsx:1718 #: src/pages/UserPublicProfile.tsx:1746
msgid "Failed to save" msgid "Failed to save"
msgstr "Failed to save" msgstr "Failed to save"
#: src/components/CategoryManager.tsx:197
msgid "Failed to save category"
msgstr "Failed to save category"
#: src/components/CommentThread.tsx:330 #: src/components/CommentThread.tsx:330
msgid "Failed to save edit" msgid "Failed to save edit"
msgstr "Failed to save edit" msgstr "Failed to save edit"
#: src/pages/UserPublicProfile.tsx:825 #: src/pages/UserPublicProfile.tsx:836
msgid "Failed to update avatar" msgid "Failed to update avatar"
msgstr "Failed to update avatar" msgstr "Failed to update avatar"
#: src/pages/UserPublicProfile.tsx:1587 #: src/pages/UserPublicProfile.tsx:1615
msgid "Failed to update role" msgid "Failed to update role"
msgstr "Failed to update role" msgstr "Failed to update role"
#: src/pages/UserPublicProfile.tsx:1209 #: src/pages/UserPublicProfile.tsx:1220
msgid "Feeds" msgid "Feeds"
msgstr "Feeds" msgstr "Feeds"
#: src/components/DumpCreateModal.tsx:360 #: src/components/DumpCreateModal.tsx:373
msgid "Fetching preview…" msgid "Fetching preview…"
msgstr "Fetching preview…" msgstr "Fetching preview…"
#: src/components/DumpCreateModal.tsx:446 #: src/components/DumpCreateModal.tsx:465
msgid "Fetching…" msgid "Fetching…"
msgstr "Fetching…" msgstr "Fetching…"
#: src/components/DumpCreateModal.tsx:322 #: src/components/DumpCreateModal.tsx:335
#: src/components/FileDropZone.tsx:31 #: src/components/FileDropZone.tsx:31
msgid "File" msgid "File"
msgstr "File" msgstr "File"
#: src/components/DumpCreateModal.tsx:231 #: src/components/DumpCreateModal.tsx:243
msgid "File too large (max 50 MB)." msgid "File too large (max 50 MB)."
msgstr "File too large (max 50 MB)." msgstr "File too large (max 50 MB)."
@@ -481,17 +520,17 @@ msgstr "Follow {targetUsername}"
msgid "Follow playlist" msgid "Follow playlist"
msgstr "Follow playlist" msgstr "Follow playlist"
#: src/pages/index/FollowedFeed.tsx:365 #: src/pages/index/FollowedFeed.tsx:373
msgid "Follow some public playlists to see their dumps here." msgid "Follow some public playlists to see their dumps here."
msgstr "Follow some public playlists to see their dumps here." msgstr "Follow some public playlists to see their dumps here."
#: src/pages/index/FollowedFeed.tsx:351 #: src/pages/index/FollowedFeed.tsx:359
msgid "Follow some users to see their dumps here." msgid "Follow some users to see their dumps here."
msgstr "Follow some users to see their dumps here." msgstr "Follow some users to see their dumps here."
#: src/components/FeedTabBar.tsx:21 #: src/components/FeedTabBar.tsx:34
#: src/pages/UserPublicProfile.tsx:891 #: src/pages/UserPublicProfile.tsx:902
#: src/pages/UserPublicProfile.tsx:1243 #: src/pages/UserPublicProfile.tsx:1254
msgid "Followed" msgid "Followed"
msgstr "Followed" msgstr "Followed"
@@ -501,13 +540,13 @@ msgstr "Followed"
msgid "Followed ({0}{1})" msgid "Followed ({0}{1})"
msgstr "Followed ({0}{1})" msgstr "Followed ({0}{1})"
#: src/pages/UserPublicProfile.tsx:1042 #: src/pages/UserPublicProfile.tsx:1053
msgid "Followed playlists" msgid "Followed playlists"
msgstr "Followed playlists" msgstr "Followed playlists"
#: src/components/FollowButton.tsx:37 #: src/components/FollowButton.tsx:37
#: src/components/FollowButton.tsx:64 #: src/components/FollowButton.tsx:64
#: src/pages/UserPublicProfile.tsx:1000 #: src/pages/UserPublicProfile.tsx:1011
msgid "Following" msgid "Following"
msgstr "Following" msgstr "Following"
@@ -515,11 +554,11 @@ msgstr "Following"
msgid "Forgot password?" msgid "Forgot password?"
msgstr "Forgot password?" msgstr "Forgot password?"
#: src/pages/index/FollowedFeed.tsx:334 #: src/pages/index/FollowedFeed.tsx:342
msgid "From people" msgid "From people"
msgstr "From people" msgstr "From people"
#: src/pages/index/FollowedFeed.tsx:335 #: src/pages/index/FollowedFeed.tsx:343
msgid "From playlists" msgid "From playlists"
msgstr "From playlists" msgstr "From playlists"
@@ -531,8 +570,8 @@ msgstr "Go home"
msgid "Go to login" msgid "Go to login"
msgstr "Go to login" msgstr "Go to login"
#: src/components/FeedTabBar.tsx:17 #: src/components/FeedTabBar.tsx:30
#: src/pages/UserPublicProfile.tsx:1222 #: src/pages/UserPublicProfile.tsx:1233
msgid "Hot" msgid "Hot"
msgstr "Hot" msgstr "Hot"
@@ -548,21 +587,21 @@ msgstr "Invalid invite"
msgid "Invalid link" msgid "Invalid link"
msgstr "Invalid link" msgstr "Invalid link"
#: src/pages/UserPublicProfile.tsx:787 #: src/pages/UserPublicProfile.tsx:798
msgid "invited by" msgid "invited by"
msgstr "invited by" msgstr "invited by"
#: src/pages/UserPublicProfile.tsx:892 #: src/pages/UserPublicProfile.tsx:903
#: src/pages/UserPublicProfile.tsx:1087 #: src/pages/UserPublicProfile.tsx:1098
msgid "Invitees" msgid "Invitees"
msgstr "Invitees" msgstr "Invitees"
#: src/components/FeedTabBar.tsx:19 #: src/components/FeedTabBar.tsx:32
#: src/pages/UserPublicProfile.tsx:1236 #: src/pages/UserPublicProfile.tsx:1247
msgid "Journal" msgid "Journal"
msgstr "Journal" msgstr "Journal"
#: src/pages/UserPublicProfile.tsx:1194 #: src/pages/UserPublicProfile.tsx:1205
msgid "Light" msgid "Light"
msgstr "Light" msgstr "Light"
@@ -583,12 +622,12 @@ msgstr "Live updates unavailable."
msgid "Load more" msgid "Load more"
msgstr "Load more" msgstr "Load more"
#: src/pages/Dump.tsx:235 #: src/pages/Dump.tsx:237
#: src/pages/DumpEdit.tsx:154 #: src/pages/DumpEdit.tsx:155
msgid "Loading dump…" msgid "Loading dump…"
msgstr "Loading dump…" msgstr "Loading dump…"
#: src/pages/index/FollowedFeed.tsx:110 #: src/pages/index/FollowedFeed.tsx:111
#: src/pages/index/HotFeed.tsx:64 #: src/pages/index/HotFeed.tsx:64
#: src/pages/index/JournalFeed.tsx:62 #: src/pages/index/JournalFeed.tsx:62
#: src/pages/index/NewFeed.tsx:64 #: src/pages/index/NewFeed.tsx:64
@@ -604,15 +643,16 @@ msgstr "Loading more…"
msgid "Loading playlist…" msgid "Loading playlist…"
msgstr "Loading playlist…" msgstr "Loading playlist…"
#: src/pages/UserPublicProfile.tsx:724 #: src/pages/UserPublicProfile.tsx:732
msgid "Loading profile…" msgid "Loading profile…"
msgstr "Loading profile…" msgstr "Loading profile…"
#: src/components/CategoryManager.tsx:41
#: src/components/PlaylistMembershipPanel.tsx:28 #: src/components/PlaylistMembershipPanel.tsx:28
#: src/components/TextEditor.tsx:289 #: src/components/TextEditor.tsx:289
#: src/components/UserListPopover.tsx:192 #: src/components/UserListPopover.tsx:192
#: src/components/UserListPopover.tsx:230 #: src/components/UserListPopover.tsx:230
#: src/pages/index/FollowedFeed.tsx:77 #: src/pages/index/FollowedFeed.tsx:78
#: src/pages/index/HotFeed.tsx:32 #: src/pages/index/HotFeed.tsx:32
#: src/pages/index/JournalFeed.tsx:29 #: src/pages/index/JournalFeed.tsx:29
#: src/pages/index/NewFeed.tsx:32 #: src/pages/index/NewFeed.tsx:32
@@ -620,9 +660,9 @@ msgstr "Loading profile…"
#: src/pages/Notifications.tsx:441 #: src/pages/Notifications.tsx:441
#: src/pages/UserDumps.tsx:54 #: src/pages/UserDumps.tsx:54
#: src/pages/UserPlaylists.tsx:345 #: src/pages/UserPlaylists.tsx:345
#: src/pages/UserPublicProfile.tsx:1005 #: src/pages/UserPublicProfile.tsx:1016
#: src/pages/UserPublicProfile.tsx:1047 #: src/pages/UserPublicProfile.tsx:1058
#: src/pages/UserPublicProfile.tsx:1092 #: src/pages/UserPublicProfile.tsx:1103
#: src/pages/UserUpvoted.tsx:125 #: src/pages/UserUpvoted.tsx:125
msgid "Loading…" msgid "Loading…"
msgstr "Loading…" msgstr "Loading…"
@@ -642,8 +682,8 @@ msgstr "Log in to like"
msgid "Log in to vote" msgid "Log in to vote"
msgstr "Log in to vote" msgstr "Log in to vote"
#: src/pages/UserPublicProfile.tsx:745 #: src/pages/UserPublicProfile.tsx:753
#: src/pages/UserPublicProfile.tsx:839 #: src/pages/UserPublicProfile.tsx:850
msgid "Log out" msgid "Log out"
msgstr "Log out" msgstr "Log out"
@@ -655,7 +695,7 @@ msgstr "Logging in…"
msgid "Login failed" msgid "Login failed"
msgstr "Login failed" msgstr "Login failed"
#: src/pages/UserPublicProfile.tsx:897 #: src/pages/UserPublicProfile.tsx:908
msgid "Manage" msgid "Manage"
msgstr "Manage" msgstr "Manage"
@@ -663,24 +703,33 @@ msgstr "Manage"
msgid "Max 50 MB" msgid "Max 50 MB"
msgstr "Max 50 MB" msgstr "Max 50 MB"
#: src/pages/UserPublicProfile.tsx:1528 #: src/pages/UserPublicProfile.tsx:1548
msgid "Moderator" msgid "Moderator"
msgstr "Moderator" msgstr "Moderator"
#: src/components/CategoryManager.tsx:91
#: src/components/CategoryManager.tsx:167
msgid "Name"
msgstr "Name"
#: src/pages/Notifications.tsx:358 #: src/pages/Notifications.tsx:358
msgid "new" msgid "new"
msgstr "new" msgstr "new"
#: src/components/FeedTabBar.tsx:18 #: src/components/FeedTabBar.tsx:31
#: src/pages/UserPublicProfile.tsx:1229 #: src/pages/UserPublicProfile.tsx:1240
msgid "New" msgid "New"
msgstr "New" msgstr "New"
#: src/components/DumpCreateModal.tsx:292 #: src/components/CategoryManager.tsx:90
msgid "New category name"
msgstr "New category name"
#: src/components/DumpCreateModal.tsx:305
#: src/components/DumpFab.tsx:65 #: src/components/DumpFab.tsx:65
#: src/components/DumpFab.tsx:66 #: src/components/DumpFab.tsx:66
#: src/pages/UserDumps.tsx:88 #: src/pages/UserDumps.tsx:88
#: src/pages/UserPublicProfile.tsx:1307 #: src/pages/UserPublicProfile.tsx:1327
msgid "New dump" msgid "New dump"
msgstr "New dump" msgstr "New dump"
@@ -694,6 +743,10 @@ msgstr "New password"
msgid "New playlist" msgid "New playlist"
msgstr "New playlist" msgstr "New playlist"
#: src/components/CategoryManager.tsx:46
msgid "No categories yet."
msgstr "No categories yet."
#: src/pages/PlaylistDetail.tsx:680 #: src/pages/PlaylistDetail.tsx:680
msgid "No dumps in this playlist yet." msgid "No dumps in this playlist yet."
msgstr "No dumps in this playlist yet." msgstr "No dumps in this playlist yet."
@@ -713,11 +766,11 @@ msgid "No emoji found."
msgstr "No emoji found." msgstr "No emoji found."
#: src/pages/UserPlaylists.tsx:442 #: src/pages/UserPlaylists.tsx:442
#: src/pages/UserPublicProfile.tsx:1060 #: src/pages/UserPublicProfile.tsx:1071
msgid "No followed playlists yet." msgid "No followed playlists yet."
msgstr "No followed playlists yet." msgstr "No followed playlists yet."
#: src/pages/UserPublicProfile.tsx:1105 #: src/pages/UserPublicProfile.tsx:1116
msgid "No invitees yet." msgid "No invitees yet."
msgstr "No invitees yet." msgstr "No invitees yet."
@@ -731,7 +784,7 @@ msgstr "No playlists match \"{q}\"."
#: src/components/PlaylistMembershipPanel.tsx:34 #: src/components/PlaylistMembershipPanel.tsx:34
#: src/pages/UserPlaylists.tsx:400 #: src/pages/UserPlaylists.tsx:400
#: src/pages/UserPublicProfile.tsx:971 #: src/pages/UserPublicProfile.tsx:982
msgid "No playlists yet." msgid "No playlists yet."
msgstr "No playlists yet." msgstr "No playlists yet."
@@ -739,14 +792,14 @@ msgstr "No playlists yet."
msgid "No users match \"{q}\"." msgid "No users match \"{q}\"."
msgstr "No users match \"{q}\"." msgstr "No users match \"{q}\"."
#: src/pages/UserPublicProfile.tsx:1018 #: src/pages/UserPublicProfile.tsx:1029
msgid "Not following anyone yet." msgid "Not following anyone yet."
msgstr "Not following anyone yet." msgstr "Not following anyone yet."
#: src/pages/Notifications.tsx:376 #: src/pages/Notifications.tsx:376
#: src/pages/UserDumps.tsx:99 #: src/pages/UserDumps.tsx:99
#: src/pages/UserPublicProfile.tsx:1318 #: src/pages/UserPublicProfile.tsx:1338
#: src/pages/UserPublicProfile.tsx:1440 #: src/pages/UserPublicProfile.tsx:1460
#: src/pages/UserUpvoted.tsx:159 #: src/pages/UserUpvoted.tsx:159
msgid "Nothing here yet." msgid "Nothing here yet."
msgstr "Nothing here yet." msgstr "Nothing here yet."
@@ -774,7 +827,7 @@ msgid "Page not found"
msgstr "Page not found" msgstr "Page not found"
#: src/pages/UserLogin.tsx:74 #: src/pages/UserLogin.tsx:74
#: src/pages/UserPublicProfile.tsx:1125 #: src/pages/UserPublicProfile.tsx:1136
msgid "Password" msgid "Password"
msgstr "Password" msgstr "Password"
@@ -804,17 +857,17 @@ msgstr "Playlist title"
#: src/components/UserMenu.tsx:62 #: src/components/UserMenu.tsx:62
#: src/pages/Search.tsx:177 #: src/pages/Search.tsx:177
#: src/pages/UserPlaylists.tsx:371 #: src/pages/UserPlaylists.tsx:371
#: src/pages/UserPublicProfile.tsx:890 #: src/pages/UserPublicProfile.tsx:901
msgid "Playlists" msgid "Playlists"
msgstr "Playlists" msgstr "Playlists"
#. placeholder {0}: playlists.items.length #. placeholder {0}: playlists.items.length
#. placeholder {1}: playlists.hasMore ? "+" : "" #. placeholder {1}: playlists.hasMore ? "+" : ""
#: src/pages/UserPublicProfile.tsx:940 #: src/pages/UserPublicProfile.tsx:951
msgid "Playlists ({0}{1})" msgid "Playlists ({0}{1})"
msgstr "Playlists ({0}{1})" msgstr "Playlists ({0}{1})"
#: src/components/DumpCreateModal.tsx:228 #: src/components/DumpCreateModal.tsx:240
msgid "Please select a file." msgid "Please select a file."
msgstr "Please select a file." msgstr "Please select a file."
@@ -831,11 +884,11 @@ msgstr "Post reply"
msgid "Posting…" msgid "Posting…"
msgstr "Posting…" msgstr "Posting…"
#: src/components/DumpCard.tsx:114 #: src/components/DumpCard.tsx:119
#: src/components/JournalCard.tsx:119 #: src/components/JournalCard.tsx:121
#: src/components/PlaylistCard.tsx:73 #: src/components/PlaylistCard.tsx:73
#: src/components/PlaylistMembershipPanel.tsx:55 #: src/components/PlaylistMembershipPanel.tsx:55
#: src/pages/Dump.tsx:427 #: src/pages/Dump.tsx:432
#: src/pages/PlaylistDetail.tsx:644 #: src/pages/PlaylistDetail.tsx:644
msgid "private" msgid "private"
msgstr "private" msgstr "private"
@@ -855,11 +908,11 @@ msgstr "public"
msgid "Public" msgid "Public"
msgstr "Public" msgstr "Public"
#: src/pages/DumpEdit.tsx:232 #: src/pages/DumpEdit.tsx:233
msgid "Refresh metadata" msgid "Refresh metadata"
msgstr "Refresh metadata" msgstr "Refresh metadata"
#: src/pages/DumpEdit.tsx:231 #: src/pages/DumpEdit.tsx:232
msgid "Refreshing…" msgid "Refreshing…"
msgstr "Refreshing…" msgstr "Refreshing…"
@@ -877,7 +930,7 @@ msgstr "Registering…"
msgid "Registration failed" msgid "Registration failed"
msgstr "Registration failed" msgstr "Registration failed"
#: src/pages/Dump.tsx:480 #: src/pages/Dump.tsx:498
msgid "Related" msgid "Related"
msgstr "Related" msgstr "Related"
@@ -897,7 +950,7 @@ msgstr "Remove like"
msgid "Remove vote" msgid "Remove vote"
msgstr "Remove vote" msgstr "Remove vote"
#: src/pages/DumpEdit.tsx:413 #: src/pages/DumpEdit.tsx:428
msgid "Replace file" msgid "Replace file"
msgstr "Replace file" msgstr "Replace file"
@@ -917,36 +970,38 @@ msgstr "Reset failed"
msgid "Reset password" msgid "Reset password"
msgstr "Reset password" msgstr "Reset password"
#: src/pages/DumpEdit.tsx:364 #: src/pages/DumpEdit.tsx:379
#: src/pages/DumpEdit.tsx:382 #: src/pages/DumpEdit.tsx:397
msgid "Reset to default" msgid "Reset to default"
msgstr "Reset to default" msgstr "Reset to default"
#: src/pages/Dump.tsx:252 #: src/pages/Dump.tsx:254
#: src/pages/DumpEdit.tsx:171 #: src/pages/DumpEdit.tsx:172
msgid "Retry" msgid "Retry"
msgstr "Retry" msgstr "Retry"
#: src/pages/UserPublicProfile.tsx:1571 #: src/pages/UserPublicProfile.tsx:1599
msgid "Role" msgid "Role"
msgstr "Role" msgstr "Role"
#: src/components/CategoryManager.tsx:184
#: src/components/CommentThread.tsx:328 #: src/components/CommentThread.tsx:328
#: src/pages/Dump.tsx:360 #: src/pages/Dump.tsx:365
#: src/pages/DumpEdit.tsx:446 #: src/pages/DumpEdit.tsx:466
#: src/pages/PlaylistDetail.tsx:927 #: src/pages/PlaylistDetail.tsx:927
#: src/pages/UserPublicProfile.tsx:1638 #: src/pages/UserPublicProfile.tsx:1666
#: src/pages/UserPublicProfile.tsx:1708 #: src/pages/UserPublicProfile.tsx:1736
msgid "Save" msgid "Save"
msgstr "Save" msgstr "Save"
#: src/components/CategoryManager.tsx:181
#: src/components/ChangePasswordModal.tsx:100 #: src/components/ChangePasswordModal.tsx:100
#: src/components/CommentThread.tsx:329 #: src/components/CommentThread.tsx:329
#: src/pages/Dump.tsx:359 #: src/pages/Dump.tsx:364
#: src/pages/PlaylistDetail.tsx:923 #: src/pages/PlaylistDetail.tsx:923
#: src/pages/ResetPassword.tsx:126 #: src/pages/ResetPassword.tsx:126
#: src/pages/UserPublicProfile.tsx:1635 #: src/pages/UserPublicProfile.tsx:1663
#: src/pages/UserPublicProfile.tsx:1705 #: src/pages/UserPublicProfile.tsx:1733
msgid "Saving…" msgid "Saving…"
msgstr "Saving…" msgstr "Saving…"
@@ -985,7 +1040,7 @@ msgstr "Server unreachable"
msgid "Set new password" msgid "Set new password"
msgstr "Set new password" msgstr "Set new password"
#: src/pages/UserPublicProfile.tsx:894 #: src/pages/UserPublicProfile.tsx:905
msgid "Settings" msgid "Settings"
msgstr "Settings" msgstr "Settings"
@@ -994,7 +1049,7 @@ msgstr "Settings"
msgid "Something went wrong" msgid "Something went wrong"
msgstr "Something went wrong" msgstr "Something went wrong"
#: src/pages/UserPublicProfile.tsx:1144 #: src/pages/UserPublicProfile.tsx:1155
msgid "Style" msgid "Style"
msgstr "Style" msgstr "Style"
@@ -1018,17 +1073,17 @@ msgstr "This page does not exist."
msgid "This reset link is missing or malformed." msgid "This reset link is missing or malformed."
msgstr "This reset link is missing or malformed." msgstr "This reset link is missing or malformed."
#: src/pages/DumpEdit.tsx:348 #: src/pages/DumpEdit.tsx:363
msgid "Thumbnail" msgid "Thumbnail"
msgstr "Thumbnail" msgstr "Thumbnail"
#: src/components/DumpCreateModal.tsx:386 #: src/components/DumpCreateModal.tsx:399
#: src/components/PlaylistCreateForm.tsx:70 #: src/components/PlaylistCreateForm.tsx:70
#: src/pages/DumpEdit.tsx:372 #: src/pages/DumpEdit.tsx:387
msgid "Title" msgid "Title"
msgstr "Title" msgstr "Title"
#: src/components/DumpCreateModal.tsx:229 #: src/components/DumpCreateModal.tsx:241
msgid "Title is required." msgid "Title is required."
msgstr "Title is required." msgstr "Title is required."
@@ -1048,11 +1103,11 @@ msgstr "Unfollow {targetUsername}"
msgid "Unfollow playlist" msgid "Unfollow playlist"
msgstr "Unfollow playlist" msgstr "Unfollow playlist"
#: src/pages/UserPublicProfile.tsx:689 #: src/pages/UserPublicProfile.tsx:697
msgid "Upload failed" msgid "Upload failed"
msgstr "Upload failed" msgstr "Upload failed"
#: src/components/DumpCreateModal.tsx:447 #: src/components/DumpCreateModal.tsx:466
msgid "Uploading…" msgid "Uploading…"
msgstr "Uploading…" msgstr "Uploading…"
@@ -1066,24 +1121,33 @@ msgstr "Upvoted"
#. placeholder {0}: votes.items.length #. placeholder {0}: votes.items.length
#. placeholder {1}: votes.hasMore ? "+" : "" #. placeholder {1}: votes.hasMore ? "+" : ""
#: src/pages/UserPublicProfile.tsx:920 #: src/pages/UserPublicProfile.tsx:931
msgid "Upvoted ({0}{1})" msgid "Upvoted ({0}{1})"
msgstr "Upvoted ({0}{1})" msgstr "Upvoted ({0}{1})"
#: src/components/DumpCreateModal.tsx:335 #: src/components/DumpCreateModal.tsx:348
#: src/pages/DumpEdit.tsx:395 #: src/pages/DumpEdit.tsx:410
msgid "URL" msgid "URL"
msgstr "URL" msgstr "URL"
#: src/components/DumpCreateModal.tsx:216 #: src/components/DumpCreateModal.tsx:227
msgid "URL is required." msgid "URL is required."
msgstr "URL is required." msgstr "URL is required."
#: src/pages/UserPublicProfile.tsx:1527 #: src/components/CategoryManager.tsx:98
#: src/components/CategoryManager.tsx:174
msgid "URL slug"
msgstr "URL slug"
#: src/components/CategoryManager.tsx:97
msgid "url-slug"
msgstr "url-slug"
#: src/pages/UserPublicProfile.tsx:1547
msgid "User" msgid "User"
msgstr "User" msgstr "User"
#: src/pages/UserPublicProfile.tsx:1255 #: src/pages/UserPublicProfile.tsx:1266
msgid "User management" msgid "User management"
msgstr "User management" msgstr "User management"
@@ -1100,30 +1164,30 @@ msgstr "Username"
msgid "Users" msgid "Users"
msgstr "Users" msgstr "Users"
#: src/pages/UserPublicProfile.tsx:990 #: src/pages/UserPublicProfile.tsx:1001
#: src/pages/UserPublicProfile.tsx:1033 #: src/pages/UserPublicProfile.tsx:1044
#: src/pages/UserPublicProfile.tsx:1075 #: src/pages/UserPublicProfile.tsx:1086
#: src/pages/UserPublicProfile.tsx:1339 #: src/pages/UserPublicProfile.tsx:1359
#: src/pages/UserPublicProfile.tsx:1470 #: src/pages/UserPublicProfile.tsx:1490
msgid "View all →" msgid "View all →"
msgstr "View all →" msgstr "View all →"
#: src/components/DumpCreateModal.tsx:462 #: src/components/DumpCreateModal.tsx:481
msgid "View dump →" msgid "View dump →"
msgstr "View dump →" msgstr "View dump →"
#: src/components/DumpCreateModal.tsx:431 #: src/components/DumpCreateModal.tsx:444
#: src/pages/DumpEdit.tsx:425 #: src/pages/DumpEdit.tsx:440
msgid "What makes it worth it?" msgid "What makes it worth it?"
msgstr "What makes it worth it?" msgstr "What makes it worth it?"
#: src/pages/UserPublicProfile.tsx:874 #: src/pages/UserPublicProfile.tsx:885
#: src/pages/UserPublicProfile.tsx:1697 #: src/pages/UserPublicProfile.tsx:1725
msgid "Who am I?" msgid "Who am I?"
msgstr "Who am I?" msgstr "Who am I?"
#: src/components/DumpCreateModal.tsx:430 #: src/components/DumpCreateModal.tsx:443
#: src/pages/DumpEdit.tsx:424 #: src/pages/DumpEdit.tsx:439
msgid "Why?" msgid "Why?"
msgstr "Why?" msgstr "Why?"
@@ -1139,7 +1203,7 @@ msgstr "Yesterday"
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content." msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
msgstr "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content." msgstr "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
#: src/pages/index/FollowedFeed.tsx:115 #: src/pages/index/FollowedFeed.tsx:116
#: src/pages/index/HotFeed.tsx:69 #: src/pages/index/HotFeed.tsx:69
#: src/pages/index/JournalFeed.tsx:67 #: src/pages/index/JournalFeed.tsx:67
#: src/pages/index/NewFeed.tsx:69 #: src/pages/index/NewFeed.tsx:69

File diff suppressed because one or more lines are too long

View File

@@ -18,8 +18,8 @@ msgid "[deleted]"
msgstr "[supprimé]" msgstr "[supprimé]"
#. placeholder {0}: dump.commentCount #. placeholder {0}: dump.commentCount
#: src/components/DumpCard.tsx:105 #: src/components/DumpCard.tsx:110
#: src/components/JournalCard.tsx:110 #: src/components/JournalCard.tsx:112
msgid "{0, plural, one {# comment} other {# comments}}" msgid "{0, plural, one {# comment} other {# comments}}"
msgstr "{0, plural, one {# commentaire} other {# commentaires}}" msgstr "{0, plural, one {# commentaire} other {# commentaires}}"
@@ -43,13 +43,13 @@ msgid "{visibleCount, plural, one {# comment} other {# comments}}"
msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}" msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}"
#: src/pages/PlaylistDetail.tsx:570 #: src/pages/PlaylistDetail.tsx:570
#: src/pages/UserPublicProfile.tsx:741 #: src/pages/UserPublicProfile.tsx:749
msgid "← Back" msgid "← Back"
msgstr "← Retour" msgstr "← Retour"
#: src/pages/Dump.tsx:259 #: src/pages/Dump.tsx:261
#: src/pages/Dump.tsx:472 #: src/pages/Dump.tsx:490
#: src/pages/DumpEdit.tsx:178 #: src/pages/DumpEdit.tsx:179
msgid "← Back to all dumps" msgid "← Back to all dumps"
msgstr "← Retour à toutes les recos" msgstr "← Retour à toutes les recos"
@@ -59,7 +59,7 @@ msgstr "← Retour à toutes les recos"
msgid "← Back to profile" msgid "← Back to profile"
msgstr "← Retour au profil" msgstr "← Retour au profil"
#: src/pages/UserPublicProfile.tsx:114 #: src/pages/UserPublicProfile.tsx:115
msgid "+ Invite someone" msgid "+ Invite someone"
msgstr "+ Inviter quelqu'un" msgstr "+ Inviter quelqu'un"
@@ -67,7 +67,7 @@ msgstr "+ Inviter quelqu'un"
msgid "+ New playlist" msgid "+ New playlist"
msgstr "+ Nouvelle collection" msgstr "+ Nouvelle collection"
#: src/pages/Dump.tsx:327 #: src/pages/Dump.tsx:332
msgid "+ Playlist" msgid "+ Playlist"
msgstr "+ Collection" msgstr "+ Collection"
@@ -125,7 +125,7 @@ msgstr "un commentaire"
msgid "a post" msgid "a post"
msgstr "une publication" msgstr "une publication"
#: src/pages/UserPublicProfile.tsx:1120 #: src/pages/UserPublicProfile.tsx:1131
msgid "Account" msgid "Account"
msgstr "Compte" msgstr "Compte"
@@ -133,24 +133,29 @@ msgstr "Compte"
msgid "Add a comment…" msgid "Add a comment…"
msgstr "Ajouter un commentaire…" msgstr "Ajouter un commentaire…"
#: src/pages/UserPublicProfile.tsx:816 #: src/pages/UserPublicProfile.tsx:827
msgid "Add email…" msgid "Add email…"
msgstr "Ajouter un e-mail…" msgstr "Ajouter un e-mail…"
#: src/components/AddToPlaylistModal.tsx:64 #: src/components/AddToPlaylistModal.tsx:64
#: src/components/DumpCreateModal.tsx:292 #: src/components/DumpCreateModal.tsx:305
msgid "Add to playlist" msgid "Add to playlist"
msgstr "Ajouter à la collection" msgstr "Ajouter à la collection"
#: src/pages/UserPublicProfile.tsx:1529 #: src/pages/UserPublicProfile.tsx:1549
msgid "Admin" msgid "Admin"
msgstr "Administrateur" msgstr "Administrateur"
#: src/components/CategorySwitcher.tsx:79
#: src/components/CategorySwitcher.tsx:82
msgid "All"
msgstr "Tout"
#: src/pages/UserRegister.tsx:156 #: src/pages/UserRegister.tsx:156
msgid "Already have an account? <0>Log in</0>" msgid "Already have an account? <0>Log in</0>"
msgstr "Vous avez déjà un compte ? <0>Se connecter</0>" msgstr "Vous avez déjà un compte ? <0>Se connecter</0>"
#: src/pages/UserPublicProfile.tsx:1139 #: src/pages/UserPublicProfile.tsx:1150
msgid "Appearance" msgid "Appearance"
msgstr "Apparence" msgstr "Apparence"
@@ -161,7 +166,7 @@ msgstr "Apparence"
msgid "At least {0} characters" msgid "At least {0} characters"
msgstr "Au moins {0} caractères" msgstr "Au moins {0} caractères"
#: src/pages/UserPublicProfile.tsx:1187 #: src/pages/UserPublicProfile.tsx:1198
msgid "Auto" msgid "Auto"
msgstr "Auto" msgstr "Auto"
@@ -178,11 +183,11 @@ msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les vo
#: src/components/CommentThread.tsx:124 #: src/components/CommentThread.tsx:124
#: src/components/ConfirmModal.tsx:32 #: src/components/ConfirmModal.tsx:32
#: src/components/form/FormActions.tsx:32 #: src/components/form/FormActions.tsx:32
#: src/pages/Dump.tsx:368 #: src/pages/Dump.tsx:373
#: src/pages/DumpEdit.tsx:443 #: src/pages/DumpEdit.tsx:463
#: src/pages/PlaylistDetail.tsx:920 #: src/pages/PlaylistDetail.tsx:920
#: src/pages/UserPublicProfile.tsx:1646 #: src/pages/UserPublicProfile.tsx:1674
#: src/pages/UserPublicProfile.tsx:1716 #: src/pages/UserPublicProfile.tsx:1744
msgid "Cancel" msgid "Cancel"
msgstr "Annuler" msgstr "Annuler"
@@ -194,7 +199,16 @@ msgstr "Annuler la suppression"
msgid "Cancel search" msgid "Cancel search"
msgstr "Annuler la recherche" msgstr "Annuler la recherche"
#: src/pages/UserPublicProfile.tsx:768 #: src/components/CategorySelect.tsx:22
#: src/pages/UserPublicProfile.tsx:1281
msgid "Categories"
msgstr "Catégories"
#: src/components/CategorySwitcher.tsx:102
msgid "Category"
msgstr "Catégorie"
#: src/pages/UserPublicProfile.tsx:776
msgid "Change avatar" msgid "Change avatar"
msgstr "Changer l'avatar" msgstr "Changer l'avatar"
@@ -203,7 +217,7 @@ msgstr "Changer l'avatar"
msgid "Change password" msgid "Change password"
msgstr "Changer le mot de passe" msgstr "Changer le mot de passe"
#: src/pages/UserPublicProfile.tsx:1132 #: src/pages/UserPublicProfile.tsx:1143
msgid "Change password…" msgid "Change password…"
msgstr "Changer le mot de passe…" msgstr "Changer le mot de passe…"
@@ -216,7 +230,7 @@ msgstr "Vérification de l'invitation…"
msgid "Close" msgid "Close"
msgstr "Fermer" msgstr "Fermer"
#: src/pages/UserPublicProfile.tsx:1179 #: src/pages/UserPublicProfile.tsx:1190
msgid "Color scheme" msgid "Color scheme"
msgstr "Thème de couleur" msgstr "Thème de couleur"
@@ -225,11 +239,11 @@ msgstr "Thème de couleur"
msgid "Confirm new password" msgid "Confirm new password"
msgstr "Confirmer le nouveau mot de passe" msgstr "Confirmer le nouveau mot de passe"
#: src/pages/UserPublicProfile.tsx:105 #: src/pages/UserPublicProfile.tsx:106
msgid "Copied!" msgid "Copied!"
msgstr "Copié !" msgstr "Copié !"
#: src/pages/UserPublicProfile.tsx:105 #: src/pages/UserPublicProfile.tsx:106
msgid "Copy" msgid "Copy"
msgstr "Copier" msgstr "Copier"
@@ -241,10 +255,11 @@ msgstr "Impossible de changer le mot de passe"
msgid "Could not load." msgid "Could not load."
msgstr "Impossible de charger." msgstr "Impossible de charger."
#: src/pages/DumpEdit.tsx:344 #: src/pages/DumpEdit.tsx:359
msgid "Could not save" msgid "Could not save"
msgstr "Sauvegarde impossible" msgstr "Sauvegarde impossible"
#: src/components/CategoryManager.tsx:106
#: src/components/PlaylistCreateForm.tsx:87 #: src/components/PlaylistCreateForm.tsx:87
msgid "Create" msgid "Create"
msgstr "Créer" msgstr "Créer"
@@ -259,6 +274,7 @@ msgstr "Créer et ajouter"
msgid "Created ({0}{1})" msgid "Created ({0}{1})"
msgstr "Créées ({0}{1})" msgstr "Créées ({0}{1})"
#: src/components/CategoryManager.tsx:103
#: src/components/PlaylistCreateForm.tsx:86 #: src/components/PlaylistCreateForm.tsx:86
msgid "Creating…" msgid "Creating…"
msgstr "Création…" msgstr "Création…"
@@ -267,14 +283,16 @@ msgstr "Création…"
msgid "Current password" msgid "Current password"
msgstr "Mot de passe actuel" msgstr "Mot de passe actuel"
#: src/pages/UserPublicProfile.tsx:1201 #: src/pages/UserPublicProfile.tsx:1212
msgid "Dark" msgid "Dark"
msgstr "Sombre" msgstr "Sombre"
#: src/pages/UserPublicProfile.tsx:1214 #: src/pages/UserPublicProfile.tsx:1225
msgid "Default tab" msgid "Default tab"
msgstr "Onglet par défaut" msgstr "Onglet par défaut"
#: src/components/CategoryManager.tsx:191
#: src/components/CategoryManager.tsx:192
#: src/components/CommentThread.tsx:376 #: src/components/CommentThread.tsx:376
#: src/components/CommentThread.tsx:382 #: src/components/CommentThread.tsx:382
#: src/components/ConfirmModal.tsx:16 #: src/components/ConfirmModal.tsx:16
@@ -282,8 +300,17 @@ msgstr "Onglet par défaut"
msgid "Delete" msgid "Delete"
msgstr "Supprimer" msgstr "Supprimer"
#: src/pages/DumpEdit.tsx:252 #: src/components/CategoryManager.tsx:202
#: src/pages/DumpEdit.tsx:439 msgid "Delete category"
msgstr "Supprimer la catégorie"
#. placeholder {0}: category.name
#: src/components/CategoryManager.tsx:201
msgid "Delete category \"{0}\"? This cannot be undone."
msgstr "Supprimer la catégorie \"{0}\" ? Cette action est irréversible."
#: src/pages/DumpEdit.tsx:253
#: src/pages/DumpEdit.tsx:459
msgid "Delete dump" msgid "Delete dump"
msgstr "Supprimer la reco" msgstr "Supprimer la reco"
@@ -297,7 +324,7 @@ msgstr "Supprimer la collection"
msgid "Delete this comment?" msgid "Delete this comment?"
msgstr "Supprimer ce commentaire ?" msgstr "Supprimer ce commentaire ?"
#: src/pages/DumpEdit.tsx:251 #: src/pages/DumpEdit.tsx:252
msgid "Delete this dump? This cannot be undone." msgid "Delete this dump? This cannot be undone."
msgstr "Supprimer cette reco ? Cette action est irréversible." msgstr "Supprimer cette reco ? Cette action est irréversible."
@@ -311,7 +338,7 @@ msgstr "Supprimer cette collection ? Cette action est irréversible."
msgid "Description (optional)" msgid "Description (optional)"
msgstr "Description (facultatif)" msgstr "Description (facultatif)"
#: src/components/DumpCreateModal.tsx:479 #: src/components/DumpCreateModal.tsx:498
msgid "Done" msgid "Done"
msgstr "Terminé" msgstr "Terminé"
@@ -319,7 +346,7 @@ msgstr "Terminé"
msgid "Drop a file here" msgid "Drop a file here"
msgstr "Déposez un fichier ici" msgstr "Déposez un fichier ici"
#: src/pages/DumpEdit.tsx:414 #: src/pages/DumpEdit.tsx:429
msgid "Drop a replacement here" msgid "Drop a replacement here"
msgstr "Déposez un fichier de remplacement ici" msgstr "Déposez un fichier de remplacement ici"
@@ -327,23 +354,23 @@ msgstr "Déposez un fichier de remplacement ici"
msgid "Dump" msgid "Dump"
msgstr "Reco" msgstr "Reco"
#: src/components/DumpCreateModal.tsx:449 #: src/components/DumpCreateModal.tsx:468
msgid "Dump it" msgid "Dump it"
msgstr "Recommander" msgstr "Recommander"
#: src/components/DumpCreateModal.tsx:460 #: src/components/DumpCreateModal.tsx:479
msgid "Dumped!" msgid "Dumped!"
msgstr "Recommandé !" msgstr "Recommandé !"
#: src/pages/Search.tsx:174 #: src/pages/Search.tsx:174
#: src/pages/UserDumps.tsx:80 #: src/pages/UserDumps.tsx:80
#: src/pages/UserPublicProfile.tsx:889 #: src/pages/UserPublicProfile.tsx:900
msgid "Dumps" msgid "Dumps"
msgstr "Recos" msgstr "Recos"
#. placeholder {0}: dumps.items.length #. placeholder {0}: dumps.items.length
#. placeholder {1}: dumps.hasMore ? "+" : "" #. placeholder {1}: dumps.hasMore ? "+" : ""
#: src/pages/UserPublicProfile.tsx:909 #: src/pages/UserPublicProfile.tsx:920
msgid "Dumps ({0}{1})" msgid "Dumps ({0}{1})"
msgstr "Recos ({0}{1})" msgstr "Recos ({0}{1})"
@@ -352,12 +379,12 @@ msgid "Earlier"
msgstr "Plus tôt" msgstr "Plus tôt"
#: src/components/CommentThread.tsx:367 #: src/components/CommentThread.tsx:367
#: src/pages/Dump.tsx:468 #: src/pages/Dump.tsx:486
#: src/pages/PlaylistDetail.tsx:625 #: src/pages/PlaylistDetail.tsx:625
msgid "Edit" msgid "Edit"
msgstr "Modifier" msgstr "Modifier"
#: src/components/DumpCreateModal.tsx:417 #: src/components/DumpCreateModal.tsx:430
msgid "Edit title" msgid "Edit title"
msgstr "Modifier le titre" msgstr "Modifier le titre"
@@ -365,7 +392,7 @@ msgstr "Modifier le titre"
#. placeholder {0}: relativeTime(dump.updatedAt) #. placeholder {0}: relativeTime(dump.updatedAt)
#. placeholder {0}: relativeTime(playlist.updatedAt) #. placeholder {0}: relativeTime(playlist.updatedAt)
#: src/components/CommentThread.tsx:317 #: src/components/CommentThread.tsx:317
#: src/pages/Dump.tsx:421 #: src/pages/Dump.tsx:426
#: src/pages/PlaylistDetail.tsx:664 #: src/pages/PlaylistDetail.tsx:664
msgid "edited {0}" msgid "edited {0}"
msgstr "modifié {0}" msgstr "modifié {0}"
@@ -374,17 +401,17 @@ msgstr "modifié {0}"
#. placeholder {0}: dump.updatedAt.toLocaleString() #. placeholder {0}: dump.updatedAt.toLocaleString()
#. placeholder {0}: playlist.updatedAt.toLocaleString() #. placeholder {0}: playlist.updatedAt.toLocaleString()
#: src/components/CommentThread.tsx:315 #: src/components/CommentThread.tsx:315
#: src/pages/Dump.tsx:419 #: src/pages/Dump.tsx:424
#: src/pages/PlaylistDetail.tsx:661 #: src/pages/PlaylistDetail.tsx:661
msgid "Edited {0}" msgid "Edited {0}"
msgstr "Modifié le {0}" msgstr "Modifié le {0}"
#: src/pages/DumpEdit.tsx:203 #: src/pages/DumpEdit.tsx:204
msgid "Editing" msgid "Editing"
msgstr "Modification" msgstr "Modification"
#. placeholder {0}: state.dump.title #. placeholder {0}: state.dump.title
#: src/pages/DumpEdit.tsx:48 #: src/pages/DumpEdit.tsx:49
msgid "Editing {0}" msgid "Editing {0}"
msgstr "Édition de {0}" msgstr "Édition de {0}"
@@ -396,28 +423,36 @@ msgstr "Adresse e-mail"
msgid "Enter a query to search." msgid "Enter a query to search."
msgstr "Saisissez une recherche." msgstr "Saisissez une recherche."
#: src/components/CategoryManager.tsx:108
msgid "Failed to create category"
msgstr "Échec de la création de la catégorie"
#: src/components/PlaylistCreateForm.tsx:84 #: src/components/PlaylistCreateForm.tsx:84
msgid "Failed to create playlist" msgid "Failed to create playlist"
msgstr "Impossible de créer la collection" msgstr "Impossible de créer la collection"
#: src/pages/UserPublicProfile.tsx:86 #: src/components/CategoryManager.tsx:153
#: src/pages/UserPublicProfile.tsx:89 msgid "Failed to delete"
#: src/pages/UserPublicProfile.tsx:117 msgstr "Suppression échouée"
#: src/pages/UserPublicProfile.tsx:87
#: src/pages/UserPublicProfile.tsx:90
#: src/pages/UserPublicProfile.tsx:118
msgid "Failed to generate invite" msgid "Failed to generate invite"
msgstr "Impossible de générer une invitation" msgstr "Impossible de générer une invitation"
#: src/pages/index/FollowedFeed.tsx:82 #: src/pages/index/FollowedFeed.tsx:83
#: src/pages/index/HotFeed.tsx:36 #: src/pages/index/HotFeed.tsx:36
#: src/pages/index/JournalFeed.tsx:33 #: src/pages/index/JournalFeed.tsx:33
#: src/pages/index/NewFeed.tsx:36 #: src/pages/index/NewFeed.tsx:36
#: src/pages/Notifications.tsx:369 #: src/pages/Notifications.tsx:369
#: src/pages/UserPublicProfile.tsx:1011 #: src/pages/UserPublicProfile.tsx:1022
#: src/pages/UserPublicProfile.tsx:1053 #: src/pages/UserPublicProfile.tsx:1064
#: src/pages/UserPublicProfile.tsx:1098 #: src/pages/UserPublicProfile.tsx:1109
msgid "Failed to load" msgid "Failed to load"
msgstr "Chargement échoué" msgstr "Chargement échoué"
#: src/components/DumpCreateModal.tsx:328 #: src/components/DumpCreateModal.tsx:341
msgid "Failed to post" msgid "Failed to post"
msgstr "Publication échouée" msgstr "Publication échouée"
@@ -430,41 +465,45 @@ msgid "Failed to post reply"
msgstr "Impossible de publier la réponse" msgstr "Impossible de publier la réponse"
#: src/pages/PlaylistDetail.tsx:908 #: src/pages/PlaylistDetail.tsx:908
#: src/pages/UserPublicProfile.tsx:1649 #: src/pages/UserPublicProfile.tsx:1677
#: src/pages/UserPublicProfile.tsx:1718 #: src/pages/UserPublicProfile.tsx:1746
msgid "Failed to save" msgid "Failed to save"
msgstr "Enregistrement échoué" msgstr "Enregistrement échoué"
#: src/components/CategoryManager.tsx:197
msgid "Failed to save category"
msgstr "Échec de lenregistrement de la catégorie"
#: src/components/CommentThread.tsx:330 #: src/components/CommentThread.tsx:330
msgid "Failed to save edit" msgid "Failed to save edit"
msgstr "Impossible d'enregistrer la modification" msgstr "Impossible d'enregistrer la modification"
#: src/pages/UserPublicProfile.tsx:825 #: src/pages/UserPublicProfile.tsx:836
msgid "Failed to update avatar" msgid "Failed to update avatar"
msgstr "Impossible de mettre à jour l'avatar" msgstr "Impossible de mettre à jour l'avatar"
#: src/pages/UserPublicProfile.tsx:1587 #: src/pages/UserPublicProfile.tsx:1615
msgid "Failed to update role" msgid "Failed to update role"
msgstr "Erreur lors de la mise à jour du rôle" msgstr "Erreur lors de la mise à jour du rôle"
#: src/pages/UserPublicProfile.tsx:1209 #: src/pages/UserPublicProfile.tsx:1220
msgid "Feeds" msgid "Feeds"
msgstr "Flux" msgstr "Flux"
#: src/components/DumpCreateModal.tsx:360 #: src/components/DumpCreateModal.tsx:373
msgid "Fetching preview…" msgid "Fetching preview…"
msgstr "Récupération de l'aperçu…" msgstr "Récupération de l'aperçu…"
#: src/components/DumpCreateModal.tsx:446 #: src/components/DumpCreateModal.tsx:465
msgid "Fetching…" msgid "Fetching…"
msgstr "Récupération…" msgstr "Récupération…"
#: src/components/DumpCreateModal.tsx:322 #: src/components/DumpCreateModal.tsx:335
#: src/components/FileDropZone.tsx:31 #: src/components/FileDropZone.tsx:31
msgid "File" msgid "File"
msgstr "Fichier" msgstr "Fichier"
#: src/components/DumpCreateModal.tsx:231 #: src/components/DumpCreateModal.tsx:243
msgid "File too large (max 50 MB)." msgid "File too large (max 50 MB)."
msgstr "Fichier trop volumineux (max 50 Mo)." msgstr "Fichier trop volumineux (max 50 Mo)."
@@ -481,17 +520,17 @@ msgstr "Suivre {targetUsername}"
msgid "Follow playlist" msgid "Follow playlist"
msgstr "Suivre la collection" msgstr "Suivre la collection"
#: src/pages/index/FollowedFeed.tsx:365 #: src/pages/index/FollowedFeed.tsx:373
msgid "Follow some public playlists to see their dumps here." msgid "Follow some public playlists to see their dumps here."
msgstr "Suivez des collections publiques pour voir leurs recos ici." msgstr "Suivez des collections publiques pour voir leurs recos ici."
#: src/pages/index/FollowedFeed.tsx:351 #: src/pages/index/FollowedFeed.tsx:359
msgid "Follow some users to see their dumps here." msgid "Follow some users to see their dumps here."
msgstr "Suivez des utilisateurs pour voir leurs recos ici." msgstr "Suivez des utilisateurs pour voir leurs recos ici."
#: src/components/FeedTabBar.tsx:21 #: src/components/FeedTabBar.tsx:34
#: src/pages/UserPublicProfile.tsx:891 #: src/pages/UserPublicProfile.tsx:902
#: src/pages/UserPublicProfile.tsx:1243 #: src/pages/UserPublicProfile.tsx:1254
msgid "Followed" msgid "Followed"
msgstr "Suivi" msgstr "Suivi"
@@ -501,13 +540,13 @@ msgstr "Suivi"
msgid "Followed ({0}{1})" msgid "Followed ({0}{1})"
msgstr "Suivies ({0}{1})" msgstr "Suivies ({0}{1})"
#: src/pages/UserPublicProfile.tsx:1042 #: src/pages/UserPublicProfile.tsx:1053
msgid "Followed playlists" msgid "Followed playlists"
msgstr "Collections suivies" msgstr "Collections suivies"
#: src/components/FollowButton.tsx:37 #: src/components/FollowButton.tsx:37
#: src/components/FollowButton.tsx:64 #: src/components/FollowButton.tsx:64
#: src/pages/UserPublicProfile.tsx:1000 #: src/pages/UserPublicProfile.tsx:1011
msgid "Following" msgid "Following"
msgstr "Abonné" msgstr "Abonné"
@@ -515,11 +554,11 @@ msgstr "Abonné"
msgid "Forgot password?" msgid "Forgot password?"
msgstr "Mot de passe oublié ?" msgstr "Mot de passe oublié ?"
#: src/pages/index/FollowedFeed.tsx:334 #: src/pages/index/FollowedFeed.tsx:342
msgid "From people" msgid "From people"
msgstr "De personnes" msgstr "De personnes"
#: src/pages/index/FollowedFeed.tsx:335 #: src/pages/index/FollowedFeed.tsx:343
msgid "From playlists" msgid "From playlists"
msgstr "De collections" msgstr "De collections"
@@ -531,8 +570,8 @@ msgstr "Accueil"
msgid "Go to login" msgid "Go to login"
msgstr "Aller à la connexion" msgstr "Aller à la connexion"
#: src/components/FeedTabBar.tsx:17 #: src/components/FeedTabBar.tsx:30
#: src/pages/UserPublicProfile.tsx:1222 #: src/pages/UserPublicProfile.tsx:1233
msgid "Hot" msgid "Hot"
msgstr "Tendances" msgstr "Tendances"
@@ -548,21 +587,21 @@ msgstr "Invitation invalide"
msgid "Invalid link" msgid "Invalid link"
msgstr "Lien invalide" msgstr "Lien invalide"
#: src/pages/UserPublicProfile.tsx:787 #: src/pages/UserPublicProfile.tsx:798
msgid "invited by" msgid "invited by"
msgstr "invité par" msgstr "invité par"
#: src/pages/UserPublicProfile.tsx:892 #: src/pages/UserPublicProfile.tsx:903
#: src/pages/UserPublicProfile.tsx:1087 #: src/pages/UserPublicProfile.tsx:1098
msgid "Invitees" msgid "Invitees"
msgstr "Invités" msgstr "Invités"
#: src/components/FeedTabBar.tsx:19 #: src/components/FeedTabBar.tsx:32
#: src/pages/UserPublicProfile.tsx:1236 #: src/pages/UserPublicProfile.tsx:1247
msgid "Journal" msgid "Journal"
msgstr "Journal" msgstr "Journal"
#: src/pages/UserPublicProfile.tsx:1194 #: src/pages/UserPublicProfile.tsx:1205
msgid "Light" msgid "Light"
msgstr "Clair" msgstr "Clair"
@@ -583,12 +622,12 @@ msgstr "Mises à jour en direct indisponibles."
msgid "Load more" msgid "Load more"
msgstr "Charger plus" msgstr "Charger plus"
#: src/pages/Dump.tsx:235 #: src/pages/Dump.tsx:237
#: src/pages/DumpEdit.tsx:154 #: src/pages/DumpEdit.tsx:155
msgid "Loading dump…" msgid "Loading dump…"
msgstr "Chargement de la reco…" msgstr "Chargement de la reco…"
#: src/pages/index/FollowedFeed.tsx:110 #: src/pages/index/FollowedFeed.tsx:111
#: src/pages/index/HotFeed.tsx:64 #: src/pages/index/HotFeed.tsx:64
#: src/pages/index/JournalFeed.tsx:62 #: src/pages/index/JournalFeed.tsx:62
#: src/pages/index/NewFeed.tsx:64 #: src/pages/index/NewFeed.tsx:64
@@ -604,15 +643,16 @@ msgstr "Chargement…"
msgid "Loading playlist…" msgid "Loading playlist…"
msgstr "Chargement de la collection…" msgstr "Chargement de la collection…"
#: src/pages/UserPublicProfile.tsx:724 #: src/pages/UserPublicProfile.tsx:732
msgid "Loading profile…" msgid "Loading profile…"
msgstr "Chargement du profil…" msgstr "Chargement du profil…"
#: src/components/CategoryManager.tsx:41
#: src/components/PlaylistMembershipPanel.tsx:28 #: src/components/PlaylistMembershipPanel.tsx:28
#: src/components/TextEditor.tsx:289 #: src/components/TextEditor.tsx:289
#: src/components/UserListPopover.tsx:192 #: src/components/UserListPopover.tsx:192
#: src/components/UserListPopover.tsx:230 #: src/components/UserListPopover.tsx:230
#: src/pages/index/FollowedFeed.tsx:77 #: src/pages/index/FollowedFeed.tsx:78
#: src/pages/index/HotFeed.tsx:32 #: src/pages/index/HotFeed.tsx:32
#: src/pages/index/JournalFeed.tsx:29 #: src/pages/index/JournalFeed.tsx:29
#: src/pages/index/NewFeed.tsx:32 #: src/pages/index/NewFeed.tsx:32
@@ -620,9 +660,9 @@ msgstr "Chargement du profil…"
#: src/pages/Notifications.tsx:441 #: src/pages/Notifications.tsx:441
#: src/pages/UserDumps.tsx:54 #: src/pages/UserDumps.tsx:54
#: src/pages/UserPlaylists.tsx:345 #: src/pages/UserPlaylists.tsx:345
#: src/pages/UserPublicProfile.tsx:1005 #: src/pages/UserPublicProfile.tsx:1016
#: src/pages/UserPublicProfile.tsx:1047 #: src/pages/UserPublicProfile.tsx:1058
#: src/pages/UserPublicProfile.tsx:1092 #: src/pages/UserPublicProfile.tsx:1103
#: src/pages/UserUpvoted.tsx:125 #: src/pages/UserUpvoted.tsx:125
msgid "Loading…" msgid "Loading…"
msgstr "Chargement…" msgstr "Chargement…"
@@ -642,8 +682,8 @@ msgstr "Se connecter pour aimer"
msgid "Log in to vote" msgid "Log in to vote"
msgstr "Se connecter pour voter" msgstr "Se connecter pour voter"
#: src/pages/UserPublicProfile.tsx:745 #: src/pages/UserPublicProfile.tsx:753
#: src/pages/UserPublicProfile.tsx:839 #: src/pages/UserPublicProfile.tsx:850
msgid "Log out" msgid "Log out"
msgstr "Se déconnecter" msgstr "Se déconnecter"
@@ -655,7 +695,7 @@ msgstr "Connexion…"
msgid "Login failed" msgid "Login failed"
msgstr "Connexion échouée" msgstr "Connexion échouée"
#: src/pages/UserPublicProfile.tsx:897 #: src/pages/UserPublicProfile.tsx:908
msgid "Manage" msgid "Manage"
msgstr "Administration" msgstr "Administration"
@@ -663,24 +703,33 @@ msgstr "Administration"
msgid "Max 50 MB" msgid "Max 50 MB"
msgstr "Max 50 Mo" msgstr "Max 50 Mo"
#: src/pages/UserPublicProfile.tsx:1528 #: src/pages/UserPublicProfile.tsx:1548
msgid "Moderator" msgid "Moderator"
msgstr "Modérateur" msgstr "Modérateur"
#: src/components/CategoryManager.tsx:91
#: src/components/CategoryManager.tsx:167
msgid "Name"
msgstr "Nom"
#: src/pages/Notifications.tsx:358 #: src/pages/Notifications.tsx:358
msgid "new" msgid "new"
msgstr "nouveau" msgstr "nouveau"
#: src/components/FeedTabBar.tsx:18 #: src/components/FeedTabBar.tsx:31
#: src/pages/UserPublicProfile.tsx:1229 #: src/pages/UserPublicProfile.tsx:1240
msgid "New" msgid "New"
msgstr "Nouveau" msgstr "Nouveau"
#: src/components/DumpCreateModal.tsx:292 #: src/components/CategoryManager.tsx:90
msgid "New category name"
msgstr "Nom de la nouvelle catégorie"
#: src/components/DumpCreateModal.tsx:305
#: src/components/DumpFab.tsx:65 #: src/components/DumpFab.tsx:65
#: src/components/DumpFab.tsx:66 #: src/components/DumpFab.tsx:66
#: src/pages/UserDumps.tsx:88 #: src/pages/UserDumps.tsx:88
#: src/pages/UserPublicProfile.tsx:1307 #: src/pages/UserPublicProfile.tsx:1327
msgid "New dump" msgid "New dump"
msgstr "Nouvelle reco" msgstr "Nouvelle reco"
@@ -694,6 +743,10 @@ msgstr "Nouveau mot de passe"
msgid "New playlist" msgid "New playlist"
msgstr "Nouvelle collection" msgstr "Nouvelle collection"
#: src/components/CategoryManager.tsx:46
msgid "No categories yet."
msgstr "Aucune catégorie pour le moment."
#: src/pages/PlaylistDetail.tsx:680 #: src/pages/PlaylistDetail.tsx:680
msgid "No dumps in this playlist yet." msgid "No dumps in this playlist yet."
msgstr "Aucune reco dans cette collection pour l'instant." msgstr "Aucune reco dans cette collection pour l'instant."
@@ -713,11 +766,11 @@ msgid "No emoji found."
msgstr "Aucun emoji trouvé." msgstr "Aucun emoji trouvé."
#: src/pages/UserPlaylists.tsx:442 #: src/pages/UserPlaylists.tsx:442
#: src/pages/UserPublicProfile.tsx:1060 #: src/pages/UserPublicProfile.tsx:1071
msgid "No followed playlists yet." msgid "No followed playlists yet."
msgstr "Pas encore de collections suivies." msgstr "Pas encore de collections suivies."
#: src/pages/UserPublicProfile.tsx:1105 #: src/pages/UserPublicProfile.tsx:1116
msgid "No invitees yet." msgid "No invitees yet."
msgstr "Aucun invité pour le moment." msgstr "Aucun invité pour le moment."
@@ -731,7 +784,7 @@ msgstr "Aucune collection ne correspond à « {q} »."
#: src/components/PlaylistMembershipPanel.tsx:34 #: src/components/PlaylistMembershipPanel.tsx:34
#: src/pages/UserPlaylists.tsx:400 #: src/pages/UserPlaylists.tsx:400
#: src/pages/UserPublicProfile.tsx:971 #: src/pages/UserPublicProfile.tsx:982
msgid "No playlists yet." msgid "No playlists yet."
msgstr "Pas encore de collections." msgstr "Pas encore de collections."
@@ -739,14 +792,14 @@ msgstr "Pas encore de collections."
msgid "No users match \"{q}\"." msgid "No users match \"{q}\"."
msgstr "Aucun utilisateur ne correspond à « {q} »." msgstr "Aucun utilisateur ne correspond à « {q} »."
#: src/pages/UserPublicProfile.tsx:1018 #: src/pages/UserPublicProfile.tsx:1029
msgid "Not following anyone yet." msgid "Not following anyone yet."
msgstr "Aucun abonnement pour le moment." msgstr "Aucun abonnement pour le moment."
#: src/pages/Notifications.tsx:376 #: src/pages/Notifications.tsx:376
#: src/pages/UserDumps.tsx:99 #: src/pages/UserDumps.tsx:99
#: src/pages/UserPublicProfile.tsx:1318 #: src/pages/UserPublicProfile.tsx:1338
#: src/pages/UserPublicProfile.tsx:1440 #: src/pages/UserPublicProfile.tsx:1460
#: src/pages/UserUpvoted.tsx:159 #: src/pages/UserUpvoted.tsx:159
msgid "Nothing here yet." msgid "Nothing here yet."
msgstr "Rien ici pour l'instant." msgstr "Rien ici pour l'instant."
@@ -774,7 +827,7 @@ msgid "Page not found"
msgstr "Page introuvable" msgstr "Page introuvable"
#: src/pages/UserLogin.tsx:74 #: src/pages/UserLogin.tsx:74
#: src/pages/UserPublicProfile.tsx:1125 #: src/pages/UserPublicProfile.tsx:1136
msgid "Password" msgid "Password"
msgstr "Mot de passe" msgstr "Mot de passe"
@@ -804,17 +857,17 @@ msgstr "Titre de la collection"
#: src/components/UserMenu.tsx:62 #: src/components/UserMenu.tsx:62
#: src/pages/Search.tsx:177 #: src/pages/Search.tsx:177
#: src/pages/UserPlaylists.tsx:371 #: src/pages/UserPlaylists.tsx:371
#: src/pages/UserPublicProfile.tsx:890 #: src/pages/UserPublicProfile.tsx:901
msgid "Playlists" msgid "Playlists"
msgstr "Collections" msgstr "Collections"
#. placeholder {0}: playlists.items.length #. placeholder {0}: playlists.items.length
#. placeholder {1}: playlists.hasMore ? "+" : "" #. placeholder {1}: playlists.hasMore ? "+" : ""
#: src/pages/UserPublicProfile.tsx:940 #: src/pages/UserPublicProfile.tsx:951
msgid "Playlists ({0}{1})" msgid "Playlists ({0}{1})"
msgstr "Collections ({0}{1})" msgstr "Collections ({0}{1})"
#: src/components/DumpCreateModal.tsx:228 #: src/components/DumpCreateModal.tsx:240
msgid "Please select a file." msgid "Please select a file."
msgstr "Veuillez sélectionner un fichier." msgstr "Veuillez sélectionner un fichier."
@@ -831,11 +884,11 @@ msgstr "Publier la réponse"
msgid "Posting…" msgid "Posting…"
msgstr "Publication…" msgstr "Publication…"
#: src/components/DumpCard.tsx:114 #: src/components/DumpCard.tsx:119
#: src/components/JournalCard.tsx:119 #: src/components/JournalCard.tsx:121
#: src/components/PlaylistCard.tsx:73 #: src/components/PlaylistCard.tsx:73
#: src/components/PlaylistMembershipPanel.tsx:55 #: src/components/PlaylistMembershipPanel.tsx:55
#: src/pages/Dump.tsx:427 #: src/pages/Dump.tsx:432
#: src/pages/PlaylistDetail.tsx:644 #: src/pages/PlaylistDetail.tsx:644
msgid "private" msgid "private"
msgstr "privé" msgstr "privé"
@@ -855,11 +908,11 @@ msgstr "public"
msgid "Public" msgid "Public"
msgstr "Public" msgstr "Public"
#: src/pages/DumpEdit.tsx:232 #: src/pages/DumpEdit.tsx:233
msgid "Refresh metadata" msgid "Refresh metadata"
msgstr "Actualiser les métadonnées" msgstr "Actualiser les métadonnées"
#: src/pages/DumpEdit.tsx:231 #: src/pages/DumpEdit.tsx:232
msgid "Refreshing…" msgid "Refreshing…"
msgstr "Actualisation…" msgstr "Actualisation…"
@@ -877,7 +930,7 @@ msgstr "Inscription…"
msgid "Registration failed" msgid "Registration failed"
msgstr "Inscription échouée" msgstr "Inscription échouée"
#: src/pages/Dump.tsx:480 #: src/pages/Dump.tsx:498
msgid "Related" msgid "Related"
msgstr "Connexe" msgstr "Connexe"
@@ -897,7 +950,7 @@ msgstr "Retirer le j'aime"
msgid "Remove vote" msgid "Remove vote"
msgstr "Retirer le vote" msgstr "Retirer le vote"
#: src/pages/DumpEdit.tsx:413 #: src/pages/DumpEdit.tsx:428
msgid "Replace file" msgid "Replace file"
msgstr "Remplacer le fichier" msgstr "Remplacer le fichier"
@@ -917,36 +970,38 @@ msgstr "Échec de la réinitialisation"
msgid "Reset password" msgid "Reset password"
msgstr "Réinitialiser le mot de passe" msgstr "Réinitialiser le mot de passe"
#: src/pages/DumpEdit.tsx:364 #: src/pages/DumpEdit.tsx:379
#: src/pages/DumpEdit.tsx:382 #: src/pages/DumpEdit.tsx:397
msgid "Reset to default" msgid "Reset to default"
msgstr "Réinitialiser par défaut" msgstr "Réinitialiser par défaut"
#: src/pages/Dump.tsx:252 #: src/pages/Dump.tsx:254
#: src/pages/DumpEdit.tsx:171 #: src/pages/DumpEdit.tsx:172
msgid "Retry" msgid "Retry"
msgstr "Réessayer" msgstr "Réessayer"
#: src/pages/UserPublicProfile.tsx:1571 #: src/pages/UserPublicProfile.tsx:1599
msgid "Role" msgid "Role"
msgstr "Rôle" msgstr "Rôle"
#: src/components/CategoryManager.tsx:184
#: src/components/CommentThread.tsx:328 #: src/components/CommentThread.tsx:328
#: src/pages/Dump.tsx:360 #: src/pages/Dump.tsx:365
#: src/pages/DumpEdit.tsx:446 #: src/pages/DumpEdit.tsx:466
#: src/pages/PlaylistDetail.tsx:927 #: src/pages/PlaylistDetail.tsx:927
#: src/pages/UserPublicProfile.tsx:1638 #: src/pages/UserPublicProfile.tsx:1666
#: src/pages/UserPublicProfile.tsx:1708 #: src/pages/UserPublicProfile.tsx:1736
msgid "Save" msgid "Save"
msgstr "Enregistrer" msgstr "Enregistrer"
#: src/components/CategoryManager.tsx:181
#: src/components/ChangePasswordModal.tsx:100 #: src/components/ChangePasswordModal.tsx:100
#: src/components/CommentThread.tsx:329 #: src/components/CommentThread.tsx:329
#: src/pages/Dump.tsx:359 #: src/pages/Dump.tsx:364
#: src/pages/PlaylistDetail.tsx:923 #: src/pages/PlaylistDetail.tsx:923
#: src/pages/ResetPassword.tsx:126 #: src/pages/ResetPassword.tsx:126
#: src/pages/UserPublicProfile.tsx:1635 #: src/pages/UserPublicProfile.tsx:1663
#: src/pages/UserPublicProfile.tsx:1705 #: src/pages/UserPublicProfile.tsx:1733
msgid "Saving…" msgid "Saving…"
msgstr "Enregistrement…" msgstr "Enregistrement…"
@@ -985,7 +1040,7 @@ msgstr "Serveur inaccessible"
msgid "Set new password" msgid "Set new password"
msgstr "Définir un nouveau mot de passe" msgstr "Définir un nouveau mot de passe"
#: src/pages/UserPublicProfile.tsx:894 #: src/pages/UserPublicProfile.tsx:905
msgid "Settings" msgid "Settings"
msgstr "Paramètres" msgstr "Paramètres"
@@ -994,7 +1049,7 @@ msgstr "Paramètres"
msgid "Something went wrong" msgid "Something went wrong"
msgstr "Une erreur est survenue" msgstr "Une erreur est survenue"
#: src/pages/UserPublicProfile.tsx:1144 #: src/pages/UserPublicProfile.tsx:1155
msgid "Style" msgid "Style"
msgstr "Style" msgstr "Style"
@@ -1018,17 +1073,17 @@ msgstr "Rien à voir, circulez."
msgid "This reset link is missing or malformed." msgid "This reset link is missing or malformed."
msgstr "Ce lien de réinitialisation est absent ou malformé." msgstr "Ce lien de réinitialisation est absent ou malformé."
#: src/pages/DumpEdit.tsx:348 #: src/pages/DumpEdit.tsx:363
msgid "Thumbnail" msgid "Thumbnail"
msgstr "Miniature" msgstr "Miniature"
#: src/components/DumpCreateModal.tsx:386 #: src/components/DumpCreateModal.tsx:399
#: src/components/PlaylistCreateForm.tsx:70 #: src/components/PlaylistCreateForm.tsx:70
#: src/pages/DumpEdit.tsx:372 #: src/pages/DumpEdit.tsx:387
msgid "Title" msgid "Title"
msgstr "Titre" msgstr "Titre"
#: src/components/DumpCreateModal.tsx:229 #: src/components/DumpCreateModal.tsx:241
msgid "Title is required." msgid "Title is required."
msgstr "Un titre est requis." msgstr "Un titre est requis."
@@ -1048,11 +1103,11 @@ msgstr "Ne plus suivre {targetUsername}"
msgid "Unfollow playlist" msgid "Unfollow playlist"
msgstr "Ne plus suivre la collection" msgstr "Ne plus suivre la collection"
#: src/pages/UserPublicProfile.tsx:689 #: src/pages/UserPublicProfile.tsx:697
msgid "Upload failed" msgid "Upload failed"
msgstr "Envoi échoué" msgstr "Envoi échoué"
#: src/components/DumpCreateModal.tsx:447 #: src/components/DumpCreateModal.tsx:466
msgid "Uploading…" msgid "Uploading…"
msgstr "Envoi…" msgstr "Envoi…"
@@ -1066,24 +1121,33 @@ msgstr "Voté"
#. placeholder {0}: votes.items.length #. placeholder {0}: votes.items.length
#. placeholder {1}: votes.hasMore ? "+" : "" #. placeholder {1}: votes.hasMore ? "+" : ""
#: src/pages/UserPublicProfile.tsx:920 #: src/pages/UserPublicProfile.tsx:931
msgid "Upvoted ({0}{1})" msgid "Upvoted ({0}{1})"
msgstr "Votés ({0}{1})" msgstr "Votés ({0}{1})"
#: src/components/DumpCreateModal.tsx:335 #: src/components/DumpCreateModal.tsx:348
#: src/pages/DumpEdit.tsx:395 #: src/pages/DumpEdit.tsx:410
msgid "URL" msgid "URL"
msgstr "URL" msgstr "URL"
#: src/components/DumpCreateModal.tsx:216 #: src/components/DumpCreateModal.tsx:227
msgid "URL is required." msgid "URL is required."
msgstr "L'URL est obligatoire." msgstr "L'URL est obligatoire."
#: src/pages/UserPublicProfile.tsx:1527 #: src/components/CategoryManager.tsx:98
#: src/components/CategoryManager.tsx:174
msgid "URL slug"
msgstr "Identifiant dURL"
#: src/components/CategoryManager.tsx:97
msgid "url-slug"
msgstr "identifiant-url"
#: src/pages/UserPublicProfile.tsx:1547
msgid "User" msgid "User"
msgstr "Utilisateur" msgstr "Utilisateur"
#: src/pages/UserPublicProfile.tsx:1255 #: src/pages/UserPublicProfile.tsx:1266
msgid "User management" msgid "User management"
msgstr "Gestion utilisateur" msgstr "Gestion utilisateur"
@@ -1100,30 +1164,30 @@ msgstr "Nom d'utilisateur"
msgid "Users" msgid "Users"
msgstr "Utilisateurs" msgstr "Utilisateurs"
#: src/pages/UserPublicProfile.tsx:990 #: src/pages/UserPublicProfile.tsx:1001
#: src/pages/UserPublicProfile.tsx:1033 #: src/pages/UserPublicProfile.tsx:1044
#: src/pages/UserPublicProfile.tsx:1075 #: src/pages/UserPublicProfile.tsx:1086
#: src/pages/UserPublicProfile.tsx:1339 #: src/pages/UserPublicProfile.tsx:1359
#: src/pages/UserPublicProfile.tsx:1470 #: src/pages/UserPublicProfile.tsx:1490
msgid "View all →" msgid "View all →"
msgstr "Tout voir →" msgstr "Tout voir →"
#: src/components/DumpCreateModal.tsx:462 #: src/components/DumpCreateModal.tsx:481
msgid "View dump →" msgid "View dump →"
msgstr "Voir la reco →" msgstr "Voir la reco →"
#: src/components/DumpCreateModal.tsx:431 #: src/components/DumpCreateModal.tsx:444
#: src/pages/DumpEdit.tsx:425 #: src/pages/DumpEdit.tsx:440
msgid "What makes it worth it?" msgid "What makes it worth it?"
msgstr "Pourquoi on en voudrait ?" msgstr "Pourquoi on en voudrait ?"
#: src/pages/UserPublicProfile.tsx:874 #: src/pages/UserPublicProfile.tsx:885
#: src/pages/UserPublicProfile.tsx:1697 #: src/pages/UserPublicProfile.tsx:1725
msgid "Who am I?" msgid "Who am I?"
msgstr "Qui suis-je ?" msgstr "Qui suis-je ?"
#: src/components/DumpCreateModal.tsx:430 #: src/components/DumpCreateModal.tsx:443
#: src/pages/DumpEdit.tsx:424 #: src/pages/DumpEdit.tsx:439
msgid "Why?" msgid "Why?"
msgstr "Pourquoi ?" msgstr "Pourquoi ?"
@@ -1139,7 +1203,7 @@ msgstr "Hier"
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content." msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
msgstr "Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vos recos ou publie du nouveau contenu." msgstr "Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vos recos ou publie du nouveau contenu."
#: src/pages/index/FollowedFeed.tsx:115 #: src/pages/index/FollowedFeed.tsx:116
#: src/pages/index/HotFeed.tsx:69 #: src/pages/index/HotFeed.tsx:69
#: src/pages/index/JournalFeed.tsx:67 #: src/pages/index/JournalFeed.tsx:67
#: src/pages/index/NewFeed.tsx:69 #: src/pages/index/NewFeed.tsx:69

View File

@@ -64,6 +64,7 @@ export interface Dump {
commentCount: number; commentCount: number;
isPrivate: boolean; isPrivate: boolean;
thumbnailMime?: string; thumbnailMime?: string;
categoryIds?: string[];
} }
export type RawDump = WithStringDate<Dump>; export type RawDump = WithStringDate<Dump>;
@@ -80,6 +81,41 @@ export function hydrateDump(raw: unknown): Dump {
return deserializeDump(raw as RawDump); return deserializeDump(raw as RawDump);
} }
/**
* Categories
*/
export interface Category {
id: string;
slug: string;
name: string;
position: number;
createdAt: Date;
updatedAt?: Date;
}
export type RawCategory = WithStringDate<Category>;
export function deserializeCategory(raw: RawCategory): Category {
return {
...raw,
createdAt: new Date(raw.createdAt),
updatedAt: raw.updatedAt ? new Date(raw.updatedAt) : undefined,
};
}
export interface CreateCategoryRequest {
slug: string;
name: string;
position?: number;
}
export interface UpdateCategoryRequest {
slug?: string;
name?: string;
position?: number;
}
/** /**
* Users * Users
*/ */
@@ -540,6 +576,7 @@ export interface CreateUrlDumpRequest {
url: string; url: string;
comment?: string; comment?: string;
isPrivate?: boolean; isPrivate?: boolean;
categoryIds?: string[];
} }
export interface UpdateDumpRequest { export interface UpdateDumpRequest {
@@ -547,6 +584,7 @@ export interface UpdateDumpRequest {
title?: string; title?: string;
comment?: string; comment?: string;
isPrivate?: boolean; isPrivate?: boolean;
categoryIds?: string[];
} }
export interface CreateCommentRequest { export interface CreateCommentRequest {

View File

@@ -24,6 +24,7 @@ import {
} from "../model.ts"; } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { useCategories } from "../hooks/useCategories.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts"; import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { relativeTime } from "../utils/relativeTime.ts"; import { relativeTime } from "../utils/relativeTime.ts";
import { useWS } from "../hooks/useWS.ts"; import { useWS } from "../hooks/useWS.ts";
@@ -73,6 +74,7 @@ export function Dump() {
const [titleError, setTitleError] = useState<string | null>(null); const [titleError, setTitleError] = useState<string | null>(null);
const { user, token, authFetch } = useAuth(); const { user, token, authFetch } = useAuth();
const { byId } = useCategories();
const { const {
voteCounts, voteCounts,
myVotes, myVotes,
@@ -267,6 +269,9 @@ export function Dump() {
const { dump } = dumpState; const { dump } = dumpState;
const canEdit = !!user && const canEdit = !!user &&
(dump.userId === user.id || can(user, "dump:moderate")); (dump.userId === user.id || can(user, "dump:moderate"));
const dumpCategories = (dump.categoryIds ?? [])
.map((id) => byId.get(id))
.filter((c) => c !== undefined);
const handleTitleSave = async () => { const handleTitleSave = async () => {
const trimmed = titleDraft.trim(); const trimmed = titleDraft.trim();
@@ -428,6 +433,19 @@ export function Dump() {
</span> </span>
)} )}
</div> </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>
)}
</div> </div>
</div> </div>

View File

@@ -18,6 +18,7 @@ import RichContentCard from "../components/RichContentCard.tsx";
import FilePreview from "../components/FilePreview.tsx"; import FilePreview from "../components/FilePreview.tsx";
import { FileDropZone } from "../components/FileDropZone.tsx"; import { FileDropZone } from "../components/FileDropZone.tsx";
import { ImagePicker } from "../components/ImagePicker.tsx"; import { ImagePicker } from "../components/ImagePicker.tsx";
import { CategorySelect } from "../components/CategorySelect.tsx";
import { import {
expectOk, expectOk,
FormError, FormError,
@@ -286,6 +287,15 @@ function DumpEditForm({
onSaved, onSaved,
}: DumpEditFormProps) { }: DumpEditFormProps) {
const { authFetch } = useAuth(); const { authFetch } = useAuth();
const [selectedCategoryIds, setSelectedCategoryIds] = useState<Set<string>>(
() => new Set(dump.categoryIds ?? []),
);
const toggleCategory = (id: string) =>
setSelectedCategoryIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
const form = useApiForm<EditValues>({ const form = useApiForm<EditValues>({
defaultValues: { defaultValues: {
title: dump.title, title: dump.title,
@@ -306,11 +316,14 @@ function DumpEditForm({
const isPrivate = !isPublic; const isPrivate = !isPublic;
let res: Response; let res: Response;
const categoryIds = [...selectedCategoryIds];
if (dump.kind === "file" && newFile) { if (dump.kind === "file" && newFile) {
const formData = new FormData(); const formData = new FormData();
formData.append("file", newFile); formData.append("file", newFile);
formData.append("title", trimmedTitle); formData.append("title", trimmedTitle);
if (comment.trim()) formData.append("comment", comment.trim()); if (comment.trim()) formData.append("comment", comment.trim());
formData.append("categoryIds", JSON.stringify(categoryIds));
res = await authFetch(`${API_URL}/api/dumps/${dump.id}/file`, { res = await authFetch(`${API_URL}/api/dumps/${dump.id}/file`, {
method: "PUT", method: "PUT",
body: formData, body: formData,
@@ -322,11 +335,13 @@ function DumpEditForm({
title: trimmedTitle, title: trimmedTitle,
comment: comment.trim() || undefined, comment: comment.trim() || undefined,
isPrivate, isPrivate,
categoryIds,
} }
: { : {
title: trimmedTitle, title: trimmedTitle,
comment: comment.trim() || undefined, comment: comment.trim() || undefined,
isPrivate, isPrivate,
categoryIds,
}; };
res = await authFetch(`${API_URL}/api/dumps/${dump.id}`, { res = await authFetch(`${API_URL}/api/dumps/${dump.id}`, {
method: "PUT", method: "PUT",
@@ -428,6 +443,11 @@ function DumpEditForm({
rules={{ maxLength: VALIDATION.DUMP_COMMENT_MAX }} rules={{ maxLength: VALIDATION.DUMP_COMMENT_MAX }}
/> />
<CategorySelect
selected={selectedCategoryIds}
onToggle={toggleCategory}
/>
<VisibilityToggle<EditValues> name="isPublic" /> <VisibilityToggle<EditValues> name="isPublic" />
<div className="form-actions"> <div className="form-actions">

View File

@@ -6,13 +6,12 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { useLocation } from "react-router"; import { useLocation, useParams } from "react-router";
import { AppHeader } from "../components/AppHeader.tsx"; import { AppHeader } from "../components/AppHeader.tsx";
import { PresenceRow } from "../components/PresenceRow.tsx"; import { PresenceRow } from "../components/PresenceRow.tsx";
import { FeedTabBar } from "../components/FeedTabBar.tsx"; import { FeedTabBar } from "../components/FeedTabBar.tsx";
import { FEED_TABS, type FeedTab } from "../config/feedTabs.ts"; import { FEED_TABS, type FeedTab } from "../config/feedTabs.ts";
import { useTabParam } from "../hooks/useTabParam.ts";
import { useDefaultFeedTab } from "../hooks/useDefaultFeedTab.ts"; import { useDefaultFeedTab } from "../hooks/useDefaultFeedTab.ts";
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts"; import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
@@ -31,6 +30,7 @@ import { useScrollSave } from "../hooks/useScrollSave.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts"; import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts"; import { useWS } from "../hooks/useWS.ts";
import { useCategories } from "../hooks/useCategories.ts";
import { useDumpListSync } from "../hooks/useDumpListSync.ts"; import { useDumpListSync } from "../hooks/useDumpListSync.ts";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts"; import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
@@ -38,6 +38,7 @@ import { HotFeed } from "./index/HotFeed.tsx";
import { NewFeed } from "./index/NewFeed.tsx"; import { NewFeed } from "./index/NewFeed.tsx";
import { JournalFeed } from "./index/JournalFeed.tsx"; import { JournalFeed } from "./index/JournalFeed.tsx";
import { FollowedFeed } from "./index/FollowedFeed.tsx"; import { FollowedFeed } from "./index/FollowedFeed.tsx";
import { NotFound } from "./NotFound.tsx";
import type { MainFeedProps } from "./index/types.ts"; import type { MainFeedProps } from "./index/types.ts";
type DumpsState = type DumpsState =
@@ -51,13 +52,16 @@ type DumpsState =
loadingMore: boolean; loadingMore: boolean;
}; };
export function Index() { /**
* The shared feed page. `categorySlug` scopes it to a single category; when
* absent it shows every dump. The router keys this component by scope, so each
* category gets its own mount/fetch while switching feed tabs reuses it.
*/
export function Index({ categorySlug }: { categorySlug?: string }) {
const location = useLocation(); const location = useLocation();
const justDeletedId = (location.state as { deletedDumpId?: string } | null) const justDeletedId = (location.state as { deletedDumpId?: string } | null)
?.deletedDumpId; ?.deletedDumpId;
useDocumentTitle(null);
const { user, token } = useAuth(); const { user, token } = useAuth();
const { const {
voteCounts, voteCounts,
@@ -68,10 +72,42 @@ export function Index() {
removeVote, removeVote,
} = useWS(); } = useWS();
// Resolve the scope to a real category (for its id, name, and 404 handling).
const { categories, isLoaded: categoriesLoaded } = useCategories();
const category = categorySlug
? categories.find((c) => c.slug === categorySlug)
: undefined;
const categoryNotFound = !!categorySlug && categoriesLoaded && !category;
useDocumentTitle(
categorySlug ? (category ? category.name : undefined) : null,
);
// ── Active feed tab (from the URL) ──
const { feedTab: rawTab } = useParams();
const [preferredTab] = useDefaultFeedTab();
const defaultTab: FeedTab = preferredTab === "followed" && !user
? "hot"
: preferredTab;
const tab: FeedTab = (FEED_TABS as readonly string[]).includes(rawTab ?? "")
? (rawTab as FeedTab)
: defaultTab;
// ── Main feed state ── // ── Main feed state ──
const feedUrl = useCallback(
(page: number) =>
categorySlug
? `${API_URL}/api/categories/${
encodeURIComponent(categorySlug)
}/dumps?page=${page}&limit=${DEFAULT_PAGE_SIZE}`
: `${API_URL}/api/dumps/?page=${page}&limit=${DEFAULT_PAGE_SIZE}`,
[categorySlug],
);
const { cached, saveState } = useFeedCache<Dump>( const { cached, saveState } = useFeedCache<Dump>(
`feed:index:${user?.id ?? "guest"}`, `feed:index:${categorySlug ?? "all"}:${user?.id ?? "guest"}`,
hydrateDump, hydrateDump,
); );
const [dumpsState, setDumpsState] = useState<DumpsState>(() => const [dumpsState, setDumpsState] = useState<DumpsState>(() =>
@@ -87,14 +123,6 @@ export function Index() {
); );
const mainFetchDone = useRef(false); const mainFetchDone = useRef(false);
// The preferred default tab decides which feed `/` opens on. "followed"
// requires a session, so fall back to "hot" for guests.
const [preferredTab] = useDefaultFeedTab();
const defaultTab: FeedTab = preferredTab === "followed" && !user
? "hot"
: preferredTab;
const [tab] = useTabParam<FeedTab>(FEED_TABS, defaultTab);
// Web Share Target: Android share sheet navigates to /?share_url=... // Web Share Target: Android share sheet navigates to /?share_url=...
const searchParams = new URLSearchParams(location.search); const searchParams = new URLSearchParams(location.search);
const shareUrl = searchParams.get("share_url") ?? const shareUrl = searchParams.get("share_url") ??
@@ -102,9 +130,6 @@ export function Index() {
useEffect(() => { useEffect(() => {
if (!shareUrl) return; if (!shareUrl) return;
// Clean share params from the URL so a refresh doesn't re-open the modal.
// The active tab already lives in the pathname (e.g. /~/new), so just drop
// the query string.
globalThis.history.replaceState({}, "", location.pathname); globalThis.history.replaceState({}, "", location.pathname);
}, [shareUrl, location.pathname]); }, [shareUrl, location.pathname]);
@@ -116,13 +141,10 @@ export function Index() {
const controller = new AbortController(); const controller = new AbortController();
(async () => { (async () => {
try { try {
const res = await fetch( const res = await fetch(feedUrl(1), {
`${API_URL}/api/dumps/?page=1&limit=${DEFAULT_PAGE_SIZE}`, signal: controller.signal,
{ headers: token ? { Authorization: `Bearer ${token}` } : {},
signal: controller.signal, });
headers: token ? { Authorization: `Bearer ${token}` } : {},
},
);
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json(); const body = await res.json();
const { items, hasMore } = body.data as PaginatedData<RawDump>; const { items, hasMore } = body.data as PaginatedData<RawDump>;
@@ -142,7 +164,7 @@ export function Index() {
mainFetchDone.current = false; mainFetchDone.current = false;
controller.abort(); controller.abort();
}; };
}, [cached, token]); }, [cached, token, feedUrl]);
// ── WS sync for main feed ── // ── WS sync for main feed ──
@@ -166,7 +188,7 @@ export function Index() {
setDumpsState((s) => setDumpsState((s) =>
s.status === "loaded" ? { ...s, loadingMore: true } : s s.status === "loaded" ? { ...s, loadingMore: true } : s
); );
fetch(`${API_URL}/api/dumps/?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`, { fetch(feedUrl(nextPage), {
headers: token ? { Authorization: `Bearer ${token}` } : {}, headers: token ? { Authorization: `Bearer ${token}` } : {},
}) })
.then((r) => r.json()) .then((r) => r.json())
@@ -189,7 +211,7 @@ export function Index() {
s.status === "loaded" ? { ...s, loadingMore: false } : s s.status === "loaded" ? { ...s, loadingMore: false } : s
) )
); );
}, [dumpsState, token]); }, [dumpsState, token, feedUrl]);
// ── Scroll save / restore ── // ── Scroll save / restore ──
@@ -227,13 +249,20 @@ export function Index() {
const loadingMore = dumpsState.status === "loaded" && dumpsState.loadingMore; const loadingMore = dumpsState.status === "loaded" && dumpsState.loadingMore;
const hasMore = dumpsState.status === "loaded" ? dumpsState.hasMore : false; const hasMore = dumpsState.status === "loaded" ? dumpsState.hasMore : false;
// New dumps arriving over the WS only belong in this feed if they're in scope.
const categoryId = category?.id;
const inScope = useCallback(
(d: Dump) =>
categorySlug ? !!categoryId && !!d.categoryIds?.includes(categoryId) : true,
[categorySlug, categoryId],
);
const restIds = useMemo(() => new Set(dumps.map((d) => d.id)), [dumps]); const restIds = useMemo(() => new Set(dumps.map((d) => d.id)), [dumps]);
const combined = useMemo( const combined = useMemo(
() => () =>
[...recentDumps.filter((d) => !restIds.has(d.id)), ...dumps].filter( [...recentDumps.filter((d) => inScope(d) && !restIds.has(d.id)), ...dumps]
(d) => !deletedDumpIds.has(d.id) && d.id !== justDeletedId, .filter((d) => !deletedDumpIds.has(d.id) && d.id !== justDeletedId),
), [recentDumps, restIds, dumps, deletedDumpIds, justDeletedId, inScope],
[recentDumps, restIds, dumps, deletedDumpIds, justDeletedId],
); );
const mainFeedProps: MainFeedProps = { const mainFeedProps: MainFeedProps = {
@@ -252,6 +281,8 @@ export function Index() {
// ── Render ── // ── Render ──
if (categoryNotFound) return <NotFound />;
return ( return (
<div className="index-page"> <div className="index-page">
<AppHeader <AppHeader
@@ -275,6 +306,7 @@ export function Index() {
{tab === "journal" && <JournalFeed {...mainFeedProps} />} {tab === "journal" && <JournalFeed {...mainFeedProps} />}
{tab === "followed" && user && ( {tab === "followed" && user && (
<FollowedFeed <FollowedFeed
categorySlug={categorySlug}
voteCounts={voteCounts} voteCounts={voteCounts}
myVotes={myVotes} myVotes={myVotes}
user={user} user={user}

View File

@@ -37,6 +37,7 @@ import { PlaylistCard } from "../components/PlaylistCard.tsx";
import { NewPlaylistForm } from "../components/NewPlaylistForm.tsx"; import { NewPlaylistForm } from "../components/NewPlaylistForm.tsx";
import { PageShell } from "../components/PageShell.tsx"; import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx"; import { PageError } from "../components/PageError.tsx";
import { CategoryManager } from "../components/CategoryManager.tsx";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts"; import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { useTheme } from "../hooks/useTheme.ts"; import { useTheme } from "../hooks/useTheme.ts";
@@ -227,9 +228,16 @@ export function UserPublicProfile() {
// `settings` is own-profile only; `manage` requires the user:manage permission // `settings` is own-profile only; `manage` requires the user:manage permission
// on someone else's profile. Invalid tabs fall back to "dumps". // on someone else's profile. Invalid tabs fall back to "dumps".
const canManageUsers = can(me, "user:manage"); const canManageUsers = can(me, "user:manage");
const canManageCategories = can(me, "category:manage");
// The "manage" tab is an admin/moderation area. On someone else's profile it
// holds per-user tools (role management); on your own it holds global tools
// (category management, with more to come).
const showManageTab = isOwnProfile
? canManageCategories
: canManageUsers;
const availableTabs: ProfileTab[] = PROFILE_TABS.filter((t) => { const availableTabs: ProfileTab[] = PROFILE_TABS.filter((t) => {
if (t === "settings") return isOwnProfile; if (t === "settings") return isOwnProfile;
if (t === "manage") return !isOwnProfile && canManageUsers; if (t === "manage") return showManageTab;
return true; return true;
}); });
const [tab, setTab] = useTabParam<ProfileTab>(availableTabs, "dumps"); const [tab, setTab] = useTabParam<ProfileTab>(availableTabs, "dumps");
@@ -780,7 +788,10 @@ export function UserPublicProfile() {
</div> </div>
<div className="profile-info"> <div className="profile-info">
<div className="profile-info-scroll"> <div className="profile-info-scroll">
<h1 className="profile-username">{profileUser.username}</h1> <div className="profile-username-row">
<h1 className="profile-username">{profileUser.username}</h1>
<RoleChip role={profileUser.role} />
</div>
{profileUser.invitedByUsername {profileUser.invitedByUsername
? ( ? (
<p className="profile-invited-by"> <p className="profile-invited-by">
@@ -893,7 +904,7 @@ export function UserPublicProfile() {
...(isOwnProfile ...(isOwnProfile
? [{ key: "settings" as const, label: <Trans>Settings</Trans> }] ? [{ key: "settings" as const, label: <Trans>Settings</Trans> }]
: []), : []),
...(!isOwnProfile && canManageUsers ...(showManageTab
? [{ key: "manage" as const, label: <Trans>Manage</Trans> }] ? [{ key: "manage" as const, label: <Trans>Manage</Trans> }]
: []), : []),
]} ]}
@@ -1263,6 +1274,15 @@ export function UserPublicProfile() {
</div> </div>
</section> </section>
)} )}
{tab === "manage" && isOwnProfile && canManageCategories && (
<section className="profile-section">
<h2 className="profile-section-title">
<Trans>Categories</Trans>
</h2>
<CategoryManager />
</section>
)}
</PageShell> </PageShell>
); );
} }
@@ -1529,6 +1549,14 @@ const ROLE_OPTIONS: { value: Role; label: React.ReactNode }[] = [
{ value: "admin", label: <Trans>Admin</Trans> }, { value: "admin", label: <Trans>Admin</Trans> },
]; ];
// Role badge shown next to the username. The default "user" role is implicit,
// so only elevated roles get a chip to avoid labelling every profile.
function RoleChip({ role }: { role: Role }) {
if (role === "user") return null;
const label = ROLE_OPTIONS.find((o) => o.value === role)?.label;
return <span className={`profile-role-chip profile-role-chip--${role}`}>{label}</span>;
}
interface RoleSwitcherProps { interface RoleSwitcherProps {
userId: string; userId: string;
currentRole: Role; currentRole: Role;

View File

@@ -34,6 +34,7 @@ type FeedState =
type FollowedSection = "users" | "playlists"; type FollowedSection = "users" | "playlists";
interface FollowedFeedProps { interface FollowedFeedProps {
categorySlug?: string;
voteCounts: Record<string, number>; voteCounts: Record<string, number>;
myVotes: Set<string>; myVotes: Set<string>;
user: User; user: User;
@@ -122,6 +123,7 @@ function FollowedSubFeed({
// ── FollowedFeed ────────────────────────────────────────────────────────────── // ── FollowedFeed ──────────────────────────────────────────────────────────────
export function FollowedFeed({ export function FollowedFeed({
categorySlug,
voteCounts, voteCounts,
myVotes, myVotes,
user, user,
@@ -131,14 +133,20 @@ export function FollowedFeed({
}: FollowedFeedProps) { }: FollowedFeedProps) {
const { token } = useAuth(); const { token } = useAuth();
// Scope key + query suffix applied to every followed-feed request.
const scope = categorySlug ?? "all";
const catQuery = categorySlug
? `&category=${encodeURIComponent(categorySlug)}`
: "";
const { cached: cachedUsers, saveState: saveUsers } = useFeedCache<Dump>( const { cached: cachedUsers, saveState: saveUsers } = useFeedCache<Dump>(
`feed:followed-users:${user.id}`, `feed:followed-users:${scope}:${user.id}`,
hydrateDump, hydrateDump,
); );
const { cached: cachedPlaylists, saveState: savePlaylists } = useFeedCache< const { cached: cachedPlaylists, saveState: savePlaylists } = useFeedCache<
Dump Dump
>( >(
`feed:followed-playlists:${user.id}`, `feed:followed-playlists:${scope}:${user.id}`,
hydrateDump, hydrateDump,
); );
@@ -192,7 +200,7 @@ export function FollowedFeed({
if (!token) return; if (!token) return;
if (usersState.status === "loading") { if (usersState.status === "loading") {
fetch( fetch(
`${API_URL}/api/follows/feed/users?page=1&limit=${DEFAULT_PAGE_SIZE}`, `${API_URL}/api/follows/feed/users?page=1&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
{ headers: { Authorization: `Bearer ${token}` } }, { headers: { Authorization: `Bearer ${token}` } },
) )
.then((r) => r.json()) .then((r) => r.json())
@@ -212,7 +220,7 @@ export function FollowedFeed({
} }
if (playlistsState.status === "loading") { if (playlistsState.status === "loading") {
fetch( fetch(
`${API_URL}/api/follows/feed/playlists?page=1&limit=${DEFAULT_PAGE_SIZE}`, `${API_URL}/api/follows/feed/playlists?page=1&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
{ headers: { Authorization: `Bearer ${token}` } }, { headers: { Authorization: `Bearer ${token}` } },
) )
.then((r) => r.json()) .then((r) => r.json())
@@ -267,7 +275,7 @@ export function FollowedFeed({
s.status === "loaded" ? { ...s, loadingMore: true } : s s.status === "loaded" ? { ...s, loadingMore: true } : s
); );
fetch( fetch(
`${API_URL}/api/follows/feed/users?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`, `${API_URL}/api/follows/feed/users?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
{ headers: { Authorization: `Bearer ${token}` } }, { headers: { Authorization: `Bearer ${token}` } },
) )
.then((r) => r.json()) .then((r) => r.json())
@@ -302,7 +310,7 @@ export function FollowedFeed({
s.status === "loaded" ? { ...s, loadingMore: true } : s s.status === "loaded" ? { ...s, loadingMore: true } : s
); );
fetch( fetch(
`${API_URL}/api/follows/feed/playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`, `${API_URL}/api/follows/feed/playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
{ headers: { Authorization: `Bearer ${token}` } }, { headers: { Authorization: `Bearer ${token}` } },
) )
.then((r) => r.json()) .then((r) => r.json())

View File

@@ -306,6 +306,17 @@
box-shadow: 2px 2px 0 var(--color-accent); box-shadow: 2px 2px 0 var(--color-accent);
} }
/* ── Category management chips / inline editor ───────────────────── */
[data-style="brutalist"] .category-chip,
[data-style="brutalist"] .category-editor,
[data-style="brutalist"] .category-editor-btn {
border-radius: 0;
}
[data-style="brutalist"] .category-editor {
box-shadow: 2px 2px 0 var(--color-accent);
}
/* ── Search bar ──────────────────────────────────────────────────── */ /* ── Search bar ──────────────────────────────────────────────────── */
[data-style="brutalist"] .search-bar-input, [data-style="brutalist"] .search-bar-input,
[data-style="brutalist"] .search-bar-btn { [data-style="brutalist"] .search-bar-btn {
@@ -505,3 +516,26 @@
[data-style="brutalist"] .error-card { [data-style="brutalist"] .error-card {
border-radius: 0; border-radius: 0;
} }
/* ── Category switcher dropdown ──────────────────────────────────── */
/* The trigger reuses .feed-sort-btn, so it already wears this theme's button
chrome and lines up with the sort tabs. The menu keeps the shared popover
frame (matching .user-menu-dropdown here); only flatten its option buttons
so they read as list rows rather than chunky bevelled buttons. */
[data-style="brutalist"] .category-switcher-menu {
border-radius: 0;
}
[data-style="brutalist"] .category-switcher-option {
border: none !important;
border-radius: 0;
box-shadow: none !important;
}
[data-style="brutalist"] .category-switcher-option:hover:not(:disabled) {
transform: none !important;
box-shadow: none !important;
}
/* ── Journal masonry cards ───────────────────────────────────────── */
[data-style="brutalist"] .journal-card {
border-radius: 0;
}

View File

@@ -311,7 +311,8 @@
[data-style="geocities"] .nav-search-btn, [data-style="geocities"] .nav-search-btn,
[data-style="geocities"] .auth-link-btn, [data-style="geocities"] .auth-link-btn,
[data-style="geocities"] .form-cancel, [data-style="geocities"] .form-cancel,
[data-style="geocities"] .form-label-action { [data-style="geocities"] .form-label-action,
[data-style="geocities"] .category-editor-btn {
border: none !important; border: none !important;
box-shadow: none !important; box-shadow: none !important;
} }
@@ -488,10 +489,32 @@
[data-style="geocities"] .md code, [data-style="geocities"] .md code,
[data-style="geocities"] .md pre, [data-style="geocities"] .md pre,
[data-style="geocities"] .error-card, [data-style="geocities"] .error-card,
[data-style="geocities"] .feed-tab { [data-style="geocities"] .feed-tab,
[data-style="geocities"] .category-chip,
[data-style="geocities"] .category-editor,
[data-style="geocities"] .category-editor-btn {
border-radius: 0; border-radius: 0;
} }
/* The editor is one squared box; flatten its fields (geocities otherwise
forces a sunken inset border on every input) and drop the soft glow. */
[data-style="geocities"] .category-editor {
border: 2px solid var(--color-accent);
box-shadow: none;
}
[data-style="geocities"] .category-editor-input {
border: none !important;
background: transparent !important;
}
/* Chips are tags, not raised Win95 buttons — keep a clean flat border
instead of the generic 3px outset bevel. */
[data-style="geocities"] .category-chip {
border: 2px solid var(--color-border);
box-shadow: none;
}
[data-style="geocities"] .notification-badge { [data-style="geocities"] .notification-badge {
border-radius: 0; border-radius: 0;
animation: geo-blink 1.1s step-start infinite; animation: geo-blink 1.1s step-start infinite;
@@ -520,3 +543,17 @@
[data-style="geocities"] .invite-url { [data-style="geocities"] .invite-url {
text-transform: none; text-transform: none;
} }
/* ── Category switcher dropdown ──────────────────────────────────── */
/* Same ridged frame as the other popovers; option buttons flattened so the
chunky Win95 button bevel doesn't apply to menu rows. (The trigger reuses
.feed-sort-btn and keeps the bevel, matching the sort tabs.) */
[data-style="geocities"] .category-switcher-menu {
border-radius: 0;
border: 3px ridge var(--color-border);
box-shadow: 0 0 18px color-mix(in srgb, var(--color-border) 45%, transparent);
}
[data-style="geocities"] .category-switcher-option {
border: none !important;
box-shadow: none !important;
}

View File

@@ -534,6 +534,17 @@
border: none; border: none;
} }
/* Category management chips / inline editor: squared, no accent glow. */
[data-style="nyt"] .category-chip,
[data-style="nyt"] .category-editor,
[data-style="nyt"] .category-editor-btn {
border-radius: 0;
}
[data-style="nyt"] .category-editor {
box-shadow: none;
}
/* Links are now ink, so give the user-link classes a hover underline /* Links are now ink, so give the user-link classes a hover underline
to keep them findable without colour. */ to keep them findable without colour. */
[data-style="nyt"] .dump-op-link:hover, [data-style="nyt"] .dump-op-link:hover,
@@ -595,3 +606,12 @@
[data-style="nyt"] .playlist-card-inner:hover .playlist-card-preview img { [data-style="nyt"] .playlist-card-inner:hover .playlist-card-preview img {
transform: scale(1.09); transform: scale(1.09);
} }
/* ── Category switcher dropdown — paper sheet, squared ───────────── */
/* Match the other nyt popovers (1px rule + editorial shadow, no rounding).
Option rows inherit the global square-off, so no per-row rule is needed. */
[data-style="nyt"] .category-switcher-menu {
border-radius: 0;
border: 1px solid var(--color-border);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
}

View File

@@ -18,6 +18,7 @@ export interface JournalEntry {
/** A dump that can carry a real preview image (file image or rich thumbnail). */ /** A dump that can carry a real preview image (file image or rich thumbnail). */
export function hasThumbnail(dump: Dump): boolean { export function hasThumbnail(dump: Dump): boolean {
if (dump.kind === "file" && dump.fileMime?.startsWith("image/")) return true; if (dump.kind === "file" && dump.fileMime?.startsWith("image/")) return true;
if (dump.thumbnailMime) return true;
return !!dump.richContent?.thumbnailUrl; return !!dump.richContent?.thumbnailUrl;
} }

View File

@@ -5,13 +5,15 @@ export type Permission =
| "dump:moderate" | "dump:moderate"
| "comment:moderate" | "comment:moderate"
| "playlist:moderate" | "playlist:moderate"
| "user:manage"; | "user:manage"
| "category:manage";
const ALL_PERMISSIONS: readonly Permission[] = [ const ALL_PERMISSIONS: readonly Permission[] = [
"dump:moderate", "dump:moderate",
"comment:moderate", "comment:moderate",
"playlist:moderate", "playlist:moderate",
"user:manage", "user:manage",
"category:manage",
]; ];
const ROLE_PERMISSIONS: Record<Role, readonly Permission[]> = { const ROLE_PERMISSIONS: Record<Role, readonly Permission[]> = {