Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,13 @@ easy to spot when bumping.

- `GenreTreeView` (wheel, pop-core): genre-tree-view's "Previous" button now sits inside the search field, left of the genre name, and fades in sliding from the left; the field's left padding widens to make room.
- `GenreActionBand`: Google Maps-style band under the detail panel header (icon over label) holding Play/Pause, Focus, Add, Rename, Parent and the `additionalActions`; genre-tree-view's own toolbar and its ⋮ menu are hidden.
- `TrackNounProvider` / `useTrackNoun()` (`@behindthemusictree/app-kit/track-noun`) and a `capitalize` helper: the app supplies the noun its UI uses for tracks (`{ singular, plural }`).

### Breaking

- `GenreSearch`: `onZoomIn`, `onPlayPause` and `isPlaying` are removed; those actions moved to the detail panel's action band. The search icon now sits right of the field's text, left of the clear button.
- App-kit-ui components no longer hardcode "track(s)": `TrackListSidebar`, `GenreDetailExtras` (the "Essential …" heading), `GenrePlaylistTracks`, `TrackUploadPopup` and the genre tree components read the noun from `useTrackNoun()`, which throws outside a `TrackNounProvider`. Wrap the app in `<TrackNounProvider singular="song" plural="songs">` (grow) or `singular="track" plural="tracks"` (hear).
- Requires `@behindthemusictree/genre-tree-view` 3.0.0; the tree components pass it `itemCountLabel` (e.g. "Song count").

## [13.5.0] - 2026-10-26

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { screen } from "@testing-library/react";
import { render } from "../test-utils";

const { useFetchGenreOverviewMock, tracksPropsMock } = vi.hoisted(() => ({
useFetchGenreOverviewMock: vi.fn(),
Expand Down
6 changes: 4 additions & 2 deletions packages/app-kit-ui/src/genre-tree/GenreDetailExtras.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ import { useFetchGenreOverview } from "@behindthemusictree/app-kit/genre-tree";
import { CriteriaPlaylistMinimum } from "@behindthemusictree/app-kit/genre-tree";
import { GenrePlaylistTracks, type GenrePlaylistTracksSlots } from "./GenrePlaylistTracks";
import { RenderArtistLink } from "@behindthemusictree/app-kit/genre-tree";
import { useTrackNoun } from "@behindthemusictree/app-kit/track-noun";

export type GenreDetailSlots<O extends CriteriaOverview> = {
/** Replaces the default Summary section. */
renderSummary?: (overview: O) => ReactNode;
/** Replaces the default Essential tracks section. */
/** Replaces the default Essential section (e.g. "Essential songs"). */
renderEssentialTracks?: (overview: O) => ReactNode;
};

Expand Down Expand Up @@ -41,6 +42,7 @@ export function GenreDetailExtras<O extends CriteriaOverview>({
renderEssentialTracks,
...trackSlots
}: GenreDetailExtrasProps<O>) {
const trackNoun = useTrackNoun();
const { data: overview, isPending } = useFetchGenreOverview<O>(
genreUuid,
scope,
Expand Down Expand Up @@ -87,7 +89,7 @@ export function GenreDetailExtras<O extends CriteriaOverview>({
) : (
essentialTracks && (
<div className="gtv-info-panel-children">
<span className="gtv-info-panel-children-title">Essential tracks</span>
<span className="gtv-info-panel-children-title">Essential {trackNoun.plural}</span>
{essentialTracks.length > 0 ? (
<ul className="list-disc pl-5">
{essentialTracks.map((track) => (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import { screen, fireEvent, act } from "@testing-library/react";
import { render } from "../test-utils";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const { useTrackListMock, usePlayerMock, fetchMock, showPopupMock } = vi.hoisted(() => ({
Expand Down
8 changes: 5 additions & 3 deletions packages/app-kit-ui/src/genre-tree/GenrePlaylistTracks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { isRecording, recordingUnplayableReason, RecordingDetailedSchema } from
import { GenrePlaylistTracksPage, useTrackList } from "@behindthemusictree/app-kit/genre-tree";
import { MusicBrainzRecordingLink } from "./MusicBrainzRecordingLink";
import { ArtistNames, RenderArtistLink } from "@behindthemusictree/app-kit/genre-tree";
import { capitalize, useTrackNoun } from "@behindthemusictree/app-kit/track-noun";

const CATALOGUE_PAGE_SIZE = 100;
// withTotal=false: the backend skips COUNT(*) and pages by keyset cursor, so huge genre subtrees
Expand Down Expand Up @@ -87,6 +88,7 @@ export function GenrePlaylistTracks({
const { session, sessionRestored } = useSession();
const { playerTrackObject, playState, handlePlayPauseAction } = usePlayer();
const { showPopup } = usePopup();
const trackNoun = useTrackNoun();
const { uuid } = genrePlaylist;

const { fetch } = useFetchWrapper(getBackendBaseUrl);
Expand Down Expand Up @@ -139,21 +141,21 @@ export function GenrePlaylistTracks({

return (
<div className="gtv-info-panel-children">
<span className="gtv-info-panel-children-title">Tracks{total !== undefined && ` (${total})`}</span>
<span className="gtv-info-panel-children-title">{capitalize(trackNoun.plural)}{total !== undefined && ` (${total})`}</span>
{renderTracksToolbar?.()}
{isPending ? (
<div className="h-4 w-3/4 animate-pulse rounded bg-[var(--color-neutral-200)]" aria-busy="true" />
) : isError && tracks.length === 0 ? (
<p role="alert">
Couldn&apos;t load tracks.{" "}
Couldn&apos;t load {trackNoun.plural}.{" "}
<button type="button" onClick={() => void refetch()}>
Retry
</button>
</p>
) : tracks.length === 0 ? (
<p>—</p>
) : (
<ul className="max-h-64 overflow-y-auto [container-type:inline-size]" aria-label={`${genrePlaylist.name} tracks`}>
<ul className="max-h-64 overflow-y-auto [container-type:inline-size]" aria-label={`${genrePlaylist.name} ${trackNoun.plural}`}>
{tracks.map((track, index) => {
const label = track.artists?.length
? `${track.title} — ${track.artists.map((artist) => artist.name).join(", ")}`
Expand Down
3 changes: 2 additions & 1 deletion packages/app-kit-ui/src/genre-tree/GenreTreeView.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, fireEvent, act, cleanup, waitFor } from "@testing-library/react";
import { screen, fireEvent, act, cleanup, waitFor } from "@testing-library/react";
import { render } from "../test-utils";
import { z } from "zod";

const {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render } from "@testing-library/react";
import { render } from "../../test-utils";
import { describe, expect, it, vi, beforeEach } from "vitest";

import GenrePlaylistTreePerRoot from "./TreePerRoot";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { CriteriaPlaylistSimple } from "@behindthemusictree/app-kit/genre-tree";
import { CriteriaMinimum } from "@behindthemusictree/app-kit/genre-tree";
import { Scope } from "@behindthemusictree/app-kit/transport";
import type { InfoPanelCurationProps } from "./infoPanelCurationProps";
import { capitalize, useTrackNoun } from "@behindthemusictree/app-kit/track-noun";

export type GenrePlaylistTreePerRootProps<T extends TrackBase> = {
scope: Scope;
Expand Down Expand Up @@ -82,6 +83,7 @@ export default function GenrePlaylistTreePerRoot<T extends TrackBase>({
}: GenrePlaylistTreePerRootProps<T>) {
const { isPlaying, setIsPlaying } = usePlayer();
const { trackList, playNewTrackListFromGenrePlaylist } = useTrackList<T>();
const trackNoun = useTrackNoun();
const { showPopup } = usePopup();

const nodes: GenreTreeNode[] = useMemo(
Expand Down Expand Up @@ -171,6 +173,7 @@ export default function GenrePlaylistTreePerRoot<T extends TrackBase>({
<GenreTree
className={className}
nodes={nodes}
itemCountLabel={`${capitalize(trackNoun.singular)} count`}
rootColor={getGenreTreeColor(rootUuid)}
playingNodeId={playingNodeId}
playState={isPlaying ? "playing" : "paused"}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render } from "@testing-library/react";
import { render } from "../../test-utils";

const {
genreTreeWheelPropsMock,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { CriteriaPlaylistSimple } from "@behindthemusictree/app-kit/genre-tree";
import { CriteriaMinimum } from "@behindthemusictree/app-kit/genre-tree";
import { Scope } from "@behindthemusictree/app-kit/transport";
import type { InfoPanelCurationProps } from "./infoPanelCurationProps";
import { capitalize, useTrackNoun } from "@behindthemusictree/app-kit/track-noun";

export type GenrePlaylistTreeWheelProps<T extends TrackBase> = {
scope: Scope;
Expand Down Expand Up @@ -80,6 +81,7 @@ export default function GenrePlaylistTreeWheel<T extends TrackBase>({
}: GenrePlaylistTreeWheelProps<T>) {
const { isPlaying, setIsPlaying } = usePlayer();
const { trackList, playNewTrackListFromGenrePlaylist } = useTrackList<T>();
const trackNoun = useTrackNoun();
const { showPopup } = usePopup();

const nodes: GenreTreeNode[] = useMemo(
Expand Down Expand Up @@ -168,6 +170,7 @@ export default function GenrePlaylistTreeWheel<T extends TrackBase>({
<GenreTreeWheel
className={className}
nodes={nodes}
itemCountLabel={`${capitalize(trackNoun.singular)} count`}
playingNodeId={playingNodeId}
playState={isPlaying ? "playing" : "paused"}
reparentingNodeId={reparentingGenreUuid}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { render } from "@testing-library/react";
import { render } from "../../test-utils";

vi.mock("@behindthemusictree/app-kit/genre-tree", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render } from "@testing-library/react";
import { render } from "../../test-utils";

const {
genreTreeWheelRadialPopCorePropsMock,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { CriteriaPlaylistSimple } from "@behindthemusictree/app-kit/genre-tree";
import { CriteriaMinimum } from "@behindthemusictree/app-kit/genre-tree";
import { Scope } from "@behindthemusictree/app-kit/transport";
import type { InfoPanelCurationProps } from "./infoPanelCurationProps";
import { capitalize, useTrackNoun } from "@behindthemusictree/app-kit/track-noun";

/** Pop-core focus is controlled-only: pass both props or neither. Ignored in outline mode. */
export type GenreFocusProps =
Expand Down Expand Up @@ -112,6 +113,7 @@ export default function GenrePlaylistTreeWheelRadialPopCore<T extends TrackBase>
};
const { isPlaying, setIsPlaying } = usePlayer();
const { trackList, playNewTrackListFromGenrePlaylist } = useTrackList<T>();
const trackNoun = useTrackNoun();

const nodes: GenreTreeNode[] = useMemo(
() =>
Expand Down Expand Up @@ -191,6 +193,7 @@ export default function GenrePlaylistTreeWheelRadialPopCore<T extends TrackBase>
const treeProps = {
className,
nodes,
itemCountLabel: `${capitalize(trackNoun.singular)} count`,
playingNodeId,
playState: isPlaying ? ("playing" as const) : ("paused" as const),
reparentingNodeId: reparentingGenreUuid,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { screen, fireEvent } from "@testing-library/react";
import { render } from "../../test-utils";
import { TrackNounProvider } from "@behindthemusictree/app-kit/track-noun";

const { useTrackListMock, useTrackListSidebarVisibilityMock } = vi.hoisted(() => ({
useTrackListMock: vi.fn(),
Expand Down Expand Up @@ -84,6 +86,25 @@ describe("TrackListSidebar", () => {
expect(screen.getByText(/1 track /)).toBeInTheDocument();
});

it("uses the noun supplied by the TrackNounProvider", () => {
useTrackListMock.mockReturnValue({
trackList: makeTrackList({
origin: { label: "Single Track", type: TrackListOriginType.TRACK },
tracks: [{ uuid: "t1", title: "Only Song" }],
total: 2,
}),
});

render(
<TrackNounProvider singular="song" plural="songs">
<TrackListSidebar />
</TrackNounProvider>,
);

expect(screen.getByText(/song playlist/)).toBeInTheDocument();
expect(screen.getByText(/2 songs /)).toBeInTheDocument();
});

it("labels an artist origin and passes renderArtistLink to each track", () => {
useTrackListMock.mockReturnValue({
trackList: makeTrackList({ origin: { label: "Miles Davis", type: TrackListOriginType.ARTIST } }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useTrackListSidebarVisibility } from "@behindthemusictree/app-kit/genre
import { TrackListOriginType } from "@behindthemusictree/app-kit/genre-tree";
import { TrackBase } from "@behindthemusictree/app-kit/genre-tree";
import { RenderArtistLink } from "@behindthemusictree/app-kit/genre-tree";
import { useTrackNoun } from "@behindthemusictree/app-kit/track-noun";

export interface TrackListSidebarProps<T extends TrackBase> {
className?: string;
Expand All @@ -26,6 +27,7 @@ export default function TrackListSidebar<T extends TrackBase>({
renderArtistLink,
}: TrackListSidebarProps<T>) {
const { trackList, loadMore } = useTrackList<T>();
const trackNoun = useTrackNoun();
const loadMoreSentinelRef = useRef<HTMLLIElement>(null);
const hasMore = !!trackList && trackList.nextPage !== null;

Expand Down Expand Up @@ -64,8 +66,8 @@ export default function TrackListSidebar<T extends TrackBase>({
? "• Genre playlist • "
: trackList.origin.type === TrackListOriginType.ARTIST
? "• Artist • "
: "• track playlist • "}
{trackList.total + " track" + (trackList.total > 1 ? "s •" : " •")}
: `• ${trackNoun.singular} playlist • `}
{`${trackList.total} ${trackList.total > 1 ? trackNoun.plural : trackNoun.singular} •`}
</div>
<div
className="flex-grow flex flex-col items-end justify-center h-full cursor-pointer"
Expand Down
3 changes: 2 additions & 1 deletion packages/app-kit-ui/src/popup/TrackUploadPopup.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent, act } from "@testing-library/react";
import { screen, waitFor, fireEvent, act } from "@testing-library/react";
import { render } from "../test-utils";
import TrackUploadPopup from "./TrackUploadPopup";

type Settle = { resolve: (value: unknown) => void; reject: (reason: Error) => void };
Expand Down
4 changes: 3 additions & 1 deletion packages/app-kit-ui/src/popup/TrackUploadPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { MdError, MdCheckCircle, MdUpload } from "react-icons/md";
import { useTrackUpload, type UploadStatus, type UseTrackUploadOptions } from "@behindthemusictree/app-kit/popup";
import { BasePopup, BasePopupProps } from "./BasePopup";
import { capitalize, useTrackNoun } from "@behindthemusictree/app-kit/track-noun";

type TrackUploadPopupProps = Omit<BasePopupProps, "title" | "children" | "icon" | "isDismissable"> &
UseTrackUploadOptions & {
Expand Down Expand Up @@ -53,12 +54,13 @@ export default function TrackUploadPopup({
});

const displayTotal = items.length > 0 ? items.length : files.length;
const trackNoun = useTrackNoun();

return (
<BasePopup
{...rest}
onClose={onClose}
title={`Upload Tracks (${successfulCount}/${displayTotal})`}
title={`Upload ${capitalize(trackNoun.plural)} (${successfulCount}/${displayTotal})`}
isDismissable
showOkButton
okButtonText="OK"
Expand Down
15 changes: 15 additions & 0 deletions packages/app-kit-ui/src/test-utils.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { render as rtlRender, type RenderOptions, type RenderResult } from "@testing-library/react";
import { TrackNounProvider } from "@behindthemusictree/app-kit/track-noun";
import type { ReactElement, ReactNode } from "react";

function TrackNounWrapper({ children }: { children: ReactNode }) {
return (
<TrackNounProvider singular="track" plural="tracks">
{children}
</TrackNounProvider>
);
}

export function render(ui: ReactElement, options?: Omit<RenderOptions, "queries">): RenderResult {
return rtlRender(ui, { wrapper: TrackNounWrapper, ...options });
}
5 changes: 5 additions & 0 deletions packages/app-kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@
"import": "./dist/popup/index.js",
"require": "./dist/popup/index.cjs"
},
"./track-noun": {
"types": "./dist/track-noun/index.d.ts",
"import": "./dist/track-noun/index.js",
"require": "./dist/track-noun/index.cjs"
},
"./player": {
"types": "./dist/player/index.d.ts",
"import": "./dist/player/index.js",
Expand Down
1 change: 1 addition & 0 deletions packages/app-kit/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from "./transport";
export * from "./auth";
export * from "./popup";
export * from "./track-noun";
export * from "./player";
export * from "./genre-tree";
28 changes: 28 additions & 0 deletions packages/app-kit/src/track-noun/TrackNounContext.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { capitalize, TrackNounProvider, useTrackNoun } from "./TrackNounContext";

describe("useTrackNoun", () => {
it("returns the noun supplied by the provider", () => {
const { result } = renderHook(() => useTrackNoun(), {
wrapper: ({ children }) => (
<TrackNounProvider singular="song" plural="songs">
{children}
</TrackNounProvider>
),
});
expect(result.current).toEqual({ singular: "song", plural: "songs" });
});

it("throws without a provider", () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
expect(() => renderHook(() => useTrackNoun())).toThrow("useTrackNoun must be used within a TrackNounProvider");
errorSpy.mockRestore();
});
});

describe("capitalize", () => {
it("uppercases the first letter", () => {
expect(capitalize("song")).toBe("Song");
});
});
31 changes: 31 additions & 0 deletions packages/app-kit/src/track-noun/TrackNounContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"use client";

import { createContext, useContext, useMemo, ReactNode } from "react";

export interface TrackNoun {
singular: string;
plural: string;
}

const TrackNounContext = createContext<TrackNoun | undefined>(undefined);

interface TrackNounProviderProps extends TrackNoun {
children: ReactNode;
}

export function TrackNounProvider({ singular, plural, children }: TrackNounProviderProps) {
const value = useMemo(() => ({ singular, plural }), [singular, plural]);
return <TrackNounContext.Provider value={value}>{children}</TrackNounContext.Provider>;
}

export function useTrackNoun() {
const context = useContext(TrackNounContext);
if (context === undefined) {
throw new Error("useTrackNoun must be used within a TrackNounProvider");
}
return context;
}

export function capitalize(word: string) {
return word.charAt(0).toUpperCase() + word.slice(1);
}
Loading
Loading