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
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 2m52s
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
39
api/db/migrations/0006_categories.ts
Normal file
39
api/db/migrations/0006_categories.ts
Normal 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);`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
33
api/lib/reserved-slugs.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
117
api/routes/categories.ts
Normal 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;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
269
api/services/category-service.ts
Normal file
269
api/services/category-service.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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) ──────────────────────────────────
|
||||||
|
|||||||
390
src/App.css
390
src/App.css
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
46
src/App.tsx
46
src/App.tsx
@@ -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 />
|
||||||
|
|||||||
BIN
src/components/CategoryManager.tsx
Normal file
BIN
src/components/CategoryManager.tsx
Normal file
Binary file not shown.
45
src/components/CategorySelect.tsx
Normal file
45
src/components/CategorySelect.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
src/components/CategorySwitcher.tsx
Normal file
137
src/components/CategorySwitcher.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 />}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
18
src/contexts/CategoriesContext.ts
Normal file
18
src/contexts/CategoriesContext.ts
Normal 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 () => {},
|
||||||
|
});
|
||||||
53
src/contexts/CategoriesProvider.tsx
Normal file
53
src/contexts/CategoriesProvider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
src/hooks/useCategories.ts
Normal file
4
src/hooks/useCategories.ts
Normal 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
@@ -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
@@ -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 l’enregistrement 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 d’URL"
|
||||||
|
|
||||||
|
#: 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
|
||||||
|
|||||||
38
src/model.ts
38
src/model.ts
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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[]> = {
|
||||||
|
|||||||
Reference in New Issue
Block a user