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

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

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

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

View File

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

View File

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

View File

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