Web frontend

This commit is contained in:
2026-08-27 12:32:20 +02:00
parent d44c24ec97
commit edb6e5e027
97 changed files with 9535 additions and 195 deletions

View 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>
);
}