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
2 changes: 1 addition & 1 deletion frontend/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export default tseslint.config(
allow: ["layout"],
// Existing CSS treatments are allowed only on their owning components.
contracts: [
{ pattern: "^Button$", allow: ["layout", "artist-hero-back"] },
{ pattern: "^Button$", allow: ["layout"] },
{
pattern: "^CoverArt$",
allow: ["layout", "card-art", "mini-art", "detail-art", "hero-art", "share-preview-art", "artist-hero-avatar"],
Expand Down
20 changes: 4 additions & 16 deletions frontend/src/components/library/AlbumSleeve.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
} from "react";
import { Link, useSearchParams } from "react-router-dom";
import {
ArrowLeft as ArrowLeftIcon,
Pencil as PencilIcon,
Play as PlayIcon,
Shuffle as ShuffleIcon,
Expand All @@ -17,6 +16,7 @@ import { startListPlayback } from "@music-library/core/player/play-list";
import type { TrackListItem } from "../../api";
import { usePlayer, useRemotePlayback } from "../../context/Player";
import { displayText } from "../../lib/format";
import { withoutLibraryDrill } from "../../lib/libraryDrill";
import { Button } from "../Button";
import CoverArt from "../CoverArt";
import SearchInput from "../SearchInput";
Expand Down Expand Up @@ -65,7 +65,6 @@ export interface AlbumSleeveSearch {
*/
export function AlbumSleeve({
kind,
onBack,
album,
loadingLabel,
versions,
Expand All @@ -79,7 +78,6 @@ export function AlbumSleeve({
children,
}: {
kind: ReactNode;
onBack: () => void;
album: AlbumSleeveAlbum | null;
/** Announced while `album` is null. */
loadingLabel: string;
Expand Down Expand Up @@ -127,15 +125,6 @@ export function AlbumSleeve({
return (
<div ref={viewRef} className="view album-sleeve" aria-busy={album ? undefined : true}>
<header ref={sideRef} className="album-sleeve-side">
<Button
variant="ghost"
size="sm"
className="album-sleeve-back"
onClick={onBack}
leadingIcon={<ArrowLeftIcon className="size-3.5" />}
>
Library
</Button>
{album ? (
<CoverArt
className="album-sleeve-art"
Expand Down Expand Up @@ -271,14 +260,13 @@ function SleevePlay({ tracks, loading }: { tracks: TrackListItem[]; loading: boo

/**
* The artists under the title. One with a library page links to it; the
* link keeps the rest of the library's state (tab, search) so its Back
* returns there.
* link keeps the rest of the library's state (tab, search) so the Library
* crumb returns there.
*/
function AlbumCredits({ credits }: { credits: AlbumCredit[] }) {
const [params] = useSearchParams();
const artistLink = (artistId: string) => {
const next = new URLSearchParams(params);
for (const key of ["album", "tidalAlbum", "tidalArtist", "artistName"]) next.delete(key);
const next = withoutLibraryDrill(params);
next.set("artist", artistId);
return { pathname: "/library", search: `?${next}` };
};
Expand Down
27 changes: 20 additions & 7 deletions frontend/src/components/shell/Breadcrumbs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,40 +2,53 @@ import { useMemo } from "react";
import { Link, useLocation, useParams } from "react-router-dom";
import { ChevronRight as ChevronRightIcon } from "lucide-react";
import type { Playlist } from "../../api";
import { hasLibraryDrill, withoutLibraryDrill } from "../../lib/libraryDrill";

type Crumb = { label: string; to?: string; replace?: boolean; current?: boolean };

export default function Breadcrumbs({ playlists }: { playlists: Playlist[] }) {
const location = useLocation();
const params = useParams();
const crumbs = useMemo(
() => buildCrumbs(location.pathname, params, playlists),
[location.pathname, params, playlists],
() => buildCrumbs(location.pathname, location.search, params, playlists),
[location.pathname, location.search, params, playlists],
);

return (
<div className="crumbs">
<nav className="crumbs" aria-label="Breadcrumb">
{crumbs.map((c, i) => (
<span key={i} className="crumb">
{i > 0 && <ChevronRightIcon className="size-3" aria-hidden="true" />}
{c.current ? (
<b>{c.label}</b>
<b aria-current="page">{c.label}</b>
) : c.to ? (
<Link to={c.to}>{c.label}</Link>
<Link to={c.to} replace={c.replace}>
{c.label}
</Link>
) : (
<span>{c.label}</span>
)}
</span>
))}
</div>
</nav>
);
}

function buildCrumbs(
pathname: string,
search: string,
params: Record<string, string | undefined>,
playlists: Playlist[],
): { label: string; to?: string; current?: boolean }[] {
): Crumb[] {
if (pathname === "/") return [{ label: "Home", current: true }];
if (pathname.startsWith("/library")) {
// On an album or artist page it's the way back to the list (and the
// search) the page was opened from, like Library's own back step.
const query = new URLSearchParams(search);
if (hasLibraryDrill(query)) {
const rest = withoutLibraryDrill(query).toString();
return [{ label: "Library", to: rest ? `/library?${rest}` : "/library", replace: true }];
}
return [{ label: "Library", current: true }];
}
if (pathname.startsWith("/favorites")) {
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/lib/libraryDrill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/** The search params that open an album or artist page over the library list. */
const DRILL_PARAMS = ["album", "artist", "tidalAlbum", "tidalArtist"] as const;

/** An empty value (`?album=`) opens nothing, as on the Library page. */
export function hasLibraryDrill(params: URLSearchParams): boolean {
return DRILL_PARAMS.some((key) => Boolean(params.get(key)));
}

/** The library list (tab and search) the album or artist page was opened from. */
export function withoutLibraryDrill(params: URLSearchParams): URLSearchParams {
const next = new URLSearchParams(params);
for (const key of [...DRILL_PARAMS, "artistName"]) next.delete(key);
return next;
}
10 changes: 3 additions & 7 deletions frontend/src/pages/Library.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
type TrackListItem,
} from "../api";
import { displayText } from "../lib/format";
import { withoutLibraryDrill } from "../lib/libraryDrill";
import type { EntityTarget } from "@music-library/core/entity-target";
import TrackList from "../components/TrackList";
import CoverArt from "../components/CoverArt";
Expand Down Expand Up @@ -158,11 +159,7 @@ export default function Library() {
setParams(next);
};

const clearDrill = () => {
const next = new URLSearchParams(params);
for (const key of ["album", "artist", "tidalAlbum", "tidalArtist", "artistName"]) next.delete(key);
setParams(next, { replace: true });
};
const clearDrill = () => setParams(withoutLibraryDrill(params), { replace: true });

if (albumID) {
return <AlbumDetailView key={albumID} id={albumID} onBack={clearDrill} />;
Expand All @@ -173,14 +170,13 @@ export default function Library() {
key={tidalAlbumID}
id={tidalAlbumID}
artistId={tidalArtistID}
onBack={clearDrill}
onOpenAlbum={openAlbum}
onOpenVersion={openVersion}
/>
);
}
if (tidalArtistID) {
return <TidalArtistDetailView key={tidalArtistID} id={tidalArtistID} name={params.get("artistName") ?? "TIDAL artist"} onBack={clearDrill} onOpenAlbum={openAlbum} />;
return <TidalArtistDetailView key={tidalArtistID} id={tidalArtistID} name={params.get("artistName") ?? "TIDAL artist"} onOpenAlbum={openAlbum} />;
}
if (artistID) {
return <ArtistDetailView key={artistID} id={artistID} onBack={clearDrill} onOpenAlbum={openAlbum} />;
Expand Down
38 changes: 3 additions & 35 deletions frontend/src/pages/library/ArtistDetail.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import {
ArrowLeft as ArrowLeftIcon,
Shuffle as ArrowsRightLeftIcon,
Pause as PauseIcon,
Play as PlayIcon,
Expand Down Expand Up @@ -75,21 +74,13 @@ export function ArtistDetailView({
return <NotFound kind="Artist" onBack={onBack} />;
}
// A failed load leaves both artist and tracks empty; without this the page
// would sit on the loading state with no way back. A failed refresh of a
// cached artist keeps the page and shows the error in it.
// would sit on the loading state for good. The Library crumb is the way
// back. A failed refresh of a cached artist keeps the page and shows the
// error in it.
if (error && (!artist || !tracks)) {
return (
<div className="view artist-status">
<ErrorBanner message={error} />
<div>
<Button
variant="ghost"
onClick={onBack}
leadingIcon={<ArrowLeftIcon className="size-3.5" />}
>
Back to library
</Button>
</div>
</div>
);
}
Expand All @@ -102,8 +93,6 @@ export function ArtistDetailView({
imageUrl={null}
kind="Artist"
meta={["Loading artist…"]}
backLabel="Library"
onBack={onBack}
/>
<ArtistActions name="" tracks={NO_TRACKS} />
</div>
Expand All @@ -116,8 +105,6 @@ export function ArtistDetailView({
imageUrl={artistImageUrl(artist, tracks)}
kind="Artist"
meta={artistMetaParts(artist)}
backLabel="Library"
onBack={onBack}
/>
<ArtistActions name={artist.name} tracks={tracks}>
<DetailTrackSearchBar
Expand Down Expand Up @@ -155,13 +142,11 @@ export function ArtistDetailView({
export function TidalArtistDetailView({
id,
name,
onBack,
onOpenAlbum,
}: {
id: string;
/** Name from the link, shown until the loaded profile replaces it. */
name: string;
onBack: () => void;
onOpenAlbum: (target: EntityTarget) => void;
}) {
// Keyed on the id by the caller, so this only has to seed a revisit.
Expand Down Expand Up @@ -217,8 +202,6 @@ export function TidalArtistDetailView({
</>
}
meta={data ? tidalArtistMetaParts(releases.length, tracks.length) : undefined}
backLabel="Back to search"
onBack={onBack}
/>
<ArtistActions name={artistName} tracks={tracks} />
{(error || warning) && (
Expand Down Expand Up @@ -261,32 +244,17 @@ function ArtistHero({
imageUrl,
kind,
meta,
backLabel,
onBack,
}: {
/** Null while loading: a bar in place of the name. */
name: string | null;
imageUrl: string | null;
kind: ReactNode;
/** Undefined while loading; the row keeps its height either way. */
meta?: (string | false)[];
backLabel: string;
onBack: () => void;
}) {
const metaItems = meta?.filter((item): item is string => Boolean(item));
return (
<header className="artist-hero">
<div>
<Button
variant="ghost"
size="sm"
className="artist-hero-back"
onClick={onBack}
leadingIcon={<ArrowLeftIcon className="size-3.5" />}
>
{backLabel}
</Button>
</div>
<div className="artist-hero-main">
{name === null ? (
// No initial to show yet; a "?" would flash before the real one.
Expand Down
36 changes: 6 additions & 30 deletions frontend/src/pages/library/LibraryDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,11 @@ export function AlbumDetailView({
return <NotFound kind="Album" onBack={onBack} />;
}
if (!sleeve || !album || !tracks) {
// A failed first load: the way back and the error, not a frame that
// would never fill.
if (error) return <DetailLoadError error={error} onBack={onBack} />;
// A failed first load: the error, not a frame that would never fill.
if (error) return <DetailLoadError error={error} />;
return (
<AlbumSleeve
kind="Album"
onBack={onBack}
album={null}
loadingLabel="Loading album…"
search={sleeveSearch(search, 0)}
Expand All @@ -106,7 +104,6 @@ export function AlbumDetailView({
return (
<AlbumSleeve
kind="Album"
onBack={onBack}
album={sleeve}
loadingLabel="Loading album…"
onEdit={isAdmin ? () => setEditing(true) : undefined}
Expand Down Expand Up @@ -148,7 +145,6 @@ export function AlbumDetailView({
export function TidalAlbumDetailView({
id,
artistId,
onBack,
onOpenAlbum,
onOpenVersion,
}: {
Expand All @@ -158,7 +154,6 @@ export function TidalAlbumDetailView({
* Without one, the album's own artist is asked once it loads.
*/
artistId: string | null;
onBack: () => void;
onOpenAlbum: (target: EntityTarget) => void;
/**
* Swaps the page for another version of the release (a TIDAL album id),
Expand Down Expand Up @@ -248,8 +243,7 @@ export function TidalAlbumDetailView({
if (!album || !sleeve) {
if (error) {
return (
<div className="view" style={{ display: "grid", gap: 18 }}>
<DetailBackRow onBack={onBack} />
<div className="view">
<div
style={{
padding: 40,
Expand All @@ -269,7 +263,6 @@ export function TidalAlbumDetailView({
return (
<AlbumSleeve
kind="TIDAL Album"
onBack={onBack}
album={null}
loadingLabel="Loading TIDAL album…"
versions={versionPicker}
Expand All @@ -294,7 +287,6 @@ export function TidalAlbumDetailView({
return (
<AlbumSleeve
kind="TIDAL Album"
onBack={onBack}
album={sleeve}
loadingLabel="Loading TIDAL album…"
versions={versionPicker}
Expand Down Expand Up @@ -480,31 +472,15 @@ export function DetailTrackSearchBar({
);
}

/** A failed first load: the way back and the error. */
function DetailLoadError({ error, onBack }: { error: string; onBack: () => void }) {
/** A failed first load. The Library crumb is the way back. */
function DetailLoadError({ error }: { error: string }) {
return (
<div className="view" style={{ display: "grid", gap: 18 }}>
<DetailBackRow onBack={onBack} />
<div className="view">
<ErrorBanner message={error} />
</div>
);
}

function DetailBackRow({ onBack }: { onBack: () => void }) {
return (
<div>
<Button
variant="ghost"
size="sm"
onClick={onBack}
leadingIcon={<ArrowLeftIcon className="size-3.5" />}
>
Library
</Button>
</div>
);
}

export function NotFound({
kind,
onBack,
Expand Down
Loading
Loading