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:
35
src/App.css
35
src/App.css
@@ -2237,11 +2237,11 @@ body.has-player .chat-fab {
|
||||
font-size: var(--text-2xs);
|
||||
}
|
||||
|
||||
.profile-columns .dump-card-categories {
|
||||
.profile-columns .category-links {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.profile-columns .dump-card-category-chip {
|
||||
.profile-columns .category-link {
|
||||
padding: 0.05rem 0.4rem;
|
||||
font-size: var(--text-2xs);
|
||||
}
|
||||
@@ -2761,9 +2761,11 @@ body.has-player .chat-fab {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* A list of dump or playlist cards in a group fills the group's column, its
|
||||
edges in line with the label's rule. */
|
||||
.page-group .dump-feed {
|
||||
/* A list of dump or playlist cards in a group, or right under a page's header
|
||||
as on /playlists, fills the column, its edges in line with the label's rule
|
||||
or the header. */
|
||||
.page-group .dump-feed,
|
||||
.page-content--narrow > .dump-feed {
|
||||
padding: 0;
|
||||
max-width: none;
|
||||
}
|
||||
@@ -7249,6 +7251,21 @@ body.has-player .chat-fab {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* The category menu of the dumps and playlists tabs, at the end of the
|
||||
results' column, as at the end of a profile list's title row. */
|
||||
.search-filter {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
max-width: 860px;
|
||||
padding: 0.75rem 1.25rem 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.search-filter:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.search-page-empty {
|
||||
padding: 3rem 1.25rem;
|
||||
color: var(--color-text-secondary);
|
||||
@@ -7532,15 +7549,15 @@ body.has-player .chat-fab {
|
||||
color: var(--color-on-accent);
|
||||
}
|
||||
|
||||
/* Read-only category chips on dump cards */
|
||||
.dump-card-categories {
|
||||
/* Read-only category chips on dump and playlist cards and pages */
|
||||
.category-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.dump-card-category-chip {
|
||||
.category-link {
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-header-user-bg);
|
||||
@@ -7549,7 +7566,7 @@ body.has-player .chat-fab {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.dump-card-category-chip:hover {
|
||||
.category-link:hover {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-on-accent);
|
||||
}
|
||||
|
||||
10
src/App.tsx
10
src/App.tsx
@@ -21,11 +21,13 @@
|
||||
* `federation:manage` (`Manage`).
|
||||
* - `/community` and `/community/monthly`, the site's week and month
|
||||
* (`Community`), signed in only.
|
||||
* - `/playlists` is every public playlist, and `/playlists/:categorySlug` a
|
||||
* playlist category's (`Playlists`).
|
||||
* - `/@:username/*` is a user's (`UserRoutes`): their profile and its tabs,
|
||||
* their dumps at `dumps/:slug` (`Dump`) and `dumps/:slug/edit` (`DumpEdit`,
|
||||
* signed in only), and their playlists at `playlists/:slug`
|
||||
* (`PlaylistDetail`).
|
||||
* - `/:categorySlug` and `/:categorySlug/:feedTab` are a category's feed
|
||||
* - `/:categorySlug` and `/:categorySlug/:feedTab` are a dump category's feed
|
||||
* (`Index`).
|
||||
* - Any other path is `NotFound`.
|
||||
*
|
||||
@@ -68,6 +70,9 @@ const Index = lazy(() =>
|
||||
const Dump = lazy(() =>
|
||||
import("./pages/Dump.tsx").then((m) => ({ default: m.Dump }))
|
||||
);
|
||||
const Playlists = lazy(() =>
|
||||
import("./pages/Playlists.tsx").then((m) => ({ default: m.Playlists }))
|
||||
);
|
||||
const DumpEdit = lazy(() =>
|
||||
import("./pages/DumpEdit.tsx").then((m) => ({ default: m.DumpEdit }))
|
||||
);
|
||||
@@ -240,6 +245,9 @@ function AppRoutes() {
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{["/playlists", "/playlists/:categorySlug"].map((path) => (
|
||||
<Route key={path} path={path} element={<Playlists />} />
|
||||
))}
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
|
||||
62
src/components/category/CategoryChips.tsx
Normal file
62
src/components/category/CategoryChips.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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))}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -47,6 +47,7 @@ export function ProfileDumpsTab(
|
||||
actions={
|
||||
<div className="profile-section-actions">
|
||||
<CategorySwitcher
|
||||
kind="dump"
|
||||
categorySlug={categorySlug}
|
||||
onSelect={(slug) =>
|
||||
setParams((p) => {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Shares the site's categories with every component. `CategoriesProvider` fills
|
||||
* this context and `useCategories` reads it.
|
||||
* Shares the site's categories, of both kinds, with every component.
|
||||
* `CategoriesProvider` fills this context and `useCategories` reads one kind's
|
||||
* from it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -10,13 +11,15 @@ import type { Category } from "../../../shared/category.ts";
|
||||
|
||||
/**
|
||||
* Provided by `CategoriesProvider` to `useCategories`, for the category
|
||||
* switcher and picker, the dump pages and cards, and the category manager.
|
||||
* switchers and pickers, the dump and playlist pages and cards, and the
|
||||
* category manager.
|
||||
*/
|
||||
export interface CategoriesContextValue {
|
||||
/** Every category, in the server's order, by position then slug. */
|
||||
/**
|
||||
* Every category, dump and playlist ones, in the server's order, by kind,
|
||||
* position then slug.
|
||||
*/
|
||||
categories: Category[];
|
||||
/** Resolves a dump's `categoryIds` to its category chips. */
|
||||
byId: Map<string, Category>;
|
||||
/**
|
||||
* Whether the first fetch has ended. It is also true after a network error,
|
||||
* with no categories.
|
||||
@@ -35,7 +38,6 @@ export const CategoriesContext: React.Context<CategoriesContextValue> =
|
||||
CategoriesContextValue
|
||||
>({
|
||||
categories: [],
|
||||
byId: new Map(),
|
||||
isLoaded: false,
|
||||
refresh: async () => {},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Spares every page fetching the site's categories: it loads them once for the
|
||||
* whole app, and again when the category manager changes them. `App` mounts it.
|
||||
* Spares every page fetching the site's categories: it loads both kinds once
|
||||
* for the whole app, and again when the category manager changes them. `App`
|
||||
* mounts it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -26,7 +27,7 @@ import { expectOk } from "../../utils/shared/api.ts";
|
||||
const RETRY_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Returns every category.
|
||||
* Returns every category, of both kinds.
|
||||
*
|
||||
* @throws {Error} When the request fails or the API answers a failure.
|
||||
*/
|
||||
@@ -81,14 +82,9 @@ export function CategoriesProvider(
|
||||
|
||||
const refresh = useCallback(() => load().catch(() => {}), [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],
|
||||
() => ({ categories, isLoaded, refresh }),
|
||||
[categories, isLoaded, refresh],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,20 +1,45 @@
|
||||
/**
|
||||
* Gives components the categories that `CategoriesProvider` loads once for the
|
||||
* whole app.
|
||||
* Gives components the categories of one kind, of those `CategoriesProvider`
|
||||
* loads once for the whole app.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useContext } from "react";
|
||||
import {
|
||||
CategoriesContext,
|
||||
type CategoriesContextValue,
|
||||
} from "../../contexts/category/CategoriesContext.ts";
|
||||
import { useContext, useMemo } from "react";
|
||||
import { CategoriesContext } from "../../contexts/category/CategoriesContext.ts";
|
||||
import type { Category, CategoryKind } from "../../../shared/category.ts";
|
||||
|
||||
/** Returned by {@link useCategories}: one kind's categories. */
|
||||
export interface KindCategories {
|
||||
/** The categories of the kind, by position then slug. */
|
||||
categories: Category[];
|
||||
/** Resolves a dump's or playlist's `categoryIds` to its category chips. */
|
||||
byId: Map<string, Category>;
|
||||
/**
|
||||
* Whether the first fetch has ended. It is also true after a network error,
|
||||
* with no categories.
|
||||
*/
|
||||
isLoaded: boolean;
|
||||
/** Refetches both kinds. The category manager calls it after each change. */
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns every category and a function that refetches them. Under no
|
||||
* `CategoriesProvider`, the list is empty.
|
||||
* Returns the categories of `kind`, dump or playlist, and a function that
|
||||
* refetches them. Under no `CategoriesProvider`, the list is empty.
|
||||
*/
|
||||
export function useCategories(): CategoriesContextValue {
|
||||
return useContext(CategoriesContext);
|
||||
export function useCategories(kind: CategoryKind): KindCategories {
|
||||
const { categories: all, isLoaded, refresh } = useContext(CategoriesContext);
|
||||
const categories = useMemo(
|
||||
() => all.filter((c) => c.kind === kind),
|
||||
[all, kind],
|
||||
);
|
||||
const byId = useMemo(
|
||||
() => new Map(categories.map((c) => [c.id, c])),
|
||||
[categories],
|
||||
);
|
||||
return useMemo(
|
||||
() => ({ categories, byId, isLoaded, refresh }),
|
||||
[categories, byId, isLoaded, refresh],
|
||||
);
|
||||
}
|
||||
|
||||
30
src/hooks/category/useCategoryFilter.ts
Normal file
30
src/hooks/category/useCategoryFilter.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Keeps a list narrowed to one category, whatever the socket puts in it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
|
||||
/**
|
||||
* Returns `setItems` wrapped so that every list it sets keeps only the items
|
||||
* filed under `categoryId`, or `setItems` as it is without one. A list of one
|
||||
* category's dumps or playlists passes it to the hooks that apply the
|
||||
* socket's messages, so that one created outside the category isn't added,
|
||||
* and one edited out of it leaves.
|
||||
*/
|
||||
export function useCategoryFilter<T extends { categoryIds?: string[] }>(
|
||||
setItems: (fn: (prev: T[]) => T[]) => void,
|
||||
categoryId?: string,
|
||||
): (fn: (prev: T[]) => T[]) => void {
|
||||
return useCallback(
|
||||
(fn: (prev: T[]) => T[]) =>
|
||||
setItems((prev) => {
|
||||
const next = fn(prev);
|
||||
return categoryId
|
||||
? next.filter((item) => item.categoryIds?.includes(categoryId))
|
||||
: next;
|
||||
}),
|
||||
[setItems, categoryId],
|
||||
);
|
||||
}
|
||||
26
src/hooks/category/useCategorySelection.ts
Normal file
26
src/hooks/category/useCategorySelection.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Holds the categories picked in a form's `CategorySelect`.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
/**
|
||||
* Returns the ids picked, starting from `initial`, a function that turns one
|
||||
* on or off, and one that replaces them all. The dump creation dialog, the
|
||||
* dump editor and the playlist forms send `[...selected]` as `categoryIds`.
|
||||
*/
|
||||
export function useCategorySelection(
|
||||
initial: Iterable<string> = [],
|
||||
): [Set<string>, (id: string) => void, (ids: Set<string>) => void] {
|
||||
const [selected, setSelected] = useState<Set<string>>(() => new Set(initial));
|
||||
const toggle = useCallback((id: string) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
}), []);
|
||||
return [selected, toggle, setSelected];
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "../../utils/shared/deserialize.ts";
|
||||
import { useAuth } from "../auth/useAuth.ts";
|
||||
import { useCategories } from "../category/useCategories.ts";
|
||||
import { useCategoryFilter } from "../category/useCategoryFilter.ts";
|
||||
import { useWS } from "../ws/useWS.ts";
|
||||
import { useFeedCache } from "../feed/useFeedCache.ts";
|
||||
import { usePagedList, type UsePagedListResult } from "../feed/usePagedList.ts";
|
||||
@@ -64,7 +65,7 @@ export function useUserDumpFeed(
|
||||
): UseUserDumpFeedResult {
|
||||
const { user, authFetch } = useAuth();
|
||||
const { lastDumpEvent } = useWS();
|
||||
const { categories } = useCategories();
|
||||
const { categories } = useCategories("dump");
|
||||
const { cached, saveState } = useFeedCache<Dump>(
|
||||
`feed:profile-${list}:${userId}${categorySlug ? `:${categorySlug}` : ""}`,
|
||||
hydrateDump,
|
||||
@@ -91,16 +92,7 @@ export function useUserDumpFeed(
|
||||
// the categories don't know yet lets everything through.
|
||||
const categoryId = categorySlug &&
|
||||
categories.find((c) => c.slug === categorySlug)?.id;
|
||||
const setItems = useCallback(
|
||||
(fn: (prev: Dump[]) => Dump[]) =>
|
||||
setAllItems((prev) => {
|
||||
const next = fn(prev);
|
||||
return categoryId
|
||||
? next.filter((d) => d.categoryIds?.includes(categoryId))
|
||||
: next;
|
||||
}),
|
||||
[setAllItems, categoryId],
|
||||
);
|
||||
const setItems = useCategoryFilter(setAllItems, categoryId || undefined);
|
||||
|
||||
const upvoted = useUpvotedList({
|
||||
profileUserId: list === "upvoted" ? userId : null,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { dumpThumbnailUrl, dumpUrl, isAt } from "../utils/shared/urls.ts";
|
||||
import { can } from "../utils/auth/permissions.ts";
|
||||
import { CategoryChips } from "../components/category/CategoryChips.tsx";
|
||||
import { AddToPlaylistModal } from "../components/playlist/AddToPlaylistModal.tsx";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
@@ -30,7 +31,6 @@ import {
|
||||
import { friendlyFetchError, parseAPIResponse } from "../utils/shared/api.ts";
|
||||
|
||||
import { useAuth } from "../hooks/auth/useAuth.ts";
|
||||
import { useCategories } from "../hooks/category/useCategories.ts";
|
||||
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
|
||||
import { relativeTime } from "../utils/shared/relativeTime.ts";
|
||||
import { useHeightTransition } from "../hooks/shared/useHeightTransition.ts";
|
||||
@@ -116,7 +116,6 @@ export function Dump(): React.JSX.Element {
|
||||
const [titleError, setTitleError] = useState<string | null>(null);
|
||||
|
||||
const { user, mediaToken, authFetch } = useAuth();
|
||||
const { byId } = useCategories();
|
||||
const {
|
||||
voteCounts,
|
||||
myVotes,
|
||||
@@ -334,9 +333,6 @@ export function Dump(): React.JSX.Element {
|
||||
const { dump } = dumpState;
|
||||
const canEdit = !!user &&
|
||||
(dump.userId === user.id || can(user, "dump:moderate"));
|
||||
const dumpCategories = (dump.categoryIds ?? [])
|
||||
.map((id) => byId.get(id))
|
||||
.filter((c) => c !== undefined);
|
||||
|
||||
const startTitleEdit = () => {
|
||||
setTitleDraft(dump.title);
|
||||
@@ -512,19 +508,7 @@ export function Dump(): React.JSX.Element {
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
<CategoryChips kind="dump" ids={dump.categoryIds} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import { RichContentCard } from "../components/dump/RichContentCard.tsx";
|
||||
import { FilePreview } from "../components/dump/FilePreview.tsx";
|
||||
import { ImagePicker } from "../components/shared/ImagePicker.tsx";
|
||||
import { CategorySelect } from "../components/category/CategorySelect.tsx";
|
||||
import { useCategorySelection } from "../hooks/category/useCategorySelection.ts";
|
||||
import { FileField } from "../components/form/FileField.tsx";
|
||||
import { FormError } from "../components/form/FormError.tsx";
|
||||
import { FormProvider } from "react-hook-form";
|
||||
@@ -362,16 +363,9 @@ function DumpEditForm({
|
||||
onSaved,
|
||||
}: DumpEditFormProps) {
|
||||
const { authFetch } = useAuth();
|
||||
const [selectedCategoryIds, setSelectedCategoryIds] = useState<Set<string>>(
|
||||
() => new Set(dump.categoryIds ?? []),
|
||||
const [selectedCategoryIds, toggleCategory] = useCategorySelection(
|
||||
dump.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 form = useApiForm<EditValues>({
|
||||
defaultValues: {
|
||||
title: dump.title,
|
||||
@@ -547,6 +541,7 @@ function DumpEditForm({
|
||||
/>
|
||||
|
||||
<CategorySelect
|
||||
kind="dump"
|
||||
selected={selectedCategoryIds}
|
||||
onToggle={toggleCategory}
|
||||
/>
|
||||
|
||||
@@ -65,7 +65,7 @@ export function Index(
|
||||
const { user, authFetch } = useAuth();
|
||||
const { recentDumps, deletedDumpIds, connectionEpoch } = useWS();
|
||||
|
||||
const { categories, isLoaded: categoriesLoaded } = useCategories();
|
||||
const { categories, isLoaded: categoriesLoaded } = useCategories("dump");
|
||||
const category = categorySlug
|
||||
? categories.find((c) => c.slug === categorySlug)
|
||||
: undefined;
|
||||
|
||||
@@ -23,10 +23,10 @@ import {
|
||||
import { NotFound } from "./NotFound.tsx";
|
||||
|
||||
/**
|
||||
* Renders the category manager and the instances the site trusts or blocks,
|
||||
* each to those who hold its permission, which the API checks again, the
|
||||
* instances only while federated sign-in goes at least one way; without
|
||||
* either, the page isn't found.
|
||||
* Renders the managers of the dump and the playlist categories, and the
|
||||
* instances the site trusts or blocks, each to those who hold its permission,
|
||||
* which the API checks again, the instances only while federated sign-in goes
|
||||
* at least one way; without either, the page isn't found.
|
||||
*/
|
||||
export function Manage(): React.JSX.Element {
|
||||
useDocumentTitle(t`Manage`);
|
||||
@@ -39,11 +39,18 @@ export function Manage(): React.JSX.Element {
|
||||
<PageShell narrow>
|
||||
<PageHeader icon="🛠️" title={<Trans>Manage</Trans>} />
|
||||
{categories && (
|
||||
<PageGroup label={<Trans>Categories</Trans>}>
|
||||
<div className="page-card">
|
||||
<CategoryManager />
|
||||
</div>
|
||||
</PageGroup>
|
||||
<>
|
||||
<PageGroup label={<Trans>Dump categories</Trans>}>
|
||||
<div className="page-card">
|
||||
<CategoryManager kind="dump" />
|
||||
</div>
|
||||
</PageGroup>
|
||||
<PageGroup label={<Trans>Playlist categories</Trans>}>
|
||||
<div className="page-card">
|
||||
<CategoryManager kind="playlist" />
|
||||
</div>
|
||||
</PageGroup>
|
||||
</>
|
||||
)}
|
||||
{instances && (
|
||||
<PageGroup label={<Trans>Instances</Trans>}>
|
||||
|
||||
135
src/pages/Playlists.tsx
Normal file
135
src/pages/Playlists.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Renders every public playlist, `/playlists`, and those of one playlist
|
||||
* category, `/playlists/:categorySlug`, newest first.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import type { Playlist } from "../../shared/playlist.ts";
|
||||
import { playlistsPath } from "../../shared/paths.ts";
|
||||
import { canLoadMore, pageUrl, readPage } from "../utils/feed/pagedList.ts";
|
||||
import {
|
||||
deserializePlaylist,
|
||||
hydratePlaylist,
|
||||
} from "../utils/shared/deserialize.ts";
|
||||
import { useAuth } from "../hooks/auth/useAuth.ts";
|
||||
import { useCategories } from "../hooks/category/useCategories.ts";
|
||||
import { useCategoryFilter } from "../hooks/category/useCategoryFilter.ts";
|
||||
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
|
||||
import { useFeedCache } from "../hooks/feed/useFeedCache.ts";
|
||||
import { usePagedList } from "../hooks/feed/usePagedList.ts";
|
||||
import { useInfiniteScroll } from "../hooks/feed/useInfiniteScroll.ts";
|
||||
import {
|
||||
useScrollRestore,
|
||||
useScrollSave,
|
||||
} from "../hooks/feed/useScrollSave.ts";
|
||||
import { usePlaylistListSync } from "../hooks/playlist/usePlaylistListSync.ts";
|
||||
import { PageShell } from "../components/layout/PageShell.tsx";
|
||||
import { PageHeader } from "../components/layout/PageHeader.tsx";
|
||||
import { CategorySwitcher } from "../components/category/CategorySwitcher.tsx";
|
||||
import { PlaylistCardList } from "../components/playlist/PlaylistCardList.tsx";
|
||||
import { FeedFooter } from "../components/feed/FeedFooter.tsx";
|
||||
import { ListState } from "../components/shared/ListState.tsx";
|
||||
import { NotFound } from "./NotFound.tsx";
|
||||
|
||||
/**
|
||||
* Renders the list for the route's category, or every public playlist
|
||||
* without one. Each category's list is another, which the page mounts
|
||||
* afresh. A slug that names no playlist category, once the categories have
|
||||
* loaded, is a page not found.
|
||||
*/
|
||||
export function Playlists(): React.JSX.Element {
|
||||
const { categorySlug } = useParams();
|
||||
const { categories, isLoaded } = useCategories("playlist");
|
||||
const category = categorySlug
|
||||
? categories.find((c) => c.slug === categorySlug)
|
||||
: undefined;
|
||||
useDocumentTitle(category ? category.name : t`Playlists`);
|
||||
|
||||
if (categorySlug && isLoaded && !category) return <NotFound />;
|
||||
return (
|
||||
<PlaylistsList
|
||||
key={categorySlug ?? ""}
|
||||
categorySlug={categorySlug}
|
||||
categoryId={category?.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the public playlists, from `GET /api/playlists`, 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.
|
||||
*/
|
||||
function PlaylistsList(
|
||||
{ categorySlug, categoryId }: { categorySlug?: string; categoryId?: string },
|
||||
) {
|
||||
const navigate = useNavigate();
|
||||
const { user, authFetch } = useAuth();
|
||||
const { cached, saveState } = useFeedCache<Playlist>(
|
||||
`feed:playlists:${categorySlug ?? "all"}`,
|
||||
hydratePlaylist,
|
||||
);
|
||||
const url = `${API_URL}/api/playlists${
|
||||
categorySlug ? `?category=${encodeURIComponent(categorySlug)}` : ""
|
||||
}`;
|
||||
const { state, setItems: setAllItems, loadMore } = usePagedList<Playlist>(
|
||||
`${url}:${user?.id ?? "guest"}`,
|
||||
useCallback(
|
||||
(page: number, signal?: AbortSignal) =>
|
||||
authFetch(pageUrl(url, page), { signal })
|
||||
.then((res) => readPage(res, deserializePlaylist)),
|
||||
[authFetch, url],
|
||||
),
|
||||
cached,
|
||||
);
|
||||
const loaded = state.status === "loaded" ? state.data : null;
|
||||
usePlaylistListSync(useCategoryFilter(setAllItems, categoryId));
|
||||
|
||||
const sentinelRef = useInfiniteScroll(loadMore, canLoadMore(loaded));
|
||||
useScrollSave(
|
||||
!!loaded,
|
||||
useCallback((y) => {
|
||||
if (loaded) saveState(loaded, y);
|
||||
}, [loaded, saveState]),
|
||||
);
|
||||
useScrollRestore(cached?.scrollY, !!loaded);
|
||||
|
||||
return (
|
||||
<PageShell narrow>
|
||||
<PageHeader icon="📋" title={<Trans>Playlists</Trans>}>
|
||||
<CategorySwitcher
|
||||
kind="playlist"
|
||||
categorySlug={categorySlug}
|
||||
onSelect={(slug) => navigate(playlistsPath(slug))}
|
||||
/>
|
||||
</PageHeader>
|
||||
<ListState
|
||||
state={state}
|
||||
isEmpty={({ data }) => data.items.length === 0}
|
||||
empty={
|
||||
<p className="empty-state">
|
||||
<Trans>No playlists yet.</Trans>
|
||||
</p>
|
||||
}
|
||||
>
|
||||
{({ data }) => (
|
||||
<>
|
||||
<PlaylistCardList playlists={data.items} />
|
||||
<FeedFooter
|
||||
hasMore={data.hasMore}
|
||||
loadingMore={data.loadingMore}
|
||||
sentinelRef={sentinelRef}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ListState>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Renders the results of a search, in a tab for dumps, one for users and one
|
||||
* for playlists.
|
||||
* for playlists, the dumps and the playlists narrowed to a category of their
|
||||
* own.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -14,6 +15,7 @@ import { TabBar } from "../components/shared/TabBar.tsx";
|
||||
import { DumpCardList } from "../components/dump/DumpCardList.tsx";
|
||||
import { PlaylistCardList } from "../components/playlist/PlaylistCardList.tsx";
|
||||
import { FeedFooter } from "../components/feed/FeedFooter.tsx";
|
||||
import { CategorySwitcher } from "../components/category/CategorySwitcher.tsx";
|
||||
import { ErrorCard } from "../components/shared/ErrorCard.tsx";
|
||||
import { useAuth } from "../hooks/auth/useAuth.ts";
|
||||
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
|
||||
@@ -29,6 +31,7 @@ import type { Playlist } from "../../shared/playlist.ts";
|
||||
import type { PublicUser } from "../../shared/user.ts";
|
||||
import type { Wire } from "../../shared/api.ts";
|
||||
import type { SearchResults } from "../../shared/search.ts";
|
||||
import type { CategoryKind } from "../../shared/category.ts";
|
||||
import { SEARCH_URL } from "../config/api.ts";
|
||||
import { expectOk } from "../utils/shared/api.ts";
|
||||
import {
|
||||
@@ -59,29 +62,54 @@ interface Results {
|
||||
playlists: Playlist[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Names, for each tab a category narrows, the kind of its categories and the
|
||||
* parameter of `GET /api/search` that takes one.
|
||||
*/
|
||||
const TAB_CATEGORIES: Partial<
|
||||
Record<Tab, { kind: CategoryKind; param: string }>
|
||||
> = {
|
||||
dumps: { kind: "dump", param: "category" },
|
||||
playlists: { kind: "playlist", param: "playlistCategory" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders `/search?q=` and `/search/:searchTab?q=`, for anyone. The results come
|
||||
* from `GET /api/search`, signed in when the viewer is, so they find their own
|
||||
* private dumps and playlists too. The dumps load a page at a time as the
|
||||
* "dumps" tab scrolls. A new `q` aborts the search under way.
|
||||
*
|
||||
* On the "dumps" and "playlists" tabs, a menu narrows the results to a
|
||||
* category of their kind, kept in the URL's `?category=`, which another tab
|
||||
* drops.
|
||||
*/
|
||||
export function Search(): React.JSX.Element {
|
||||
useDocumentTitle(t`Search`);
|
||||
const [searchParams] = useSearchParams();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const q = searchParams.get("q") ?? "";
|
||||
const categorySlug = searchParams.get("category") || undefined;
|
||||
const [tab, setTab] = useTabParam<Tab>(searchPath(), SEARCH_TABS, "dumps");
|
||||
const filter = tab ? TAB_CATEGORIES[tab] : undefined;
|
||||
const categoryParam = filter && categorySlug ? filter.param : undefined;
|
||||
|
||||
const { user, authFetch } = useAuth();
|
||||
const search = useCallback(async (page: number, signal?: AbortSignal) => {
|
||||
const url = new URL(SEARCH_URL, location.origin);
|
||||
url.searchParams.set("q", q);
|
||||
if (categoryParam && categorySlug) {
|
||||
url.searchParams.set(categoryParam, categorySlug);
|
||||
}
|
||||
return expectOk<Wire<SearchResults>>(
|
||||
await authFetch(pageUrl(url.toString(), page), { signal }),
|
||||
);
|
||||
}, [authFetch, q]);
|
||||
}, [authFetch, q, categoryParam, categorySlug]);
|
||||
|
||||
const { state, setData } = useFetchState<Results>(
|
||||
q.trim() ? `${q}:${user?.id ?? "guest"}` : undefined,
|
||||
q.trim()
|
||||
? `${q}:${user?.id ?? "guest"}:${categoryParam ?? ""}:${
|
||||
categorySlug ?? ""
|
||||
}`
|
||||
: undefined,
|
||||
(signal) =>
|
||||
search(1, signal).then(({ dumps, users, playlists }) => ({
|
||||
dumps: firstPage(dumps.items.map(deserializeDump), dumps.hasMore),
|
||||
@@ -144,12 +172,33 @@ export function Search(): React.JSX.Element {
|
||||
),
|
||||
}))}
|
||||
activeTab={tab}
|
||||
onChange={setTab}
|
||||
onChange={(next) => {
|
||||
// Each tab's categories are its own.
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.delete("category");
|
||||
const query = params.toString();
|
||||
setTab(next, query ? `?${query}` : "");
|
||||
}}
|
||||
className="search-tabs"
|
||||
innerClassName="search-tabs-inner"
|
||||
/>
|
||||
)}
|
||||
|
||||
{q.trim() && filter && (
|
||||
<div className="search-filter">
|
||||
<CategorySwitcher
|
||||
kind={filter.kind}
|
||||
categorySlug={categorySlug}
|
||||
onSelect={(slug) =>
|
||||
setSearchParams((p) => {
|
||||
if (slug) p.set("category", slug);
|
||||
else p.delete("category");
|
||||
return p;
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!q.trim() && (
|
||||
<p className="search-page-empty">
|
||||
<Trans>Enter a query to search.</Trans>
|
||||
|
||||
@@ -16,7 +16,11 @@ describe("CategorySelect", () => {
|
||||
const onToggle = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(
|
||||
<CategorySelect selected={new Set(["c2"])} onToggle={onToggle} />,
|
||||
<CategorySelect
|
||||
kind="dump"
|
||||
selected={new Set(["c2"])}
|
||||
onToggle={onToggle}
|
||||
/>,
|
||||
{
|
||||
categories,
|
||||
},
|
||||
@@ -35,7 +39,7 @@ describe("CategorySelect", () => {
|
||||
|
||||
test("nothing without categories", () => {
|
||||
renderWithContexts(
|
||||
<CategorySelect selected={new Set()} onToggle={() => {}} />,
|
||||
<CategorySelect kind="dump" selected={new Set()} onToggle={() => {}} />,
|
||||
);
|
||||
expect(screen.queryByText("Categories")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ const categories: Category[] = [
|
||||
|
||||
describe("CategorySwitcher", () => {
|
||||
test("hidden with no categories", () => {
|
||||
renderWithContexts(<CategorySwitcher onSelect={() => {}} />);
|
||||
renderWithContexts(<CategorySwitcher kind="dump" onSelect={() => {}} />);
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ describe("CategorySwitcher", () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
renderWithContexts(
|
||||
<CategorySwitcher categorySlug="music" onSelect={onSelect} />,
|
||||
<CategorySwitcher kind="dump" categorySlug="music" onSelect={onSelect} />,
|
||||
{ categories },
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Music" }));
|
||||
@@ -38,7 +38,7 @@ describe("CategorySwitcher", () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
renderWithContexts(
|
||||
<CategorySwitcher categorySlug="gone" onSelect={onSelect} />,
|
||||
<CategorySwitcher kind="dump" categorySlug="gone" onSelect={onSelect} />,
|
||||
{ categories },
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "gone" }));
|
||||
@@ -48,7 +48,7 @@ describe("CategorySwitcher", () => {
|
||||
|
||||
test("Escape closes the menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithContexts(<CategorySwitcher onSelect={() => {}} />, {
|
||||
renderWithContexts(<CategorySwitcher kind="dump" onSelect={() => {}} />, {
|
||||
categories,
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "All" }));
|
||||
|
||||
@@ -14,16 +14,32 @@ describe("CategoriesProvider", () => {
|
||||
test("loads and indexes categories", async () => {
|
||||
const music = makeCategory({ id: "c1", slug: "music" });
|
||||
route("GET /api/categories", () => wire([music]));
|
||||
const { result } = renderHook(useCategories, { wrapper });
|
||||
const { result } = renderHook(() => useCategories("dump"), { wrapper });
|
||||
await waitFor(() => expect(result.current.isLoaded).toBe(true));
|
||||
expect(result.current.byId.get("c1")?.slug).toBe("music");
|
||||
expect(result.current.categories[0].createdAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
test("gives each kind its own set", async () => {
|
||||
route("GET /api/categories", () =>
|
||||
wire([
|
||||
makeCategory({ id: "d", slug: "music" }),
|
||||
makeCategory({ id: "p", slug: "music", kind: "playlist" }),
|
||||
]));
|
||||
const { result } = renderHook(
|
||||
() => ({ dump: useCategories("dump"), list: useCategories("playlist") }),
|
||||
{ wrapper },
|
||||
);
|
||||
await waitFor(() => expect(result.current.dump.isLoaded).toBe(true));
|
||||
expect(result.current.dump.categories.map((c) => c.id)).toEqual(["d"]);
|
||||
expect(result.current.list.categories.map((c) => c.id)).toEqual(["p"]);
|
||||
expect(result.current.dump.byId.has("p")).toBe(false);
|
||||
});
|
||||
|
||||
test("refresh refetches", async () => {
|
||||
let list = [makeCategory({ id: "a" })];
|
||||
route("GET /api/categories", () => wire(list));
|
||||
const { result } = renderHook(useCategories, { wrapper });
|
||||
const { result } = renderHook(() => useCategories("dump"), { wrapper });
|
||||
await waitFor(() => expect(result.current.categories).toHaveLength(1));
|
||||
list = [...list, makeCategory({ id: "b" })];
|
||||
await act(() => result.current.refresh());
|
||||
@@ -34,7 +50,7 @@ describe("CategoriesProvider", () => {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
try {
|
||||
route("GET /api/categories", () => fail("x", 500));
|
||||
const { result } = renderHook(useCategories, { wrapper });
|
||||
const { result } = renderHook(() => useCategories("dump"), { wrapper });
|
||||
await act(async () => {});
|
||||
expect(result.current.isLoaded).toBe(false);
|
||||
route("GET /api/categories", () => wire([makeCategory({ id: "a" })]));
|
||||
|
||||
@@ -119,6 +119,7 @@ export function makeCategory(over: Partial<Category> = {}): Category {
|
||||
const id = over.id ?? nextId("cat");
|
||||
return {
|
||||
id,
|
||||
kind: "dump",
|
||||
slug: over.slug ?? `cat${seq}`,
|
||||
name: over.name ?? `Category ${seq}`,
|
||||
position: 0,
|
||||
|
||||
@@ -22,6 +22,12 @@ vi.mock("../../config/instance.ts", async (importOriginal) => ({
|
||||
|
||||
const admin = makeSession({ id: "ann", username: "ann", role: "admin" });
|
||||
|
||||
/** Queries within the `/manage` group headed `label`. */
|
||||
async function group(label: string) {
|
||||
const heading = await screen.findByRole("heading", { name: label });
|
||||
return within(heading.closest("section")!);
|
||||
}
|
||||
|
||||
function open(session = admin) {
|
||||
route("GET /api/federation/peers", () => [], { fallback: true });
|
||||
const user = userEvent.setup();
|
||||
@@ -49,8 +55,9 @@ describe("Manage", () => {
|
||||
return null;
|
||||
});
|
||||
const user = open();
|
||||
const dumps = await group("Dump categories");
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Add category" }),
|
||||
await dumps.findByRole("button", { name: "Add category" }),
|
||||
);
|
||||
await user.type(screen.getByRole("textbox", { name: "Name" }), "Cinema");
|
||||
await user.type(
|
||||
@@ -61,6 +68,7 @@ describe("Manage", () => {
|
||||
expect(await screen.findByRole("button", { name: "Cinema" }))
|
||||
.toBeInTheDocument();
|
||||
expect(calls("POST /api/categories")[0].body).toEqual({
|
||||
kind: "dump",
|
||||
name: "Cinema",
|
||||
slug: "cinema",
|
||||
});
|
||||
@@ -82,8 +90,9 @@ describe("Manage", () => {
|
||||
route("GET /api/categories", () => []);
|
||||
route("POST /api/categories", () => fail("Slug already taken", 409));
|
||||
const user = open();
|
||||
const dumps = await group("Dump categories");
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Add category" }),
|
||||
await dumps.findByRole("button", { name: "Add category" }),
|
||||
);
|
||||
await user.type(screen.getByRole("textbox", { name: "Name" }), "X");
|
||||
await user.type(
|
||||
@@ -94,10 +103,51 @@ describe("Manage", () => {
|
||||
"Slug already taken",
|
||||
);
|
||||
await user.type(screen.getByRole("textbox", { name: "Name" }), "{Escape}");
|
||||
expect(screen.getByRole("button", { name: "Add category" }))
|
||||
expect(dumps.getByRole("button", { name: "Add category" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("the playlist categories are a set of their own", async () => {
|
||||
let list = [
|
||||
makeCategory({ id: "d1", slug: "music", name: "Music" }),
|
||||
makeCategory({
|
||||
id: "p1",
|
||||
kind: "playlist",
|
||||
slug: "mixes",
|
||||
name: "Mixes",
|
||||
}),
|
||||
];
|
||||
route("GET /api/categories", () => wire(list));
|
||||
route("POST /api/categories", (req) => {
|
||||
const c = makeCategory({ id: "p2", ...(req.body as object) });
|
||||
list = [...list, c];
|
||||
return wire(c);
|
||||
});
|
||||
const user = open();
|
||||
const dumps = await group("Dump categories");
|
||||
const playlists = await group("Playlist categories");
|
||||
expect(await dumps.findByRole("button", { name: "Music" }))
|
||||
.toBeInTheDocument();
|
||||
expect(dumps.queryByRole("button", { name: "Mixes" })).toBeNull();
|
||||
expect(playlists.getByRole("button", { name: "Mixes" }))
|
||||
.toBeInTheDocument();
|
||||
expect(playlists.queryByRole("button", { name: "Music" })).toBeNull();
|
||||
|
||||
await user.click(playlists.getByRole("button", { name: "Add category" }));
|
||||
await user.type(screen.getByRole("textbox", { name: "Name" }), "Live");
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: "URL slug" }),
|
||||
"live{Enter}",
|
||||
);
|
||||
expect(await playlists.findByRole("button", { name: "Live" }))
|
||||
.toBeInTheDocument();
|
||||
expect(calls("POST /api/categories")[0].body).toEqual({
|
||||
kind: "playlist",
|
||||
name: "Live",
|
||||
slug: "live",
|
||||
});
|
||||
});
|
||||
|
||||
test("an admin trusts, blocks and removes other instances", async () => {
|
||||
route("GET /api/categories", () => []);
|
||||
const music = {
|
||||
@@ -189,7 +239,7 @@ describe("Manage", () => {
|
||||
instance.federationOutgoing = false;
|
||||
route("GET /api/categories", () => []);
|
||||
open();
|
||||
expect(await screen.findByText("Categories")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Dump categories")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Instances")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,13 @@ import { PlaylistDetail } from "../../pages/PlaylistDetail.tsx";
|
||||
import type { Dump } from "../../../shared/dump.ts";
|
||||
import type { Playlist } from "../../../shared/playlist.ts";
|
||||
import { calls, fail, route } from "../fetch.ts";
|
||||
import { makeDump, makePlaylist, makeSession, wire } from "../fixtures.ts";
|
||||
import {
|
||||
makeCategory,
|
||||
makeDump,
|
||||
makePlaylist,
|
||||
makeSession,
|
||||
wire,
|
||||
} from "../fixtures.ts";
|
||||
import { currentPath, renderApp } from "../render.tsx";
|
||||
import { FakeWebSocket } from "../ws.ts";
|
||||
|
||||
@@ -31,6 +37,7 @@ function open(
|
||||
playlist = {} as Partial<Playlist>,
|
||||
items = dumps() as Dump[],
|
||||
url = "/@owner/playlists/mix",
|
||||
categories = [] as ReturnType<typeof makeCategory>[],
|
||||
} = {},
|
||||
) {
|
||||
const pl = makePlaylist({
|
||||
@@ -51,6 +58,7 @@ function open(
|
||||
route: url,
|
||||
path: "/:handle/playlists/:slug",
|
||||
session,
|
||||
categories,
|
||||
extraRoutes: <Route path="/" element={<p>home</p>} />,
|
||||
});
|
||||
const socket = FakeWebSocket.last;
|
||||
@@ -142,6 +150,56 @@ describe("PlaylistDetail", () => {
|
||||
expect(currentPath()).toBe("/@owner/playlists/mix");
|
||||
});
|
||||
|
||||
test("its categories show, and editing them sends the new set", async () => {
|
||||
const mixes = makeCategory({
|
||||
id: "pc1",
|
||||
kind: "playlist",
|
||||
slug: "mixes",
|
||||
name: "Mixes",
|
||||
});
|
||||
const live = makeCategory({
|
||||
id: "pc2",
|
||||
kind: "playlist",
|
||||
slug: "live",
|
||||
name: "Live",
|
||||
});
|
||||
const music = makeCategory({ id: "dc1", slug: "music", name: "Music" });
|
||||
route(
|
||||
"PUT /api/playlists/:id",
|
||||
(req) =>
|
||||
wire(
|
||||
makePlaylist({
|
||||
id: "p1",
|
||||
slug: "mix",
|
||||
title: "Mix",
|
||||
userId: "owner",
|
||||
...(req.body as object),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const { user } = open({
|
||||
playlist: { categoryIds: ["pc1"] },
|
||||
categories: [music, mixes, live],
|
||||
});
|
||||
expect(await screen.findByRole("link", { name: "Mixes" }))
|
||||
.toHaveAttribute("href", "/playlists/mixes");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Edit" }));
|
||||
// The dump categories aren't offered.
|
||||
expect(screen.queryByRole("button", { name: "Music" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Mixes" }))
|
||||
.toHaveAttribute("aria-pressed", "true");
|
||||
await user.click(screen.getByRole("button", { name: "Mixes" }));
|
||||
await user.click(screen.getByRole("button", { name: "Live" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(await screen.findByRole("link", { name: "Live" }))
|
||||
.toHaveAttribute("href", "/playlists/live");
|
||||
expect(calls("PUT /api/playlists/:id")[0].body).toEqual({
|
||||
categoryIds: ["pc2"],
|
||||
isPublic: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("a new title proposes a new link, which saving moves to", async () => {
|
||||
route(
|
||||
"PUT /api/playlists/:id",
|
||||
|
||||
94
src/test/pages/Playlists.test.tsx
Normal file
94
src/test/pages/Playlists.test.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { Playlists } from "../../pages/Playlists.tsx";
|
||||
import { calls, route } from "../fetch.ts";
|
||||
import { makeCategory, makePlaylist, page, wire } from "../fixtures.ts";
|
||||
import { currentPath, renderApp } from "../render.tsx";
|
||||
import { FakeWebSocket } from "../ws.ts";
|
||||
|
||||
const mixes = makeCategory({
|
||||
id: "pc1",
|
||||
kind: "playlist",
|
||||
slug: "mixes",
|
||||
name: "Mixes",
|
||||
});
|
||||
const music = makeCategory({ id: "dc1", slug: "music", name: "Music" });
|
||||
|
||||
function open(url: string) {
|
||||
const user = userEvent.setup();
|
||||
renderApp(<Playlists />, {
|
||||
route: url,
|
||||
path: "/playlists/:categorySlug?",
|
||||
categories: [music, mixes],
|
||||
});
|
||||
return { user, socket: FakeWebSocket.last };
|
||||
}
|
||||
|
||||
describe("Playlists", () => {
|
||||
test("every public playlist; the menu offers the playlist categories only", async () => {
|
||||
route(
|
||||
"GET /api/playlists",
|
||||
() => page([makePlaylist({ title: "Summer", categoryIds: ["pc1"] })]),
|
||||
);
|
||||
const { user } = open("/playlists");
|
||||
expect(await screen.findByText("Summer")).toBeInTheDocument();
|
||||
expect(calls("GET /api/playlists")[0].url.searchParams.get("category"))
|
||||
.toBeNull();
|
||||
// Its chip leads to its category's playlists.
|
||||
expect(screen.getByRole("link", { name: "Mixes" })).toHaveAttribute(
|
||||
"href",
|
||||
"/playlists/mixes",
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "All" }));
|
||||
expect(screen.getByRole("option", { name: "Mixes" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("option", { name: "Music" })).toBeNull();
|
||||
await user.click(screen.getByRole("option", { name: "Mixes" }));
|
||||
expect(currentPath()).toBe("/playlists/mixes");
|
||||
});
|
||||
|
||||
test("a category's playlists, kept to it as the socket brings more", async () => {
|
||||
route(
|
||||
"GET /api/playlists",
|
||||
() =>
|
||||
page([
|
||||
makePlaylist({ id: "p1", title: "Filed", categoryIds: ["pc1"] }),
|
||||
]),
|
||||
);
|
||||
const { socket } = open("/playlists/mixes");
|
||||
expect(await screen.findByText("Filed")).toBeInTheDocument();
|
||||
expect(calls("GET /api/playlists")[0].url.searchParams.get("category"))
|
||||
.toBe("mixes");
|
||||
|
||||
socket.welcome();
|
||||
socket.receive({
|
||||
type: "playlist_created",
|
||||
playlist: wire(makePlaylist({ title: "Elsewhere", categoryIds: [] })),
|
||||
});
|
||||
socket.receive({
|
||||
type: "playlist_created",
|
||||
playlist: wire(
|
||||
makePlaylist({ title: "Also filed", categoryIds: ["pc1"] }),
|
||||
),
|
||||
});
|
||||
expect(screen.getByText("Also filed")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Elsewhere")).toBeNull();
|
||||
|
||||
// Taken out of the category, it leaves.
|
||||
socket.receive({
|
||||
type: "playlist_updated",
|
||||
playlist: wire(
|
||||
makePlaylist({ id: "p1", title: "Filed", categoryIds: [] }),
|
||||
),
|
||||
});
|
||||
expect(screen.queryByText("Filed")).toBeNull();
|
||||
});
|
||||
|
||||
test("a slug that names no playlist category is not found", async () => {
|
||||
route("GET /api/playlists", () => page([]));
|
||||
open("/playlists/music");
|
||||
expect(await screen.findByText("This page does not exist."))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { act, screen } from "@testing-library/react";
|
||||
import { act, screen, waitFor } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { Search } from "../../pages/Search.tsx";
|
||||
import { calls, fail, route } from "../fetch.ts";
|
||||
import {
|
||||
makeCategory,
|
||||
makeDump,
|
||||
makePlaylist,
|
||||
makeSession,
|
||||
@@ -35,9 +36,10 @@ function results(
|
||||
function open(
|
||||
url: string,
|
||||
session: ReturnType<typeof makeSession> | null = null,
|
||||
categories: ReturnType<typeof makeCategory>[] = [],
|
||||
) {
|
||||
const user = userEvent.setup();
|
||||
renderApp(<Search />, { route: url, path: "/search/*", session });
|
||||
renderApp(<Search />, { route: url, path: "/search/*", session, categories });
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -70,6 +72,50 @@ describe("Search", () => {
|
||||
expect(screen.getByText("Cats")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("each tab narrows to a category of its own kind", async () => {
|
||||
route("GET /api/search", () =>
|
||||
results({
|
||||
dumps: [makeDump({ title: "Cat song" })],
|
||||
playlists: [makePlaylist({ title: "Cat mix" })],
|
||||
}));
|
||||
const user = open("/search?q=cat", null, [
|
||||
makeCategory({ id: "dc1", slug: "music", name: "Music" }),
|
||||
makeCategory({
|
||||
id: "pc1",
|
||||
kind: "playlist",
|
||||
slug: "mixes",
|
||||
name: "Mixes",
|
||||
}),
|
||||
]);
|
||||
await screen.findByText("Cat song");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "All" }));
|
||||
expect(screen.queryByRole("option", { name: "Mixes" })).toBeNull();
|
||||
await user.click(screen.getByRole("option", { name: "Music" }));
|
||||
expect(currentPath()).toBe("/search?q=cat&category=music");
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
calls("GET /api/search").at(-1)!.url.searchParams.get("category"),
|
||||
).toBe("music")
|
||||
);
|
||||
|
||||
// Another tab drops it, and offers its own.
|
||||
await user.click(screen.getByRole("button", { name: /^Playlists/ }));
|
||||
expect(currentPath()).toBe("/search/playlists?q=cat");
|
||||
await user.click(await screen.findByRole("button", { name: "All" }));
|
||||
expect(screen.queryByRole("option", { name: "Music" })).toBeNull();
|
||||
await user.click(screen.getByRole("option", { name: "Mixes" }));
|
||||
await waitFor(() => {
|
||||
const req = calls("GET /api/search").at(-1)!;
|
||||
expect(req.url.searchParams.get("playlistCategory")).toBe("mixes");
|
||||
expect(req.url.searchParams.get("category")).toBeNull();
|
||||
});
|
||||
|
||||
// The users tab has none.
|
||||
await user.click(screen.getByRole("button", { name: /^Users/ }));
|
||||
expect(screen.queryByRole("button", { name: "All" })).toBeNull();
|
||||
});
|
||||
|
||||
test("nothing found", async () => {
|
||||
route("GET /api/search", () => results());
|
||||
const user = open("/search?q=zzz");
|
||||
|
||||
@@ -217,6 +217,47 @@ describe("UserPublicProfile", () => {
|
||||
expect(currentPath()).toBe("/@ann/playlists");
|
||||
});
|
||||
|
||||
test("the playlists tab narrows to a playlist category, in its URL", async () => {
|
||||
const music = makeCategory({ id: "c1", slug: "music", name: "Music" });
|
||||
const mixes = makeCategory({
|
||||
id: "pc1",
|
||||
kind: "playlist",
|
||||
slug: "mixes",
|
||||
name: "Mixes",
|
||||
});
|
||||
const filed = makePlaylist({
|
||||
id: "p1",
|
||||
title: "Filed mix",
|
||||
userId: "ann",
|
||||
categoryIds: ["pc1"],
|
||||
});
|
||||
const { user, socket } = open({
|
||||
url: "/@ann/playlists",
|
||||
categories: [music, mixes],
|
||||
});
|
||||
route("GET /api/users/:u/playlists", (req) =>
|
||||
page(
|
||||
req.url.searchParams.get("category") === "mixes"
|
||||
? [filed]
|
||||
: [filed, makePlaylist({ id: "p2", title: "Loose", userId: "ann" })],
|
||||
));
|
||||
await screen.findByText("Loose");
|
||||
await user.click(screen.getByRole("button", { name: "All" }));
|
||||
expect(screen.queryByRole("option", { name: "Music" })).toBeNull();
|
||||
await user.click(screen.getByRole("option", { name: "Mixes" }));
|
||||
expect(currentPath()).toBe("/@ann/playlists?category=mixes");
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText("Loose")).not.toBeInTheDocument()
|
||||
);
|
||||
expect(screen.getByText("Filed mix")).toBeInTheDocument();
|
||||
socket.welcome();
|
||||
socket.receive({
|
||||
type: "playlist_updated",
|
||||
playlist: wire({ ...filed, categoryIds: [] }),
|
||||
});
|
||||
expect(screen.queryByText("Filed mix")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("the dumps tab loads more as its end scrolls into view", async () => {
|
||||
open({ url: "/@ann/dumps" });
|
||||
// After open's, so that it wins.
|
||||
|
||||
@@ -130,7 +130,6 @@ function categoriesValue(
|
||||
const list = Array.isArray(c) ? c : c?.categories ?? [];
|
||||
return {
|
||||
categories: list,
|
||||
byId: new Map(list.map((x) => [x.id, x])),
|
||||
isLoaded: true,
|
||||
refresh: async () => {},
|
||||
...(Array.isArray(c) ? {} : c),
|
||||
|
||||
Reference in New Issue
Block a user