UI cleanup, visual and keyboard navigation
This commit is contained in:
@@ -7,7 +7,7 @@ interface Props {
|
||||
status?: ReactNode;
|
||||
}
|
||||
|
||||
export function AppHeader({ title = "Musik Delphin", mascot = "/dolphin-mascot.png", status }: Props) {
|
||||
export function AppHeader({ title = "Musik Delfin", mascot = "/dolphin-mascot.png", status }: Props) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -19,6 +19,16 @@ import {
|
||||
import { clock } from "../lib/format";
|
||||
import type { Category, Group, Results, SongHit } from "../lib/search";
|
||||
import { categoryMatches, groupOf } from "../lib/search";
|
||||
import {
|
||||
glassTint,
|
||||
GROUP_HUE,
|
||||
GROUP_ICON,
|
||||
GROUP_LABEL,
|
||||
ROW_SPACING,
|
||||
rowTint,
|
||||
SHOW_ROW_ICONS,
|
||||
SHOW_ROW_TITLES,
|
||||
} from "../lib/theme";
|
||||
import { Cover } from "./Cover";
|
||||
|
||||
interface Props {
|
||||
@@ -29,6 +39,9 @@ interface Props {
|
||||
search: string;
|
||||
category: string | null;
|
||||
selIndex: number;
|
||||
/** Which of the three root shelves is focused - only meaningful at the bare root,
|
||||
* where `selIndex` means "which tile in that row" instead of a flat position. */
|
||||
shelfRow: number;
|
||||
currentAlbumId: string | null;
|
||||
gridRef: (element: HTMLElement | null) => void;
|
||||
onEnterGroup: (group: Group, category: string | null) => void;
|
||||
@@ -43,12 +56,6 @@ interface Props {
|
||||
|
||||
const GROUPS: Group[] = ["music", "audiobooks", "podcasts"];
|
||||
|
||||
const GROUP_TITLE: Record<Group, string> = {
|
||||
music: "🎵 Musik",
|
||||
audiobooks: "📖 Hörbücher",
|
||||
podcasts: "🎙️ Podcasts",
|
||||
};
|
||||
|
||||
/** Heading over the category tiles inside a group. */
|
||||
const GROUP_CATEGORY_LABEL: Record<Group, string> = {
|
||||
music: "Künstler",
|
||||
@@ -71,6 +78,7 @@ export function BrowseView({
|
||||
search,
|
||||
category,
|
||||
selIndex,
|
||||
shelfRow,
|
||||
currentAlbumId,
|
||||
gridRef,
|
||||
onEnterGroup,
|
||||
@@ -81,10 +89,23 @@ export function BrowseView({
|
||||
}: Props) {
|
||||
const scroller = useRef<HTMLDivElement | null>(null);
|
||||
const { songs, categories, albums: albumResults, total } = results;
|
||||
const selected = total ? Math.min(selIndex, total - 1) : -1;
|
||||
|
||||
const isRootShelf = group === null && mode !== "tracks" && !search;
|
||||
|
||||
// The root shelf has no flat `results` of its own (each row computes its own
|
||||
// categories independently) - it's a genuine two-axis layout instead: `shelfRow`
|
||||
// is which of the three rows is focused, `selIndex` which tile within that row,
|
||||
// mirroring keyboard.ts's `moveRootShelf`. `selected` here only drives the outer
|
||||
// scroller's keep-the-focused-row-on-screen effect below, so at the root it only
|
||||
// needs the row half of that.
|
||||
const focusedRow = Math.max(0, Math.min(GROUPS.length - 1, shelfRow));
|
||||
const focusedCol = Math.max(0, selIndex);
|
||||
const selected = isRootShelf
|
||||
? focusedRow
|
||||
: total
|
||||
? Math.min(selIndex, total - 1)
|
||||
: -1;
|
||||
|
||||
const shelves = useMemo(
|
||||
() =>
|
||||
GROUPS.map((shelfGroup) => ({
|
||||
@@ -117,28 +138,52 @@ export function BrowseView({
|
||||
if (isRootShelf) {
|
||||
return (
|
||||
<div ref={scroller} style={{ flex: 1, overflow: "auto", minHeight: 0, padding: "14px 32px 250px" }}>
|
||||
{shelves.map(({ group: shelfGroup, categories: shelfCategories }) => (
|
||||
<div key={shelfGroup} style={{ marginBottom: 34 }}>
|
||||
<button
|
||||
onClick={() => onEnterGroup(shelfGroup, null)}
|
||||
aria-label={`Alle ${GROUP_TITLE[shelfGroup]} durchsuchen`}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
marginBottom: 12,
|
||||
border: "none",
|
||||
background: "none",
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
fontSize: 20,
|
||||
fontWeight: 900,
|
||||
color: "var(--paper)",
|
||||
}}
|
||||
>
|
||||
{GROUP_TITLE[shelfGroup]}
|
||||
<span style={{ opacity: 0.6, fontSize: 17 }}>›</span>
|
||||
</button>
|
||||
{shelves.map(({ group: shelfGroup, categories: shelfCategories }, index) => (
|
||||
<div
|
||||
key={shelfGroup}
|
||||
className="glass-panel"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-nav-index={index}
|
||||
onClick={() => onEnterGroup(shelfGroup, null)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
onEnterGroup(shelfGroup, null);
|
||||
}}
|
||||
aria-label={`Alle ${GROUP_ICON[shelfGroup]} ${GROUP_LABEL[shelfGroup]} durchsuchen`}
|
||||
style={{
|
||||
marginBottom: ROW_SPACING,
|
||||
padding: "12px 14px",
|
||||
cursor: "pointer",
|
||||
// Once a row has tiles, the focus ring belongs on the tile the keyboard
|
||||
// is actually on (below) rather than the row around it - this only
|
||||
// stands in for that when there's nothing to focus.
|
||||
outline:
|
||||
focusedRow === index && shelfCategories.length === 0
|
||||
? "4px solid var(--paper)"
|
||||
: undefined,
|
||||
outlineOffset: 2,
|
||||
...glassTint(GROUP_HUE[shelfGroup]),
|
||||
}}
|
||||
>
|
||||
{(SHOW_ROW_TITLES || SHOW_ROW_ICONS) && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: SHOW_ROW_TITLES && SHOW_ROW_ICONS ? 10 : 6,
|
||||
marginBottom: 12,
|
||||
fontSize: 20,
|
||||
fontWeight: 900,
|
||||
color: "var(--paper)",
|
||||
}}
|
||||
>
|
||||
{SHOW_ROW_ICONS && <span className="group-icon">{GROUP_ICON[shelfGroup]}</span>}
|
||||
{SHOW_ROW_TITLES && GROUP_LABEL[shelfGroup]}
|
||||
{SHOW_ROW_TITLES && <span style={{ opacity: 0.6, fontSize: 17 }}>›</span>}
|
||||
</div>
|
||||
)}
|
||||
{shelfCategories.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
@@ -151,12 +196,19 @@ export function BrowseView({
|
||||
Noch nichts hier
|
||||
</div>
|
||||
) : (
|
||||
<ShelfRow>
|
||||
{shelfCategories.map((entry) => (
|
||||
<ShelfRow focusedIndex={focusedRow === index ? focusedCol : null}>
|
||||
{shelfCategories.map((entry, tileIndex) => (
|
||||
<CategoryTile
|
||||
key={entry.key}
|
||||
entry={entry}
|
||||
onClick={() => onEnterGroup(shelfGroup, entry.key)}
|
||||
navIndex={tileIndex}
|
||||
selected={focusedRow === index && focusedCol === tileIndex}
|
||||
onClick={(event) => {
|
||||
// Otherwise this bubbles to the panel's own onClick, which would
|
||||
// open the group root instead of the category just clicked.
|
||||
event.stopPropagation();
|
||||
onEnterGroup(shelfGroup, entry.key);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ShelfRow>
|
||||
@@ -183,12 +235,12 @@ export function BrowseView({
|
||||
textAlign: "center",
|
||||
padding: "4px 32px 6px",
|
||||
flex: "none",
|
||||
fontSize: 18,
|
||||
fontWeight: 900,
|
||||
fontSize: 15,
|
||||
fontWeight: 800,
|
||||
color: "var(--paper)",
|
||||
}}
|
||||
>
|
||||
{GROUP_TITLE[group]}
|
||||
{GROUP_LABEL[group]}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -250,7 +302,7 @@ export function BrowseView({
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
gap: 6,
|
||||
maxWidth: 760,
|
||||
margin: "0 auto",
|
||||
}}
|
||||
@@ -260,19 +312,17 @@ export function BrowseView({
|
||||
key={`${hit.album.id}-${hit.index}`}
|
||||
data-nav-index={index}
|
||||
onClick={() => onPlaySong(hit)}
|
||||
className="list-row"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
padding: "10px 16px",
|
||||
borderRadius: 14,
|
||||
padding: "8px 16px",
|
||||
borderRadius: 12,
|
||||
cursor: "pointer",
|
||||
border: "none",
|
||||
textAlign: "left",
|
||||
font: "inherit",
|
||||
background: `oklch(97% 0.01 210 / ${
|
||||
currentAlbumId === hit.album.id ? ".22" : ".10"
|
||||
})`,
|
||||
...rowTint(GROUP_HUE[groupOf(hit.album)], currentAlbumId === hit.album.id),
|
||||
outline:
|
||||
selected === index ? "4px solid var(--paper)" : undefined,
|
||||
outlineOffset: 2,
|
||||
@@ -485,9 +535,32 @@ export function BrowseView({
|
||||
* still scroll the page. Drag-to-scroll is plain mouse events on `window` rather than
|
||||
* pointer capture, so a plain click still reaches the tile underneath - capturing the
|
||||
* pointer on the row would retarget even a non-dragging click's events to the row. */
|
||||
function ShelfRow({ children }: { children: React.ReactNode }) {
|
||||
function ShelfRow({
|
||||
children,
|
||||
focusedIndex,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
/** Which tile in this row the keyboard is on, or `null` while some other row has
|
||||
* focus - keeps a Ctrl+h/l selection that's scrolled off the edge on screen, the
|
||||
* horizontal equivalent of the outer scroller's own keep-in-view effect above. */
|
||||
focusedIndex: number | null;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element || focusedIndex === null) return;
|
||||
const tile = element.querySelector<HTMLElement>(`[data-nav-index="${focusedIndex}"]`);
|
||||
if (!tile) return;
|
||||
const left = tile.offsetLeft;
|
||||
const right = left + tile.offsetWidth;
|
||||
const pad = 24;
|
||||
if (left - pad < element.scrollLeft) element.scrollLeft = Math.max(0, left - pad);
|
||||
else if (right + pad > element.scrollLeft + element.clientWidth) {
|
||||
element.scrollLeft = right + pad - element.clientWidth;
|
||||
}
|
||||
}, [focusedIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
@@ -552,7 +625,7 @@ function CategoryTile({
|
||||
entry: Category;
|
||||
navIndex?: number;
|
||||
selected?: boolean;
|
||||
onClick: () => void;
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
}) {
|
||||
const books = entry.albums.filter(isBook).length;
|
||||
const allBooks = books === entry.albums.length;
|
||||
@@ -565,7 +638,9 @@ function CategoryTile({
|
||||
data-selected={selected}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
background: allBooks ? "oklch(93% 0.055 88 / .7)" : "oklch(95% 0.015 210 / .66)",
|
||||
background: allBooks
|
||||
? `oklch(92% 0.09 ${GROUP_HUE.audiobooks} / .7)`
|
||||
: `oklch(95% 0.015 ${GROUP_HUE.music} / .66)`,
|
||||
borderRadius: allBooks ? "6px 18px 18px 6px" : "16px",
|
||||
boxShadow: "0 6px 18px var(--shadow)",
|
||||
}}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { remoteSlotView } from "../lib/remote";
|
||||
import { Cover } from "./Cover";
|
||||
|
||||
const DIGITS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"];
|
||||
const ZERO_COLUMN = 2;
|
||||
|
||||
interface Props {
|
||||
album: Album;
|
||||
@@ -44,14 +45,20 @@ export function RemoteAssignPopup({ album, mapping, albums, onAssign, onClear, o
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(5, 1fr)",
|
||||
gridTemplateColumns: "repeat(3, 1fr)",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{DIGITS.map((digit) => {
|
||||
const view = remoteSlotView(byDigit.get(digit), albums);
|
||||
return (
|
||||
<div key={digit} style={{ position: "relative" }}>
|
||||
<div
|
||||
key={digit}
|
||||
style={{
|
||||
position: "relative",
|
||||
...(digit === "0" ? { gridColumn: ZERO_COLUMN } : {}),
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => onAssign(digit)}
|
||||
title={
|
||||
|
||||
Reference in New Issue
Block a user