@behindthemusictree/app-kit-ui 36.0.0 → 37.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -130,6 +130,7 @@ type SearchBoxProps = {
130
130
  onPicked?: (groupKey: string, itemKey: string) => void;
131
131
  /** Shows the clear button when set. */
132
132
  onClear?: () => void;
133
+ clearLabel?: string;
133
134
  /** An action left of the text, e.g. a Previous button. */
134
135
  leading?: ReactNode;
135
136
  className?: string;
@@ -137,7 +138,7 @@ type SearchBoxProps = {
137
138
  onFocus?: () => void;
138
139
  };
139
140
  /** A search pill over any number of result groups; picking an item calls that item's `onSelect`. */
140
- declare function SearchBox({ label, placeholder, value, onValueChange, groups, onPicked, onClear, leading, className, onFocus, }: SearchBoxProps): react.JSX.Element;
141
+ declare function SearchBox({ label, placeholder, value, onValueChange, groups, onPicked, onClear, clearLabel, leading, className, onFocus, }: SearchBoxProps): react.JSX.Element;
141
142
 
142
143
  declare function useArtistSearchGroup(getBaseUrl: () => string, text: string, onSelect: (artist: ArtistSearchResult) => void, enabled?: boolean): SearchGroup | undefined;
143
144
 
@@ -165,6 +166,9 @@ type GenreSearchProps<T extends CriteriaPlaylistSimple = CriteriaPlaylistSimple>
165
166
  onPrevious?: () => void;
166
167
  /** Called by the clear button, after it empties the input. */
167
168
  onClear?: () => void;
169
+ /** Forces the clear button on or off; by default it shows while the input has something to clear. */
170
+ showClear?: boolean;
171
+ clearLabel?: string;
168
172
  placeholder?: string;
169
173
  /** Called with what the user types, and with "" on clear or pick; never when `selectedName` refills the input. */
170
174
  onQueryChange?: (query: string) => void;
@@ -173,7 +177,7 @@ type GenreSearchProps<T extends CriteriaPlaylistSimple = CriteriaPlaylistSimple>
173
177
  /** Called when the input gains focus. */
174
178
  onFocus?: () => void;
175
179
  };
176
- declare function GenreSearch<T extends CriteriaPlaylistSimple = CriteriaPlaylistSimple>({ genrePlaylists, onSelect, selectedName, onClear, onPrevious, placeholder, onQueryChange, extraGroups, onFocus, }: GenreSearchProps<T>): react.JSX.Element;
180
+ declare function GenreSearch<T extends CriteriaPlaylistSimple = CriteriaPlaylistSimple>({ genrePlaylists, onSelect, selectedName, onClear, showClear, clearLabel, onPrevious, placeholder, onQueryChange, extraGroups, onFocus, }: GenreSearchProps<T>): react.JSX.Element;
177
181
 
178
182
  interface GenreSearchGroupsProps {
179
183
  query: string;
@@ -393,9 +397,10 @@ type GenreDetailPanelProps<O extends CriteriaOverview> = {
393
397
  /** Tab on the panel's inner edge, for hosts that hide and show the panel. */
394
398
  edgeTab?: ComponentProps<typeof DetailPanel>["edgeTab"];
395
399
  extras?: GenreInfoPanelBodyProps<O>["extras"];
396
- headerClassName?: ComponentProps<typeof DetailPanel>["headerClassName"];
400
+ /** The host's search field shows this genre's name and closes the panel. */
401
+ titleInSearch?: boolean;
397
402
  };
398
- declare function GenreDetailPanel<O extends CriteriaOverview>({ genreUuid, scope, getBackendBaseUrl, onClose, onSelectGenre, onZoomIn, shareUrl, shareTarget, collapsed, expanded, onExpandedChange, edgeTab, extras, headerClassName, }: GenreDetailPanelProps<O>): react.JSX.Element;
403
+ declare function GenreDetailPanel<O extends CriteriaOverview>({ genreUuid, scope, getBackendBaseUrl, onClose, onSelectGenre, onZoomIn, shareUrl, shareTarget, collapsed, expanded, onExpandedChange, edgeTab, extras, titleInSearch, }: GenreDetailPanelProps<O>): react.JSX.Element;
399
404
 
400
405
  type GenrePlaylistTreePerRootProps<T extends TrackBase> = {
401
406
  scope: Scope;
@@ -483,4 +488,56 @@ interface RatingProps {
483
488
  }
484
489
  declare function Rating({ rating, handleChange }: RatingProps): react.JSX.Element;
485
490
 
486
- export { type ArtistSearchProps, GenreDetailPanel, type GenreDetailPanelProps, type GenreDetailSlots, type GenreFocusProps, GenreInfoPanelBody, type GenreInfoPanelBodyProps, GenreNodeActionBand, type GenrePlaylistPlayback, type GenrePlaylistTracksSlots, GenrePlaylistTreePerRoot, type GenrePlaylistTreePerRootProps, GenreSearch, type GenreSearchExtraGroup, type GenreSearchGroupsProps, type GenreSearchProps, type GenreTreeShareTarget, GenreTreeView, type GenreTreeViewProps, type GetGenreTreeShareUrl, type ParentChange, type PlayableGenrePlaylist, PlayerPanel, type PlayerPanelProps, Rating, type RatingProps, SEARCH_FAILED_STATUS, SearchBox, type SearchBoxProps, type SearchGroup, type SearchGroupItem, TrackItem, type TrackItemProps, TrackListSidebar, type TrackListSidebarProps, TrackPositionPlayPause, type TrackPositionPlayPauseProps, type UseGenrePlaylistPlaybackOptions, type UseGenreRemoteSearchGroupOptions, searchStatus, useArtistSearchGroup, useGenrePlaylistPlayback, useGenreRemoteSearchGroup, useSongSearchGroup };
491
+ /** How the song's genre is opened: through the loaded tree's playlists, or by the genre alone for a host without a tree. */
492
+ type SongGenreNavigation = {
493
+ /** Root-to-leaf playlists of a genre in the loaded tree; empty when the tree doesn't hold it. */
494
+ getGenrePath: (genre: CriteriaMinimum) => CriteriaPlaylistSimple[];
495
+ onSelectGenrePlaylist: (genrePlaylist: CriteriaPlaylistSimple) => void;
496
+ onSelectGenre?: never;
497
+ } | {
498
+ onSelectGenre: (genre: CriteriaMinimum) => void;
499
+ getGenrePath?: never;
500
+ onSelectGenrePlaylist?: never;
501
+ };
502
+
503
+ interface EntityPanelState {
504
+ uuid: string | null;
505
+ expanded: boolean;
506
+ onExpandedChange: (expanded: boolean) => void;
507
+ onClose: () => void;
508
+ }
509
+ type EntityPanelOptions = {
510
+ scope: Scope;
511
+ getBackendBaseUrl: () => string;
512
+ /** Artist requests go here when the host serves them from elsewhere; defaults to `getBackendBaseUrl`. */
513
+ getArtistBackendBaseUrl?: () => string;
514
+ getShareUrl?: GetGenreTreeShareUrl;
515
+ renderArtistLink: RenderArtistLink;
516
+ renderSongLink: RenderSongLink;
517
+ song: EntityPanelState;
518
+ artist: EntityPanelState;
519
+ } & SongGenreNavigation;
520
+ /** The song or artist detail overlay panel; a song takes precedence over an artist, undefined when neither is set. */
521
+ declare function useEntityPanel({ scope, getBackendBaseUrl, getArtistBackendBaseUrl, getShareUrl, renderArtistLink, renderSongLink, song, artist, ...navigation }: EntityPanelOptions): OverlayPanel | undefined;
522
+
523
+ type EntityDetailPanelProps = {
524
+ scope: Scope;
525
+ getBackendBaseUrl: () => string;
526
+ getArtistBackendBaseUrl?: () => string;
527
+ getShareUrl?: GetGenreTreeShareUrl;
528
+ songUuid: string | null;
529
+ onSongUuidChange: (uuid: string | null) => void;
530
+ artistUuid: string | null;
531
+ onArtistUuidChange: (uuid: string | null) => void;
532
+ /** Names render as buttons that open the artist by default. */
533
+ renderArtistLink?: RenderArtistLink;
534
+ /** Titles render as buttons that open the song by default. */
535
+ renderSongLink?: RenderSongLink;
536
+ collapsed?: boolean;
537
+ edgeTab?: ComponentProps<typeof DetailPanel>["edgeTab"];
538
+ } & SongGenreNavigation;
539
+ /** The song or artist detail in the shared left DetailPanel, for a host that has no genre tree. Renders nothing
540
+ * while neither is set; a song takes precedence over an artist. */
541
+ declare function EntityDetailPanel({ scope, getBackendBaseUrl, getArtistBackendBaseUrl, getShareUrl, songUuid, onSongUuidChange, artistUuid, onArtistUuidChange, renderArtistLink, renderSongLink, collapsed, edgeTab, ...navigation }: EntityDetailPanelProps): react.JSX.Element | null;
542
+
543
+ export { type ArtistSearchProps, EntityDetailPanel, type EntityDetailPanelProps, type EntityPanelOptions, type EntityPanelState, GenreDetailPanel, type GenreDetailPanelProps, type GenreDetailSlots, type GenreFocusProps, GenreInfoPanelBody, type GenreInfoPanelBodyProps, GenreNodeActionBand, type GenrePlaylistPlayback, type GenrePlaylistTracksSlots, GenrePlaylistTreePerRoot, type GenrePlaylistTreePerRootProps, GenreSearch, type GenreSearchExtraGroup, type GenreSearchGroupsProps, type GenreSearchProps, type GenreTreeShareTarget, GenreTreeView, type GenreTreeViewProps, type GetGenreTreeShareUrl, type ParentChange, type PlayableGenrePlaylist, PlayerPanel, type PlayerPanelProps, Rating, type RatingProps, SEARCH_FAILED_STATUS, SearchBox, type SearchBoxProps, type SearchGroup, type SearchGroupItem, type SongGenreNavigation, TrackItem, type TrackItemProps, TrackListSidebar, type TrackListSidebarProps, TrackPositionPlayPause, type TrackPositionPlayPauseProps, type UseGenrePlaylistPlaybackOptions, type UseGenreRemoteSearchGroupOptions, searchStatus, useArtistSearchGroup, useEntityPanel, useGenrePlaylistPlayback, useGenreRemoteSearchGroup, useSongSearchGroup };
@@ -1,4 +1,5 @@
1
1
  import {
2
+ EntityDetailPanel,
2
3
  GenreDetailPanel,
3
4
  GenreInfoPanelBody,
4
5
  GenreNodeActionBand,
@@ -18,12 +19,14 @@ import {
18
19
  TrackPositionPlayPause,
19
20
  searchStatus,
20
21
  useArtistSearchGroup,
22
+ useEntityPanel,
21
23
  useGenrePlaylistPlayback,
22
24
  useGenreRemoteSearchGroup,
23
25
  useSongSearchGroup
24
- } from "../chunk-RTXLUHJY.js";
26
+ } from "../chunk-57OI426Y.js";
25
27
  import "../chunk-DKQQLLMY.js";
26
28
  export {
29
+ EntityDetailPanel,
27
30
  GenreDetailPanel,
28
31
  GenreInfoPanelBody,
29
32
  GenreNodeActionBand,
@@ -43,6 +46,7 @@ export {
43
46
  TrackPositionPlayPause,
44
47
  searchStatus,
45
48
  useArtistSearchGroup,
49
+ useEntityPanel,
46
50
  useGenrePlaylistPlayback,
47
51
  useGenreRemoteSearchGroup,
48
52
  useSongSearchGroup
package/dist/index.cjs CHANGED
@@ -24,6 +24,7 @@ __export(src_exports, {
24
24
  AuthErrorPopup: () => AuthErrorPopup,
25
25
  AuthPopup: () => AuthPopup,
26
26
  BasePopup: () => BasePopup,
27
+ EntityDetailPanel: () => EntityDetailPanel,
27
28
  GenreDetailPanel: () => GenreDetailPanel,
28
29
  GenreInfoPanelBody: () => GenreInfoPanelBody,
29
30
  GenreNodeActionBand: () => GenreNodeActionBand,
@@ -50,6 +51,7 @@ __export(src_exports, {
50
51
  TrackUploadPopup: () => TrackUploadPopup,
51
52
  searchStatus: () => searchStatus,
52
53
  useArtistSearchGroup: () => useArtistSearchGroup,
54
+ useEntityPanel: () => useEntityPanel,
53
55
  useGenrePlaylistPlayback: () => useGenrePlaylistPlayback,
54
56
  useGenreRemoteSearchGroup: () => useGenreRemoteSearchGroup,
55
57
  useSongSearchGroup: () => useSongSearchGroup
@@ -1047,6 +1049,7 @@ function SearchBox({
1047
1049
  groups,
1048
1050
  onPicked,
1049
1051
  onClear,
1052
+ clearLabel,
1050
1053
  leading,
1051
1054
  className: className2,
1052
1055
  onFocus
@@ -1078,6 +1081,7 @@ function SearchBox({
1078
1081
  status,
1079
1082
  loading,
1080
1083
  onClear,
1084
+ clearLabel,
1081
1085
  leading,
1082
1086
  inputClassName: "focus-visible:ring-0"
1083
1087
  }
@@ -1173,6 +1177,8 @@ function GenreSearch({
1173
1177
  onSelect,
1174
1178
  selectedName = null,
1175
1179
  onClear,
1180
+ showClear,
1181
+ clearLabel,
1176
1182
  onPrevious,
1177
1183
  placeholder,
1178
1184
  onQueryChange,
@@ -1220,7 +1226,8 @@ function GenreSearch({
1220
1226
  if (groupKey !== "genres") setQuery("");
1221
1227
  onQueryChange?.("");
1222
1228
  },
1223
- onClear: canClear ? () => {
1229
+ clearLabel,
1230
+ onClear: showClear ?? canClear ? () => {
1224
1231
  clear();
1225
1232
  onQueryChange?.("");
1226
1233
  } : void 0,
@@ -2057,10 +2064,12 @@ function useGenreDetailContent({
2057
2064
  };
2058
2065
  }, [detail, scope]);
2059
2066
  if (isError) return {
2067
+ name: null,
2060
2068
  title: "Genre unavailable",
2061
2069
  content: /* @__PURE__ */ (0, import_jsx_runtime32.jsx)("p", { role: "alert", className: "p-4 text-sm text-[var(--color-text)]", children: "This genre could not be loaded." })
2062
2070
  };
2063
2071
  if (isPending || !detail || !view) return {
2072
+ name: null,
2064
2073
  title: "Loading genre\u2026",
2065
2074
  content: /* @__PURE__ */ (0, import_jsx_runtime32.jsxs)("div", { className: "flex flex-col gap-3 p-4", "aria-busy": "true", "data-testid": "genre-detail-panel-skeleton", children: [
2066
2075
  /* @__PURE__ */ (0, import_jsx_runtime32.jsx)(import_ui10.Skeleton, { className: "h-6 w-2/3" }),
@@ -2082,6 +2091,7 @@ function useGenreDetailContent({
2082
2091
  );
2083
2092
  const ancestors = /* @__PURE__ */ (0, import_jsx_runtime32.jsx)(import_genre_tree_view4.GenreNodeAncestors, { ancestorNodes: view.ancestorNodes, onSelectNode: selectGenre });
2084
2093
  return {
2094
+ name: detail.name,
2085
2095
  title: detail.name,
2086
2096
  headerAccent: { background: view.fill, color: import_genre_tree_view4.ACCENT_TEXT_COLOR },
2087
2097
  aside: expanded ? ancestors : void 0,
@@ -2123,7 +2133,7 @@ function GenreDetailPanel({
2123
2133
  onExpandedChange,
2124
2134
  edgeTab,
2125
2135
  extras,
2126
- headerClassName
2136
+ titleInSearch
2127
2137
  }) {
2128
2138
  const panel = useGenreDetailContent({
2129
2139
  genreUuid,
@@ -2146,7 +2156,7 @@ function GenreDetailPanel({
2146
2156
  onExpandedChange,
2147
2157
  edgeTab,
2148
2158
  aside: panel.aside,
2149
- headerClassName,
2159
+ titleInSearch,
2150
2160
  headerActions: onExpandedChange ? /* @__PURE__ */ (0, import_jsx_runtime32.jsx)(import_ui10.IconButton, { "aria-label": expanded ? "Shrink panel" : "Extend panel", onClick: () => onExpandedChange(!expanded), children: expanded ? /* @__PURE__ */ (0, import_jsx_runtime32.jsx)(import_lucide_react12.Minimize2, { "aria-hidden": "true", className: "h-4 w-4" }) : /* @__PURE__ */ (0, import_jsx_runtime32.jsx)(import_lucide_react12.Maximize2, { "aria-hidden": "true", className: "h-4 w-4" }) }) : void 0,
2151
2161
  children: panel.content
2152
2162
  }
@@ -2700,15 +2710,8 @@ function SongHistory({ uuid, getBackendBaseUrl }) {
2700
2710
  }
2701
2711
  );
2702
2712
  }
2703
- function SongPanelContent({
2704
- song,
2705
- scope,
2706
- getBackendBaseUrl,
2707
- shareUrl,
2708
- renderArtistLink,
2709
- genrePath,
2710
- onSelectGenre
2711
- }) {
2713
+ function SongPanelContent(props) {
2714
+ const { song, scope, getBackendBaseUrl, shareUrl, renderArtistLink } = props;
2712
2715
  const { playNewTrackListFromTrackUuid } = (0, import_genre_tree26.useTrackList)();
2713
2716
  const { playerTrackObject, playState, handlePlayPauseAction } = (0, import_player11.usePlayer)();
2714
2717
  const unplayableReason = (0, import_genre_tree26.songUnplayableReason)(song);
@@ -2718,12 +2721,14 @@ function SongPanelContent({
2718
2721
  const musicBrainzUrl = (0, import_genre_tree26.musicBrainzRecordingUrl)(song);
2719
2722
  const fields = [];
2720
2723
  if (song.genre) {
2721
- const path = genrePath.length > 0 ? genrePath : null;
2724
+ const path = props.genrePath && props.genrePath.length > 0 ? props.genrePath : null;
2725
+ const { onSelectGenre } = props;
2726
+ const genre = song.genre;
2722
2727
  fields.push({
2723
2728
  label: "Genre",
2724
- value: path ? /* @__PURE__ */ (0, import_jsx_runtime43.jsx)("span", { className: "flex flex-wrap items-center gap-x-1", children: path.map((genrePlaylist, index) => /* @__PURE__ */ (0, import_jsx_runtime43.jsxs)("span", { children: [
2729
+ value: onSelectGenre ? /* @__PURE__ */ (0, import_jsx_runtime43.jsx)("button", { type: "button", onClick: () => onSelectGenre(genre), className: "hover:underline", children: genre.name }) : path ? /* @__PURE__ */ (0, import_jsx_runtime43.jsx)("span", { className: "flex flex-wrap items-center gap-x-1", children: path.map((genrePlaylist, index) => /* @__PURE__ */ (0, import_jsx_runtime43.jsxs)("span", { children: [
2725
2730
  index > 0 && /* @__PURE__ */ (0, import_jsx_runtime43.jsx)("span", { "aria-hidden": "true", children: "\u203A " }),
2726
- /* @__PURE__ */ (0, import_jsx_runtime43.jsx)("button", { type: "button", onClick: () => onSelectGenre(genrePlaylist), className: "hover:underline", children: genrePlaylist.name })
2731
+ /* @__PURE__ */ (0, import_jsx_runtime43.jsx)("button", { type: "button", onClick: () => props.onSelectGenrePlaylist?.(genrePlaylist), className: "hover:underline", children: genrePlaylist.name })
2727
2732
  ] }, genrePlaylist.uuid)) }) : song.genre.name
2728
2733
  });
2729
2734
  } else {
@@ -2789,11 +2794,10 @@ function useSongPanel({
2789
2794
  getBackendBaseUrl,
2790
2795
  getShareUrl,
2791
2796
  renderArtistLink,
2792
- getGenrePath,
2793
- onSelectGenre,
2794
2797
  expanded,
2795
2798
  onExpandedChange,
2796
- onClose
2799
+ onClose,
2800
+ ...navigation
2797
2801
  }) {
2798
2802
  const result = (0, import_genre_tree27.useSong)(uuid, getBackendBaseUrl);
2799
2803
  const panel = { expanded, onExpandedChange, onClose };
@@ -2837,8 +2841,10 @@ function useSongPanel({
2837
2841
  getBackendBaseUrl,
2838
2842
  shareUrl: shareUrl ?? void 0,
2839
2843
  renderArtistLink,
2840
- genrePath: song.genre ? getGenrePath(song.genre) : [],
2841
- onSelectGenre
2844
+ ...navigation.getGenrePath ? {
2845
+ genrePath: song.genre ? navigation.getGenrePath(song.genre) : [],
2846
+ onSelectGenrePlaylist: navigation.onSelectGenrePlaylist
2847
+ } : { onSelectGenre: navigation.onSelectGenre }
2842
2848
  }
2843
2849
  )
2844
2850
  };
@@ -2846,6 +2852,37 @@ function useSongPanel({
2846
2852
  }
2847
2853
  }
2848
2854
 
2855
+ // src/genre-tree/useEntityPanel.tsx
2856
+ function useEntityPanel({
2857
+ scope,
2858
+ getBackendBaseUrl,
2859
+ getArtistBackendBaseUrl,
2860
+ getShareUrl,
2861
+ renderArtistLink,
2862
+ renderSongLink,
2863
+ song,
2864
+ artist,
2865
+ ...navigation
2866
+ }) {
2867
+ const songPanel = useSongPanel({
2868
+ ...song,
2869
+ scope,
2870
+ getBackendBaseUrl,
2871
+ getShareUrl,
2872
+ renderArtistLink,
2873
+ ...navigation
2874
+ });
2875
+ const artistPanel = useArtistPanel({
2876
+ ...artist,
2877
+ scope,
2878
+ getBackendBaseUrl: getArtistBackendBaseUrl ?? getBackendBaseUrl,
2879
+ getShareUrl,
2880
+ renderArtistLink,
2881
+ renderSongLink
2882
+ });
2883
+ return songPanel ?? artistPanel;
2884
+ }
2885
+
2849
2886
  // src/genre-tree/SizeMetricToggle.tsx
2850
2887
  var import_md2 = require("react-icons/md");
2851
2888
  var import_ui19 = require("@behindthemusictree/ui");
@@ -3028,20 +3065,6 @@ function GenreTreeView({
3028
3065
  const [expandedArtistUuid, setExpandedArtistUuid] = (0, import_react18.useState)(null);
3029
3066
  const artistBaseUrl = artistSearchBaseUrl ?? getBackendBaseUrl;
3030
3067
  const songBaseUrl = getBackendBaseUrl;
3031
- const artistPanel = useArtistPanel({
3032
- uuid: artistUuid,
3033
- scope,
3034
- getBackendBaseUrl: artistBaseUrl,
3035
- getShareUrl: getShareUrlRef.current,
3036
- renderArtistLink: renderArtistLink ?? ((artist) => artist.isSpecialPurpose ? artist.name : /* @__PURE__ */ (0, import_jsx_runtime46.jsx)("button", { type: "button", onClick: () => openArtist(artist.uuid), className: "hover:underline", children: artist.name })),
3037
- expanded: controlledArtistExpanded ?? (artistUuid !== null && expandedArtistUuid === artistUuid),
3038
- onExpandedChange: (on) => {
3039
- if (controlledArtistExpanded === void 0) setExpandedArtistUuid(on ? artistUuid : null);
3040
- onArtistExpandedChange?.(on);
3041
- },
3042
- renderSongLink: resolvedRenderSongLink,
3043
- onClose: () => changeArtist(null)
3044
- });
3045
3068
  const resolvedRenderArtistLink = renderArtistLink ?? ((artist) => artist.isSpecialPurpose ? artist.name : /* @__PURE__ */ (0, import_jsx_runtime46.jsx)("button", { type: "button", onClick: () => openArtist(artist.uuid), className: "hover:underline", children: artist.name }));
3046
3069
  const getGenrePath = (genre) => {
3047
3070
  const path = [];
@@ -3053,27 +3076,38 @@ function GenreTreeView({
3053
3076
  }
3054
3077
  return path;
3055
3078
  };
3056
- const songPanel = useSongPanel({
3057
- uuid: songUuid,
3079
+ const entityPanel = useEntityPanel({
3058
3080
  scope,
3059
3081
  getBackendBaseUrl: songBaseUrl,
3082
+ getArtistBackendBaseUrl: artistBaseUrl,
3060
3083
  getShareUrl: getShareUrlRef.current,
3061
3084
  renderArtistLink: resolvedRenderArtistLink,
3085
+ renderSongLink: resolvedRenderSongLink,
3086
+ song: {
3087
+ uuid: songUuid,
3088
+ expanded: controlledSongExpanded ?? (songUuid !== null && expandedSongUuid === songUuid),
3089
+ onExpandedChange: (on) => {
3090
+ if (controlledSongExpanded === void 0) setExpandedSongUuid(on ? songUuid : null);
3091
+ onSongExpandedChange?.(on);
3092
+ },
3093
+ onClose: () => changeSong(null)
3094
+ },
3095
+ artist: {
3096
+ uuid: artistUuid,
3097
+ expanded: controlledArtistExpanded ?? (artistUuid !== null && expandedArtistUuid === artistUuid),
3098
+ onExpandedChange: (on) => {
3099
+ if (controlledArtistExpanded === void 0) setExpandedArtistUuid(on ? artistUuid : null);
3100
+ onArtistExpandedChange?.(on);
3101
+ },
3102
+ onClose: () => changeArtist(null)
3103
+ },
3062
3104
  getGenrePath,
3063
- onSelectGenre: (genrePlaylist2) => {
3105
+ onSelectGenrePlaylist: (genrePlaylist2) => {
3064
3106
  changeSong(null);
3065
3107
  selectGenrePlaylist(genrePlaylist2);
3066
- },
3067
- expanded: controlledSongExpanded ?? (songUuid !== null && expandedSongUuid === songUuid),
3068
- onExpandedChange: (on) => {
3069
- if (controlledSongExpanded === void 0) setExpandedSongUuid(on ? songUuid : null);
3070
- onSongExpandedChange?.(on);
3071
- },
3072
- onClose: () => changeSong(null)
3108
+ }
3073
3109
  });
3074
- const builtInSongPanel = overlayPanel || selectedGenreUuid !== null ? void 0 : songPanel;
3075
- const builtInArtistPanel = overlayPanel || selectedGenreUuid !== null || builtInSongPanel ? void 0 : artistPanel;
3076
- const builtInPanel = builtInSongPanel ?? builtInArtistPanel;
3110
+ const builtInPanel = overlayPanel || selectedGenreUuid !== null ? void 0 : entityPanel;
3077
3111
  const [uncontrolledCollapsed, setUncontrolledCollapsed] = (0, import_react18.useState)(false);
3078
3112
  const [uncontrolledExpanded, setUncontrolledExpanded] = (0, import_react18.useState)(false);
3079
3113
  const isCollapsed = collapsed ?? uncontrolledCollapsed;
@@ -3122,7 +3156,6 @@ function GenreTreeView({
3122
3156
  if (node !== null && songUuid !== null) changeSong(null);
3123
3157
  selectNode(node);
3124
3158
  };
3125
- const handleGenreSearchClear = clearSelection;
3126
3159
  const handleGenreSearchSelect = (genrePlaylist2) => {
3127
3160
  reopenPanel();
3128
3161
  if (songUuid !== null) changeSong(null);
@@ -3205,7 +3238,7 @@ function GenreTreeView({
3205
3238
  expanded: overlayPanel.expanded ?? false,
3206
3239
  onExpandedChange: overlayPanel.onExpandedChange,
3207
3240
  onClose: overlayPanel.onClose,
3208
- compact: false
3241
+ titleInSearch: false
3209
3242
  };
3210
3243
  } else if (selectedGenreUuid) {
3211
3244
  activePanel = {
@@ -3215,7 +3248,7 @@ function GenreTreeView({
3215
3248
  expanded: isExpanded,
3216
3249
  onExpandedChange: changeExpanded,
3217
3250
  onClose: clearSelection,
3218
- compact: false,
3251
+ titleInSearch: true,
3219
3252
  headerAccent: relations ? { background: relations.fill, color: relations.textColor } : genreContent.headerAccent
3220
3253
  };
3221
3254
  } else if (builtInPanel) {
@@ -3226,7 +3259,7 @@ function GenreTreeView({
3226
3259
  expanded: builtInPanel.expanded,
3227
3260
  onExpandedChange: builtInPanel.onExpandedChange,
3228
3261
  onClose: builtInPanel.onClose,
3229
- compact: true
3262
+ titleInSearch: true
3230
3263
  };
3231
3264
  }
3232
3265
  const isPanelShown = activePanel !== null && (selectedGenreUuid !== null || isTreeReady);
@@ -3294,8 +3327,10 @@ function GenreTreeView({
3294
3327
  if (songUuid) changeSong(null);
3295
3328
  selectGenre(uuid);
3296
3329
  },
3297
- selectedName: (selectedGenreUuid ? genreContent.title : null) ?? (typeof builtInPanel?.title === "string" ? builtInPanel.title : null),
3298
- onClear: builtInSongPanel ? () => changeSong(null) : builtInArtistPanel ? () => changeArtist(null) : handleGenreSearchClear,
3330
+ selectedName: selectedGenreUuid ? genreContent.name : typeof builtInPanel?.title === "string" ? builtInPanel.title : null,
3331
+ onClear: activePanel?.onClose,
3332
+ showClear: activePanel?.titleInSearch ?? false,
3333
+ clearLabel: "Close panel",
3299
3334
  onPrevious: handlePrevious,
3300
3335
  onQueryChange: onSearchQueryChange,
3301
3336
  onFocus: onSearchFocus,
@@ -3485,8 +3520,7 @@ function GenreTreeView({
3485
3520
  ref: panelRef,
3486
3521
  side: "left",
3487
3522
  title: activePanel?.title ?? "",
3488
- hideTitle: activePanel?.compact,
3489
- hideClose: activePanel?.compact,
3523
+ titleInSearch: activePanel?.titleInSearch,
3490
3524
  headerAccent: activePanel?.headerAccent,
3491
3525
  headerActions: expandButton,
3492
3526
  onClose: activePanel?.onClose ?? noop,
@@ -3495,7 +3529,7 @@ function GenreTreeView({
3495
3529
  onExpandedChange: activePanel?.onExpandedChange,
3496
3530
  aside: activePanel?.aside,
3497
3531
  edgeTab: detailTab,
3498
- headerClassName: "pt-[calc(var(--layout-search-h)+var(--layout-panel-inset)+0.5rem)]",
3532
+ headerClassName: activePanel?.titleInSearch ? void 0 : "pt-[calc(var(--layout-search-h)+var(--layout-panel-inset)+0.5rem)]",
3499
3533
  children: activePanel?.aside && !activePanel.expanded ? /* @__PURE__ */ (0, import_jsx_runtime46.jsxs)(import_jsx_runtime46.Fragment, { children: [
3500
3534
  activePanel.content,
3501
3535
  activePanel.aside
@@ -3942,12 +3976,76 @@ function Rating({ rating, handleChange }) {
3942
3976
  ) })
3943
3977
  ] }, i)) });
3944
3978
  }
3979
+
3980
+ // src/genre-tree/EntityDetailPanel.tsx
3981
+ var import_react21 = require("react");
3982
+ var import_ui22 = require("@behindthemusictree/ui");
3983
+ var import_jsx_runtime51 = require("react/jsx-runtime");
3984
+ function EntityDetailPanel({
3985
+ scope,
3986
+ getBackendBaseUrl,
3987
+ getArtistBackendBaseUrl,
3988
+ getShareUrl,
3989
+ songUuid,
3990
+ onSongUuidChange,
3991
+ artistUuid,
3992
+ onArtistUuidChange,
3993
+ renderArtistLink,
3994
+ renderSongLink,
3995
+ collapsed,
3996
+ edgeTab,
3997
+ ...navigation
3998
+ }) {
3999
+ const [expandedSongUuid, setExpandedSongUuid] = (0, import_react21.useState)(null);
4000
+ const [expandedArtistUuid, setExpandedArtistUuid] = (0, import_react21.useState)(null);
4001
+ const panel = useEntityPanel({
4002
+ scope,
4003
+ getBackendBaseUrl,
4004
+ getArtistBackendBaseUrl,
4005
+ getShareUrl,
4006
+ renderArtistLink: renderArtistLink ?? ((artist) => artist.isSpecialPurpose ? artist.name : /* @__PURE__ */ (0, import_jsx_runtime51.jsx)("button", { type: "button", onClick: () => onArtistUuidChange(artist.uuid), className: "hover:underline", children: artist.name })),
4007
+ renderSongLink: renderSongLink ?? ((song) => /* @__PURE__ */ (0, import_jsx_runtime51.jsx)("button", { type: "button", onClick: () => onSongUuidChange(song.uuid), className: "hover:underline", children: song.title })),
4008
+ song: {
4009
+ uuid: songUuid,
4010
+ expanded: songUuid !== null && expandedSongUuid === songUuid,
4011
+ onExpandedChange: (on) => setExpandedSongUuid(on ? songUuid : null),
4012
+ onClose: () => onSongUuidChange(null)
4013
+ },
4014
+ artist: {
4015
+ uuid: artistUuid,
4016
+ expanded: artistUuid !== null && expandedArtistUuid === artistUuid,
4017
+ onExpandedChange: (on) => setExpandedArtistUuid(on ? artistUuid : null),
4018
+ onClose: () => onArtistUuidChange(null)
4019
+ },
4020
+ ...navigation
4021
+ });
4022
+ if (!panel) return null;
4023
+ return /* @__PURE__ */ (0, import_jsx_runtime51.jsx)(
4024
+ import_ui22.DetailPanel,
4025
+ {
4026
+ side: "left",
4027
+ title: panel.title,
4028
+ titleInSearch: true,
4029
+ onClose: panel.onClose,
4030
+ collapsed,
4031
+ expanded: panel.expanded,
4032
+ onExpandedChange: panel.onExpandedChange,
4033
+ aside: panel.extraContent,
4034
+ edgeTab,
4035
+ children: panel.extraContent && !panel.expanded ? /* @__PURE__ */ (0, import_jsx_runtime51.jsxs)(import_jsx_runtime51.Fragment, { children: [
4036
+ panel.content,
4037
+ panel.extraContent
4038
+ ] }) : panel.content
4039
+ }
4040
+ );
4041
+ }
3945
4042
  // Annotate the CommonJS export names for ESM import in node:
3946
4043
  0 && (module.exports = {
3947
4044
  AuthCallbackHandler,
3948
4045
  AuthErrorPopup,
3949
4046
  AuthPopup,
3950
4047
  BasePopup,
4048
+ EntityDetailPanel,
3951
4049
  GenreDetailPanel,
3952
4050
  GenreInfoPanelBody,
3953
4051
  GenreNodeActionBand,
@@ -3974,6 +4072,7 @@ function Rating({ rating, handleChange }) {
3974
4072
  TrackUploadPopup,
3975
4073
  searchStatus,
3976
4074
  useArtistSearchGroup,
4075
+ useEntityPanel,
3977
4076
  useGenrePlaylistPlayback,
3978
4077
  useGenreRemoteSearchGroup,
3979
4078
  useSongSearchGroup