From fae25f3e6c2c2c02d98ef22fd757bbb5ac439d31 Mon Sep 17 00:00:00 2001 From: khannurien Date: Sun, 28 Jun 2026 16:23:44 +0000 Subject: [PATCH] v3: added site-wide categories, added admin category management, various visual fixes --- api/config.ts | 2 + api/db/migrate.ts | 2 + api/db/migrations/0006_categories.ts | 39 +++ api/db/schema.sql | 18 ++ api/lib/permissions.ts | 4 +- api/lib/reserved-slugs.ts | 33 +++ api/main.ts | 5 + api/model/db.ts | 35 +++ api/model/interfaces.ts | 56 ++++ api/routes/categories.ts | 117 ++++++++ api/routes/dumps.ts | 17 ++ api/routes/follows.ts | 37 ++- api/services/category-service.ts | 269 ++++++++++++++++++ api/services/dump-service.ts | 60 ++++- api/services/follow-service.ts | 37 ++- src/App.css | 390 ++++++++++++++++++++++++++- src/App.tsx | 46 +++- src/components/CategoryManager.tsx | Bin 0 -> 6946 bytes src/components/CategorySelect.tsx | 45 ++++ src/components/CategorySwitcher.tsx | 137 ++++++++++ src/components/DumpCard.tsx | 21 ++ src/components/DumpCreateModal.tsx | 19 ++ src/components/FeedTabBar.tsx | 22 +- src/components/JournalCard.tsx | 4 +- src/components/TabBar.tsx | 7 +- src/config/api.ts | 2 + src/contexts/CategoriesContext.ts | 18 ++ src/contexts/CategoriesProvider.tsx | 53 ++++ src/hooks/useCategories.ts | 4 + src/locales/en.js | 2 +- src/locales/en.po | 358 ++++++++++++++---------- src/locales/fr.js | 2 +- src/locales/fr.po | 358 ++++++++++++++---------- src/model.ts | 38 +++ src/pages/Dump.tsx | 18 ++ src/pages/DumpEdit.tsx | 20 ++ src/pages/Index.tsx | 94 ++++--- src/pages/UserPublicProfile.tsx | 34 ++- src/pages/index/FollowedFeed.tsx | 20 +- src/themes/brutalist.css | 34 +++ src/themes/geocities.css | 41 ++- src/themes/nyt.css | 20 ++ src/utils/journalLayout.ts | 1 + src/utils/permissions.ts | 4 +- 44 files changed, 2144 insertions(+), 399 deletions(-) create mode 100644 api/db/migrations/0006_categories.ts create mode 100644 api/lib/reserved-slugs.ts create mode 100644 api/routes/categories.ts create mode 100644 api/services/category-service.ts create mode 100644 src/components/CategoryManager.tsx create mode 100644 src/components/CategorySelect.tsx create mode 100644 src/components/CategorySwitcher.tsx create mode 100644 src/contexts/CategoriesContext.ts create mode 100644 src/contexts/CategoriesProvider.tsx create mode 100644 src/hooks/useCategories.ts diff --git a/api/config.ts b/api/config.ts index 945d530..9ab7ed8 100644 --- a/api/config.ts +++ b/api/config.ts @@ -86,6 +86,8 @@ export const VALIDATION = { PLAYLIST_DESCRIPTION_MAX: 2000, COMMENT_BODY_MAX: 5000, USER_DESCRIPTION_MAX: 2000, + CATEGORY_NAME_MAX: 50, + CATEGORY_SLUG_MAX: 50, } as const; // SEO/OG diff --git a/api/db/migrate.ts b/api/db/migrate.ts index 69fa3fa..b0a6164 100644 --- a/api/db/migrate.ts +++ b/api/db/migrate.ts @@ -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 up0004UserRoles } from "./migrations/0004_user_roles.ts"; import { up as up0005DropIsAdmin } from "./migrations/0005_drop_is_admin.ts"; +import { up as up0006Categories } from "./migrations/0006_categories.ts"; interface Migration { name: string; @@ -19,6 +20,7 @@ const MIGRATIONS: Migration[] = [ { name: "0003_dump_backlinks", up: up0003DumpBacklinks }, { name: "0004_user_roles", up: up0004UserRoles }, { name: "0005_drop_is_admin", up: up0005DropIsAdmin }, + { name: "0006_categories", up: up0006Categories }, ]; export function runMigrations(db: DatabaseSync): void { diff --git a/api/db/migrations/0006_categories.ts b/api/db/migrations/0006_categories.ts new file mode 100644 index 0000000..37e3a20 --- /dev/null +++ b/api/db/migrations/0006_categories.ts @@ -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);`, + ); + } +} diff --git a/api/db/schema.sql b/api/db/schema.sql index 38ef6b0..78c0983 100644 --- a/api/db/schema.sql +++ b/api/db/schema.sql @@ -63,6 +63,23 @@ CREATE TABLE playlist_dumps ( 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 ( id TEXT PRIMARY KEY, 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_dump_backlinks_to ON dump_backlinks(to_dump_id); CREATE INDEX idx_dump_backlinks_from ON dump_backlinks(from_dump_id); +CREATE INDEX idx_dump_categories_category ON dump_categories(category_id); CREATE TABLE follows ( id TEXT PRIMARY KEY, diff --git a/api/lib/permissions.ts b/api/lib/permissions.ts index 0b22567..1ee506c 100644 --- a/api/lib/permissions.ts +++ b/api/lib/permissions.ts @@ -6,13 +6,15 @@ export type Permission = | "dump:moderate" | "comment:moderate" | "playlist:moderate" - | "user:manage"; + | "user:manage" + | "category:manage"; const ALL_PERMISSIONS: readonly Permission[] = [ "dump:moderate", "comment:moderate", "playlist:moderate", "user:manage", + "category:manage", ]; // Roles are fixed in code. `admin` is a superset of every permission (current diff --git a/api/lib/reserved-slugs.ts b/api/lib/reserved-slugs.ts new file mode 100644 index 0000000..7ea87b2 --- /dev/null +++ b/api/lib/reserved-slugs.ts @@ -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); +} diff --git a/api/main.ts b/api/main.ts index 773d3dd..f100628 100644 --- a/api/main.ts +++ b/api/main.ts @@ -15,6 +15,7 @@ import followsRouter from "./routes/follows.ts"; import notificationsRouter from "./routes/notifications.ts"; import invitesRouter from "./routes/invites.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 { errorMiddleware } from "./middleware/error.ts"; @@ -93,6 +94,10 @@ app.use( searchRouter.routes(), searchRouter.allowedMethods(), ); +app.use( + categoriesRouter.routes(), + categoriesRouter.allowedMethods(), +); app.use(routeStaticFilesFrom([ `${Deno.cwd()}/dist`, `${Deno.cwd()}/public`, diff --git a/api/model/db.ts b/api/model/db.ts index 52fa089..fb1836e 100644 --- a/api/model/db.ts +++ b/api/model/db.ts @@ -1,6 +1,7 @@ import { randomBytes, scryptSync } from "node:crypto"; import { DatabaseSync, type SQLOutputValue } from "node:sqlite"; import { + type Category, type Comment, Dump, 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 { id: string; follower_id: string; diff --git a/api/model/interfaces.ts b/api/model/interfaces.ts index cc567ce..612f76b 100644 --- a/api/model/interfaces.ts +++ b/api/model/interfaces.ts @@ -33,6 +33,54 @@ export interface Dump { commentCount: number; isPrivate: boolean; 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; + 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; + 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 */ +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((v) => typeof v === "string"); +} + export interface CreateUrlDumpRequest { url: string; comment?: string; isPrivate?: boolean; + categoryIds?: string[]; } export function isCreateUrlDumpRequest( @@ -412,6 +465,7 @@ export function isCreateUrlDumpRequest( (o.comment as string).length > VALIDATION.DUMP_COMMENT_MAX ) return false; if ("isPrivate" in o && typeof o.isPrivate !== "boolean") return false; + if ("categoryIds" in o && !isStringArray(o.categoryIds)) return false; return true; } @@ -420,6 +474,7 @@ export interface UpdateDumpRequest { title?: string; comment?: string; isPrivate?: boolean; + categoryIds?: string[]; } 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 ) return false; if ("isPrivate" in o && typeof o.isPrivate !== "boolean") return false; + if ("categoryIds" in o && !isStringArray(o.categoryIds)) return false; return true; } diff --git a/api/routes/categories.ts b/api/routes/categories.ts new file mode 100644 index 0000000..bf6b1b9 --- /dev/null +++ b/api/routes/categories.ts @@ -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 = { + success: true, + data: listCategories(), + }; + ctx.response.body = responseBody; +}); + +router.get("/:slug", (ctx) => { + const category = getCategoryBySlug(ctx.params.slug); + const responseBody: APIResponse = { + 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> = { + 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 = { + 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 = { + success: true, + data: category, + }; + ctx.response.body = responseBody; + }, +); + +router.delete( + "/:id", + authMiddleware, + requirePermission("category:manage"), + (ctx) => { + deleteCategory(ctx.params.id); + const responseBody: APIResponse = { success: true, data: null }; + ctx.response.body = responseBody; + }, +); + +export default router; diff --git a/api/routes/dumps.ts b/api/routes/dumps.ts index b0b3579..d2dff30 100644 --- a/api/routes/dumps.ts +++ b/api/routes/dumps.ts @@ -33,6 +33,19 @@ import { getRelatedDumps } from "../services/backlink-service.ts"; 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( "/", authMiddleware, @@ -63,6 +76,7 @@ router.post( userId, isPrivate, typeof title === "string" && title ? title : undefined, + parseCategoryIds(formData.get("categoryIds")), ); } else { const body = await ctx.request.body.json(); @@ -156,6 +170,9 @@ router.put("/:dumpId/file", authMiddleware, async (ctx) => { file, typeof comment === "string" && comment ? comment : undefined, typeof title === "string" && title ? title : undefined, + formData.has("categoryIds") + ? parseCategoryIds(formData.get("categoryIds")) + : undefined, ); const responseBody: APIResponse = { success: true, data: updatedDump }; ctx.response.body = responseBody; diff --git a/api/routes/follows.ts b/api/routes/follows.ts index 6585405..4fd8f66 100644 --- a/api/routes/follows.ts +++ b/api/routes/follows.ts @@ -16,6 +16,20 @@ import { unfollowPlaylist, unfollowUser, } from "../services/follow-service.ts"; +import { getCategoryIdBySlug } from "../services/category-service.ts"; + +// Resolves the optional ?category= 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" }); @@ -31,11 +45,10 @@ router.get("/status", authMiddleware, (ctx) => { // GET /api/follows/feed/users?page=&limit= router.get("/feed/users", authMiddleware, (ctx) => { const { page, limit } = parsePagination(ctx.request.url.searchParams); - const { items, total } = getFollowedUsersDumpFeed( - ctx.state.user.userId, - page, - limit, - ); + const categoryId = resolveCategoryFilter(ctx.request.url.searchParams); + const { items, total } = categoryId === "missing" + ? EMPTY_FEED + : getFollowedUsersDumpFeed(ctx.state.user.userId, page, limit, categoryId); const data: PaginatedData = { items, total, @@ -48,11 +61,15 @@ router.get("/feed/users", authMiddleware, (ctx) => { // GET /api/follows/feed/playlists?page=&limit= router.get("/feed/playlists", authMiddleware, (ctx) => { const { page, limit } = parsePagination(ctx.request.url.searchParams); - const { items, total } = getFollowedPlaylistsDumpFeed( - ctx.state.user.userId, - page, - limit, - ); + const categoryId = resolveCategoryFilter(ctx.request.url.searchParams); + const { items, total } = categoryId === "missing" + ? EMPTY_FEED + : getFollowedPlaylistsDumpFeed( + ctx.state.user.userId, + page, + limit, + categoryId, + ); const data: PaginatedData = { items, total, diff --git a/api/services/category-service.ts b/api/services/category-service.ts new file mode 100644 index 0000000..75336a7 --- /dev/null +++ b/api/services/category-service.ts @@ -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(); + 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 }; +} diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts index d22ee7e..68bdb1c 100644 --- a/api/services/dump-service.ts +++ b/api/services/dump-service.ts @@ -33,6 +33,7 @@ import { } from "../config.ts"; import { linkAttachments } from "./attachment-service.ts"; import { relinkBacklinks } from "./backlink-service.ts"; +import { attachCategoryIds, setDumpCategories } from "./category-service.ts"; function isAllowedMime(mime: string): boolean { return DUMP_ALLOWED_MIME_PREFIXES.some((p) => mime.startsWith(p)) || @@ -78,6 +79,9 @@ export async function createUrlDump( isPrivate ? 1 : 0, ); + const categoryIds = [...new Set(request.categoryIds ?? [])]; + setDumpCategories(dumpId, categoryIds); + const dump: Dump = { id: dumpId, kind: "url", @@ -91,6 +95,7 @@ export async function createUrlDump( voteCount: 0, commentCount: 0, isPrivate, + categoryIds, }; if (!isPrivate) { broadcastNewDump(dump); @@ -110,6 +115,7 @@ export async function createFileDump( userId: string, isPrivate = false, title?: string, + categoryIds: string[] = [], ): Promise { if (!isAllowedMime(file.type)) { throw new APIException( @@ -130,6 +136,7 @@ export async function createFileDump( const createdAt = new Date(); const finalTitle = title?.trim() || file.name; const slug = makeSlug(finalTitle, dumpId); + const dedupedCategoryIds = [...new Set(categoryIds)]; await Deno.mkdir(DUMPS_DIR, { recursive: true }); const data = new Uint8Array(await file.arrayBuffer()); @@ -153,6 +160,7 @@ export async function createFileDump( file.size, isPrivate ? 1 : 0, ); + setDumpCategories(dumpId, dedupedCategoryIds); } catch (err) { // Roll back the file if DB insert fails await Deno.remove(`${DUMPS_DIR}/${dumpId}`).catch(() => {}); @@ -173,6 +181,7 @@ export async function createFileDump( voteCount: 0, commentCount: 0, isPrivate, + categoryIds: dedupedCategoryIds, }; if (!isPrivate) { broadcastNewDump(dump); @@ -205,6 +214,7 @@ export function getDump(dumpId: string, requestingUserId?: string): Dump { if (dump.isPrivate && dump.userId !== requestingUserId) { throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found"); } + attachCategoryIds([dump]); return dump; } @@ -229,10 +239,9 @@ export function searchDumps( const totalRow = db.prepare( `SELECT COUNT(*) as count FROM dumps WHERE (is_private = 0 OR user_id = ?) AND ${searchClause};`, ).get(requestingUserId, ...searchParams) as { count: number } | undefined; - return { - items: rows.filter(isDumpRow).map(dumpRowToApi), - total: totalRow?.count ?? 0, - }; + const items = rows.filter(isDumpRow).map(dumpRowToApi); + attachCategoryIds(items); + return { items, total: totalRow?.count ?? 0 }; } else { const rows = db.prepare( `SELECT ${SELECT_COLS} FROM dumps WHERE is_private = 0 AND ${searchClause} ORDER BY created_at DESC LIMIT ? OFFSET ?;`, @@ -240,10 +249,9 @@ export function searchDumps( const totalRow = db.prepare( `SELECT COUNT(*) as count FROM dumps WHERE is_private = 0 AND ${searchClause};`, ).get(...searchParams) as { count: number } | undefined; - return { - items: rows.filter(isDumpRow).map(dumpRowToApi), - total: totalRow?.count ?? 0, - }; + const items = rows.filter(isDumpRow).map(dumpRowToApi); + attachCategoryIds(items); + 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( @@ -314,6 +324,13 @@ export async function updateDump( now.toISOString(), 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); else if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump); if (updatedDump.comment) { @@ -382,6 +399,14 @@ export async function updateDump( 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); else if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump); if (updatedDump.comment) { @@ -403,6 +428,7 @@ export async function replaceFileDump( file: File, comment: string | undefined, title?: string, + categoryIds?: string[], ): Promise { if (!isAllowedMime(file.type)) { throw new APIException( @@ -455,6 +481,14 @@ export async function replaceFileDump( throw err; } + if (categoryIds !== undefined) { + const ids = [...new Set(categoryIds)]; + setDumpCategories(dumpId, ids); + dump.categoryIds = ids; + } else { + attachCategoryIds([dump]); + } + if (comment) { notifyMentions(dump.userId, comment, "dump", dumpId, finalTitle); linkAttachments(comment, dumpId); @@ -493,7 +527,9 @@ export function getDumpsByUser( "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( @@ -544,7 +580,9 @@ export function getVotedDumpsByUser( "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 { diff --git a/api/services/follow-service.ts b/api/services/follow-service.ts index 2889902..bfccd3b 100644 --- a/api/services/follow-service.ts +++ b/api/services/follow-service.ts @@ -21,6 +21,7 @@ import { playlistRowToApi, userRowToApi, } from "../model/db.ts"; +import { attachCategoryIds } from "./category-service.ts"; // ── Follow / unfollow a user ────────────────────────────────────────────────── @@ -139,26 +140,35 @@ export function getFollowedUsersDumpFeed( followerId: string, page: number, limit: number, + categoryId?: string, ): { items: Dump[]; total: number } { 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( `SELECT ${SELECT_COLS_ALIASED} FROM dumps d INNER JOIN follows f ON f.followed_user_id = d.user_id + ${catJoin} WHERE f.follower_id = ? AND d.is_private = 0 ORDER BY d.created_at DESC LIMIT ? OFFSET ?;`, - ).all(followerId, limit, offset); + ).all(...catParams, followerId, limit, offset); const totalRow = db.prepare( `SELECT COUNT(*) as count FROM dumps d INNER JOIN follows f ON f.followed_user_id = d.user_id + ${catJoin} WHERE f.follower_id = ? AND d.is_private = 0;`, - ).get(followerId) as { count: number } | undefined; + ).get(...catParams, followerId) as { count: number } | undefined; const userFeedRows = rawRows as Parameters[0][]; if (!userFeedRows.every(isDumpRow)) { @@ -168,7 +178,9 @@ export function getFollowedUsersDumpFeed( "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 ────────────────────────────────────────────── @@ -177,22 +189,29 @@ export function getFollowedPlaylistsDumpFeed( followerId: string, page: number, limit: number, + categoryId?: string, ): { items: Dump[]; total: number } { 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( `SELECT ${SELECT_COLS_ALIASED} FROM dumps d INNER JOIN playlist_dumps pd ON pd.dump_id = d.id INNER JOIN playlists p ON p.id = pd.playlist_id INNER JOIN follows f ON f.followed_playlist_id = p.id + ${catJoin} WHERE f.follower_id = ? AND p.is_public = 1 AND d.is_private = 0 GROUP BY d.id ORDER BY MAX(pd.added_at) DESC LIMIT ? OFFSET ?;`, - ).all(followerId, limit, offset); + ).all(...catParams, followerId, limit, offset); const totalRow = db.prepare( `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 playlists p ON p.id = pd.playlist_id INNER JOIN follows f ON f.followed_playlist_id = p.id + ${catJoin} WHERE f.follower_id = ? AND p.is_public = 1 AND d.is_private = 0;`, - ).get(followerId) as { count: number } | undefined; + ).get(...catParams, followerId) as { count: number } | undefined; if (!rawRows.every(isDumpRow)) { throw new APIException( @@ -212,10 +232,9 @@ export function getFollowedPlaylistsDumpFeed( "Malformed dump data", ); } - return { - items: rawRows.map(dumpRowToApi), - total: totalRow?.count ?? 0, - }; + const items = rawRows.map(dumpRowToApi); + attachCategoryIds(items); + return { items, total: totalRow?.count ?? 0 }; } // ── Followed playlists (as playlist objects) ────────────────────────────────── diff --git a/src/App.css b/src/App.css index 61678f0..29796ae 100644 --- a/src/App.css +++ b/src/App.css @@ -1570,12 +1570,36 @@ body.has-fab .page-content { } /* ── Profile (own) page ── */ +.profile-username-row { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + .profile-username { margin: 0; font-size: 1.25rem; 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 { margin-top: 0.5rem; } @@ -3840,30 +3864,46 @@ body.has-fab .page-content { 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 { - position: relative; - margin-left: 11px; /* center 2px line on 24px avatar: 12px - 1px */ + margin-left: 11px; padding-left: calc(12px + 0.5rem); } -/* Vertical connector: starts below parent avatar, stops at last child avatar center */ -.invite-tree-node > .invite-tree::before { - content: ""; - position: absolute; - left: 0; - top: 0; - bottom: 12px; - width: 2px; - background: var(--color-border-subtle); +/* Connectors are owned per child node (not by the list) so they always anchor + to that child's own avatar center, regardless of how deep its subtree is. + --avatar-center = node top padding (0.2rem) + half the 24px avatar (12px). */ +.invite-tree-node > .invite-tree > .invite-tree-node { + --avatar-center: calc(0.2rem + 12px); } -/* 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 { content: ""; position: absolute; 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); height: 2px; background: var(--color-border-subtle); @@ -4836,3 +4876,325 @@ body.has-fab .page-content { font-weight: 600; 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); +} diff --git a/src/App.tsx b/src/App.tsx index c24fadd..99fa06c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,11 @@ 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 { RestrictedLoggedIn } from "./pages/RestrictedLoggedIn.tsx"; @@ -10,6 +16,7 @@ import { AuthProvider } from "./contexts/AuthProvider.tsx"; import { PlayerProvider } from "./contexts/PlayerProvider.tsx"; import { WSProvider } from "./contexts/WSProvider.tsx"; import { FollowProvider } from "./contexts/FollowProvider.tsx"; +import { CategoriesProvider } from "./contexts/CategoriesProvider.tsx"; import { ThemeProvider } from "./contexts/ThemeProvider.tsx"; import { GlobalPlayer } from "./components/GlobalPlayer.tsx"; import { DumpFab } from "./components/DumpFab.tsx"; @@ -69,12 +76,29 @@ const NotFound = lazy(() => 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 [preferredTab] = useDefaultFeedTab(); - // "followed" requires a session, so fall back to "hot" for guests. - const tab = preferredTab === "followed" && !user ? "hot" : preferredTab; - return ; + return preferredTab === "followed" && !user ? "hot" : preferredTab; +} + +function IndexRedirect() { + return ; +} + +// Bare `/` lands on that category's default feed tab. +function CategoryRedirect() { + const { categorySlug } = useParams(); + return ; +} + +// 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 ; } function AppRoutes() { @@ -83,7 +107,7 @@ function AppRoutes() { } /> - } /> + } /> } /> } /> + } /> + } /> } /> @@ -147,9 +173,11 @@ function App() { - - - + + + + + diff --git a/src/components/CategoryManager.tsx b/src/components/CategoryManager.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c9e43bd21148a185a17bfb8c228fa72f2fe97de2 GIT binary patch literal 6946 zcmb_h-EQN!74CJO;*79ZIJ2?T>x`Y$&Cg=CnPiZkZHq-QW-K}(MwVQWPGWcr^c9Mt z=uLs%^}+TD`Uw3FDN2;=WU|2KA`_A4kmvt=hoffmqBOLkg_c)F7)fiImc@*sQi_R* z-t8z)Vd|~lWh%d0sAN)mqy?Ox`8R?fVGA?Pi{C zO;Bclg%X;;#8#@9ZRR-_n)Ljml+4 zIGZAKFKJ?3N0WOsC#9*F%RHjqgN{kir1CSVlx`)?C7CKC)7_ZfCOe1{ZRq^uk2Iv{ zH@SR-G5C@ldP}2prgFM@Po}7D*t5JO@|56LiI}fWhihWGlu)yjDXCn;NW_vwA4D$h zWW?vq%VMfBtczRf-9an}w6s)XFXaBrl8g*a9d@91QN}dl(@ZH8CnH7QMb2<+HEqSw@IplmUTMzUvxgH6<~UMleGbnIBb!crjiS5I(LqH5B3nNX*mkhS&wu;x zAaN`HkYw(Nt?iIZoofcEdI+!aLD}cLYA#w;>m2hlG4J&}9hz zqao35Aj5uFn_F8L1B%yZ!JTOsIabt+)=v-sURmpzE*GY3*-!LM;}kC5>v5YrG#6{rp>C--NSxv!k2f*ot!Qic zIA2h>>m%Cn^injv7zxW-4QE%<4ic0h=0lZ&(>LLQD-S>- zbB7ri5?la@aGNe|3${&+!x;Dg{VQ_tAR^bn(3()7t6FooU-u<$C_XaaOWO<{3YAvt zE-2a?kZ+^CIq`aRM_caIr`o^VIkavr4ipM|qSEK`5pBkcYD?(?5MzpFZl&_QQvgn$ zN;9eZB;(7lLox_+4Y5LKb#^%HbF|)E2+h`F zvu2OK-5#wO53Yk2ZTW@_*kMm!g2gvbRZiDI{3|862z9EG-_ehw(4l1~lN!RXD*v@xLXthei}%ND4X2ta}>sCX2Jb&t{q!CZ3S(SnT} zOUw+16-yl@z414zYIs`ne3Vo^L_;6%RRS_2(M+2ABHgFx zio-gzWK$p@Abe`kN0L+_O}Uhqux<;Wc0@Xc*LQ9(bnBS7@Bp&F%ssK;fm;iVL{)j3 zi2O=C@VxQH7b>0yg5)PgUQxuWVgA>vHVs`6(U&j2Cc5Bj%?*Q7R7XFu(<+W*u2E~> zl%K`>u66bwOEC|}3ud-=yCSd+vpCKWqWA(iT=~C3fLP>zkjvxZF$Y?eL4EXPhOfTqhB{idmVXWpz`^`1d{W7Ru5CYf%Pk`Epgp+TdV!PZCb69 z9Huk!$%I-H%`-f4+!q;M1QV-;&5{I-9d8|JGpM}@+pOf%IxXAHtey_VHp%$Zw*P^b{K&ls%r`x=$5ofpUq zU~~YoGdo~Jc&6wQG$H(^Dwbgs5`~e%V*mW_f3!p$B%u~<>TP404~f^0-IWcYVpsNh z9`ln#TlBBzCxr3ywR^7UgcsU&YdqUZ)Yh3T^v{lkHYaV&=6Imukpq3F0SQ%HY?)?x z=lO}2cVDQo&qj=E&t0=x+|YT|2=S6dK3!A4#@K4s&qVZ4S3B-GZFp=qh5I_YPZV$G?nM$vN(TAWDvK=vl$}#r89RevUIL6)3*|5Htq5#r!i(N;DbbXJPeB^`)pP24B2x z$AOF%P_a0!l58H^qD4MB;fE`S$iryy$r`+wh0*2EV8c*br+%^sffhRpU~Ikff4VB^ A#sB~S literal 0 HcmV?d00001 diff --git a/src/components/CategorySelect.tsx b/src/components/CategorySelect.tsx new file mode 100644 index 0000000..26aeaf8 --- /dev/null +++ b/src/components/CategorySelect.tsx @@ -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; + onToggle: (id: string) => void; + disabled?: boolean; + }, +) { + const { categories } = useCategories(); + if (categories.length === 0) return null; + + return ( +
+ + Categories + +
+ {categories.map((category) => { + const isActive = selected.has(category.id); + return ( + + ); + })} +
+
+ ); +} diff --git a/src/components/CategorySwitcher.tsx b/src/components/CategorySwitcher.tsx new file mode 100644 index 0000000..05577bd --- /dev/null +++ b/src/components/CategorySwitcher.tsx @@ -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