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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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