Web frontend
This commit is contained in:
475
web/src/components/BrowseView.tsx
Normal file
475
web/src/components/BrowseView.tsx
Normal file
@@ -0,0 +1,475 @@
|
||||
/** The browse screen: filter pills, the search pill, and whichever of the three result
|
||||
* lists applies. Selection indices run flat across songs, then categories, then albums,
|
||||
* which is what lets one pair of arrow keys walk the whole page. */
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import type { Album } from "../api/types";
|
||||
import {
|
||||
albumLine,
|
||||
aspectOfKind,
|
||||
cardBackground,
|
||||
cardShadow,
|
||||
isBook,
|
||||
unitLabel,
|
||||
} from "../lib/covers";
|
||||
import { clock } from "../lib/format";
|
||||
import type { Filter, Results, SongHit } from "../lib/search";
|
||||
import { Cover } from "./Cover";
|
||||
|
||||
interface Props {
|
||||
results: Results;
|
||||
filter: Filter;
|
||||
mode: "albums" | "tracks";
|
||||
search: string;
|
||||
category: string | null;
|
||||
selIndex: number;
|
||||
currentAlbumId: string | null;
|
||||
gridRef: (element: HTMLElement | null) => void;
|
||||
onFilter: (filter: Filter) => void;
|
||||
onCategory: (key: string | null) => void;
|
||||
onOpenAlbum: (album: Album, navIndex: number) => void;
|
||||
onPlaySong: (hit: SongHit) => void;
|
||||
}
|
||||
|
||||
const FILTERS: Array<[Filter, string]> = [
|
||||
["all", "Alles"],
|
||||
["music", "🎵 Musik"],
|
||||
["book", "📖 Hörbücher"],
|
||||
];
|
||||
|
||||
export function BrowseView({
|
||||
results,
|
||||
filter,
|
||||
mode,
|
||||
search,
|
||||
category,
|
||||
selIndex,
|
||||
currentAlbumId,
|
||||
gridRef,
|
||||
onFilter,
|
||||
onCategory,
|
||||
onOpenAlbum,
|
||||
onPlaySong,
|
||||
}: Props) {
|
||||
const scroller = useRef<HTMLDivElement | null>(null);
|
||||
const { songs, categories, albums, total } = results;
|
||||
const selected = total ? Math.min(selIndex, total - 1) : -1;
|
||||
|
||||
// Keep the selection on screen as the arrow keys walk past the fold.
|
||||
useEffect(() => {
|
||||
const box = scroller.current;
|
||||
const element = box?.querySelector<HTMLElement>(`[data-nav-index="${selected}"]`);
|
||||
if (!box || !element) return;
|
||||
const top = element.offsetTop - box.offsetTop;
|
||||
const bottom = top + element.offsetHeight;
|
||||
const pad = 24;
|
||||
if (top - pad < box.scrollTop) box.scrollTop = Math.max(0, top - pad);
|
||||
else if (bottom + pad > box.scrollTop + box.clientHeight) {
|
||||
box.scrollTop = bottom + pad - box.clientHeight;
|
||||
}
|
||||
}, [selected]);
|
||||
|
||||
const showSearchBar = search.length > 0 || mode === "tracks";
|
||||
const countLabel =
|
||||
mode === "tracks"
|
||||
? `${songs.length} Titel gefunden`
|
||||
: albums.length
|
||||
? `${albums.length} Album${albums.length === 1 ? "" : "en"} gefunden`
|
||||
: "nichts gefunden";
|
||||
|
||||
const sectionLabel =
|
||||
filter === "book" ? "Hörbücher" : filter === "music" ? "Alben" : "Alben & Hörbücher";
|
||||
const categoryLabel =
|
||||
filter === "book" ? "Figuren" : filter === "music" ? "Künstler" : "Figuren & Künstler";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 10,
|
||||
padding: "4px 32px 6px",
|
||||
flex: "none",
|
||||
}}
|
||||
>
|
||||
{FILTERS.map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
className="pill"
|
||||
data-active={filter === value}
|
||||
onClick={() => onFilter(value)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showSearchBar && (
|
||||
<div
|
||||
style={{
|
||||
margin: "2px 32px 6px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 12,
|
||||
flex: "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "var(--paper)",
|
||||
color: "var(--ink)",
|
||||
fontWeight: 800,
|
||||
fontSize: 20,
|
||||
padding: "10px 20px",
|
||||
borderRadius: 999,
|
||||
boxShadow: "0 4px 14px var(--shadow)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 800,
|
||||
background: "var(--ink)",
|
||||
color: "#fff",
|
||||
padding: "4px 10px",
|
||||
borderRadius: 999,
|
||||
}}
|
||||
>
|
||||
{mode === "tracks" ? "♪ Titel" : "🔎 Alben"}
|
||||
</span>
|
||||
<span>{search ? `"${search}"` : "tippe …"}</span>
|
||||
</div>
|
||||
<div style={{ color: "oklch(90% 0.02 210 / .85)", fontWeight: 700, fontSize: 14 }}>
|
||||
{countLabel}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={scroller}
|
||||
style={{ flex: 1, overflow: "auto", minHeight: 0, padding: "14px 32px 250px" }}
|
||||
>
|
||||
{songs.length > 0 && (
|
||||
<div style={{ marginBottom: 30 }}>
|
||||
<SectionTitle>
|
||||
Titel <Muted>{songs.length} Treffer</Muted>
|
||||
</SectionTitle>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
maxWidth: 760,
|
||||
margin: "0 auto",
|
||||
}}
|
||||
>
|
||||
{songs.map((hit, index) => (
|
||||
<button
|
||||
key={`${hit.album.id}-${hit.index}`}
|
||||
data-nav-index={index}
|
||||
onClick={() => onPlaySong(hit)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
padding: "10px 16px",
|
||||
borderRadius: 14,
|
||||
cursor: "pointer",
|
||||
border: "none",
|
||||
textAlign: "left",
|
||||
font: "inherit",
|
||||
background: `oklch(97% 0.01 210 / ${
|
||||
currentAlbumId === hit.album.id ? ".22" : ".10"
|
||||
})`,
|
||||
outline:
|
||||
selected === index ? "4px solid var(--paper)" : undefined,
|
||||
outlineOffset: 2,
|
||||
}}
|
||||
>
|
||||
<div style={{ height: 40, flex: "none", display: "flex" }}>
|
||||
<Cover
|
||||
album={hit.album}
|
||||
size={40}
|
||||
fit="height"
|
||||
radius={isBook(hit.album) ? "5px 10px 10px 5px" : "10px"}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 800, color: "var(--paper)" }}>
|
||||
{hit.title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: "oklch(88% 0.02 210 / .65)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{albumLine(hit.album)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
font: "700 13px ui-monospace, Menlo, monospace",
|
||||
color: "oklch(88% 0.02 210 / .6)",
|
||||
}}
|
||||
>
|
||||
{clock(hit.duration)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{categories.length > 0 && (
|
||||
<div>
|
||||
<SectionTitle centered>{categoryLabel}</SectionTitle>
|
||||
<div className="grid" ref={gridRef}>
|
||||
{categories.map((entry, index) => {
|
||||
const navIndex = songs.length + index;
|
||||
const books = entry.albums.filter(isBook).length;
|
||||
const allBooks = books === entry.albums.length;
|
||||
const mostlyBooks = books * 2 > entry.albums.length;
|
||||
const shown = entry.albums.slice(0, 4);
|
||||
return (
|
||||
<button
|
||||
key={entry.key}
|
||||
className="card"
|
||||
data-nav-index={navIndex}
|
||||
data-selected={selected === navIndex}
|
||||
onClick={() => onCategory(entry.key)}
|
||||
style={{
|
||||
background: allBooks
|
||||
? "oklch(93% 0.055 88 / .7)"
|
||||
: "oklch(95% 0.015 210 / .66)",
|
||||
borderRadius: allBooks ? "6px 18px 18px 6px" : "16px",
|
||||
boxShadow: "0 6px 18px var(--shadow)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
// A category of one gets one full-bleed cover rather than a
|
||||
// 2x2 grid with three empty holes in it.
|
||||
// Always a full 2x2 for more than one, so every cell has the
|
||||
// same shape as the tile and the covers fill it exactly. Two
|
||||
// albums in a single row would each be twice as wide as their
|
||||
// cell and spill out of it.
|
||||
gridTemplateColumns: shown.length === 1 ? "1fr" : "1fr 1fr",
|
||||
gridTemplateRows: shown.length === 1 ? "1fr" : "1fr 1fr",
|
||||
gap: 4,
|
||||
padding: 8,
|
||||
// The tile takes the shape of what it holds, so a shelf of
|
||||
// audiobooks is visibly taller than a shelf of albums.
|
||||
aspectRatio: aspectOfKind(mostlyBooks ? "book" : "music"),
|
||||
}}
|
||||
>
|
||||
{shown.map((album) => (
|
||||
<div
|
||||
key={album.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Cover
|
||||
album={album}
|
||||
size={shown.length === 1 ? 160 : 70}
|
||||
fit="height"
|
||||
radius="4px"
|
||||
label={shown.length === 1}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ padding: "4px 12px 14px" }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 800,
|
||||
color: "oklch(22% 0.03 210)",
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{entry.key}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 800,
|
||||
color: "oklch(40% 0.17 340)",
|
||||
marginTop: 5,
|
||||
}}
|
||||
>
|
||||
{entry.albums.length}{" "}
|
||||
{allBooks
|
||||
? entry.albums.length === 1
|
||||
? "Hörbuch"
|
||||
: "Hörbücher"
|
||||
: entry.albums.length === 1
|
||||
? "Album"
|
||||
: "Alben"}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{albums.length > 0 && (
|
||||
<div>
|
||||
{category !== null && !search && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 12,
|
||||
marginBottom: 14,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="pill"
|
||||
data-active="true"
|
||||
onClick={() => onCategory(null)}
|
||||
>
|
||||
← Alle
|
||||
</button>
|
||||
<div style={{ fontSize: 22, fontWeight: 900, color: "var(--paper)" }}>
|
||||
{category}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{search.length > 0 && <SectionTitle centered>{sectionLabel}</SectionTitle>}
|
||||
<div className="grid" ref={gridRef}>
|
||||
{albums.map((album, index) => {
|
||||
const navIndex = songs.length + categories.length + index;
|
||||
return (
|
||||
<button
|
||||
key={album.id}
|
||||
className="card"
|
||||
data-nav-index={navIndex}
|
||||
data-selected={selected === navIndex}
|
||||
data-current={album.id === currentAlbumId}
|
||||
onClick={() => onOpenAlbum(album, navIndex)}
|
||||
style={{
|
||||
background: cardBackground(album),
|
||||
borderRadius: isBook(album) ? "6px 18px 18px 6px" : "16px",
|
||||
boxShadow: cardShadow(album),
|
||||
}}
|
||||
>
|
||||
<Cover album={album} size={180} radius="0" label />
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px 14px",
|
||||
paddingRight: isBook(album) ? 22 : 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 800,
|
||||
color: "oklch(22% 0.03 210)",
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{album.title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "oklch(30% 0.03 210)",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{album.artist}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 800,
|
||||
color: "oklch(40% 0.17 340)",
|
||||
marginTop: 6,
|
||||
}}
|
||||
>
|
||||
{unitLabel(album, album.tracks.length)}
|
||||
{album.figure && " · 🧸"}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{total === 0 && (
|
||||
<div
|
||||
style={{ textAlign: "center", marginTop: 60, color: "oklch(92% 0.02 210 / .8)" }}
|
||||
>
|
||||
<img
|
||||
src="/dolphin-mascot.png"
|
||||
alt=""
|
||||
style={{ width: 120, height: 120, objectFit: "contain", opacity: 0.9 }}
|
||||
/>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, marginTop: 10 }}>
|
||||
Nichts gefunden — probier andere Buchstaben!
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionTitle({
|
||||
children,
|
||||
centered,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
centered?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 19,
|
||||
fontWeight: 900,
|
||||
color: "var(--paper)",
|
||||
marginBottom: 12,
|
||||
textAlign: centered ? "center" : "left",
|
||||
display: centered ? "block" : "flex",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Muted({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: "oklch(88% 0.02 210 / .7)" }}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user