Files
muzick/frontend/src/components/TopBar.tsx
T
kami 93619824d8
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled
feat(ui): rework every page for comfort, and make a seeded Vibe play its seed
A pass over the whole app against the Ethos laws, then a focused pass on
Vibe with the operator reviewing each change.

Across the app:
- The player bar restores the last track it played, paused at zero, so a
  fresh tab opens on where the listener was instead of "nothing playing".
- Track titles link to their album, matching the artist links beside them.
  Playback stays on the artwork tile; a title that played was the surprise.
- The search field is bg-bg2. Tailwind cannot alpha-modify these var()
  colors, so bg-surface0/70 emitted no rule at all and the input fell back
  to the UA's white.
- Row hover is light falling off to the right, not a flat slab.
- The artwork placeholder can drop its note glyph, so TrackRow no longer
  layers a play icon on top of one.

Vibe:
- A seeded Vibe plays its seed first. The seed sits in front of the durable
  plan without being part of it, so the first advance consumes it locally
  and reports no plan feedback.
- The queue drops a second recording of a song it already holds — same
  title, different track id, which id-based dedup let through.
- Up next is read from the queue rather than the plan preview, since the
  seed is not a plan item.
- The header carries the live profile (energy, discovery, goal) and both
  verbs. Keep is gone: letting a track finish already reports `completed`,
  which the director weighs the same.
- The aura is one warm diffuse blob in the page background, warm-hued only
  and quieter on mobile.
- Compact artwork is 32px. It was h-8 w-8, which this remapped spacing
  scale renders as 64px inside a 44px row, and that overflow was the
  "stacked" look.

Verified by render at 1440x900 and 390x844, no horizontal overflow at
either. 26 frontend and 122 backend tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 23:53:46 +04:00

209 lines
7.1 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { Search, X, Command, ChevronRight, Wifi, WifiOff, Menu } from 'lucide-react';
import { useNavigate, useRouterState } from '@tanstack/react-router';
import { useQuery } from '@tanstack/react-query';
import { fetchHealthStatus } from '../services/healthService';
interface TopBarProps {
onToggleCommandPalette: () => void;
onToggleNavigation: () => void;
navigationOpen: boolean;
}
/** Page title map for breadcrumbs */
const PAGE_TITLES: Record<string, string> = {
'/': 'Home',
'/tracks': 'Songs',
'/albums': 'Albums',
'/artists': 'Artists',
'/genres': 'Genres',
'/vibe': 'Vibe',
'/discover': 'Discover',
'/search': 'Search',
'/settings': 'Settings',
'/quarantine': 'Quarantine',
'/jobs': 'Jobs',
};
function Breadcrumbs({ pathname }: { pathname: string }) {
// Handle detail pages
const segments = pathname.split('/').filter(Boolean);
if (segments.length <= 1) {
const title = PAGE_TITLES[pathname] ?? 'Muzick';
return (
<span className="text-sm font-medium text-text truncate">{title}</span>
);
}
// For /albums/$id or /artists/$id
const parentPath = `/${segments[0]}`;
const parentTitle = PAGE_TITLES[parentPath] ?? segments[0];
return (
<div className="flex items-center gap-1.5 text-sm min-w-0">
<span className="text-secondary truncate">{parentTitle}</span>
<ChevronRight size={12} className="text-muted flex-none" />
<span className="text-text font-medium truncate">Details</span>
</div>
);
}
function ConnectionStatus() {
const { data, isError } = useQuery({
queryKey: ['health'],
queryFn: () => fetchHealthStatus(),
refetchInterval: 30_000,
staleTime: 10_000,
retry: 1,
});
const healthy = data?.postgres === 'ok';
return (
<div
className={`flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] font-medium ${
healthy
? 'text-green bg-green/10'
: isError
? 'text-red bg-red/10'
: 'text-muted bg-surface0'
}`}
title={
healthy
? 'All systems healthy'
: isError
? 'Backend unreachable'
: 'Checking…'
}
>
{healthy ? (
<Wifi size={12} className="text-green" />
) : (
<WifiOff size={12} className="text-red" />
)}
<span className="hidden sm:inline">
{healthy ? 'Connected' : isError ? 'Offline' : '…'}
</span>
</div>
);
}
export function TopBar({ onToggleCommandPalette, onToggleNavigation, navigationOpen }: TopBarProps) {
const navigate = useNavigate();
const inputRef = useRef<HTMLInputElement>(null);
const { pathname, urlQuery } = useRouterState({
select: (s) => ({
pathname: s.location.pathname,
urlQuery: ((s.location.search as Record<string, unknown>)?.q as string | undefined) ?? '',
}),
});
const onSearchPage = pathname === '/search';
const [q, setQ] = useState(urlQuery);
useEffect(() => {
if (onSearchPage) setQ(urlQuery);
}, [urlQuery, onSearchPage]);
// Debounced URL push on search page
useEffect(() => {
if (!onSearchPage) return;
const id = setTimeout(() => {
const next = q.trim();
if (next !== urlQuery) {
void navigate({ to: '/search', search: { q: next || undefined } as any, replace: true });
}
}, 250);
return () => clearTimeout(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q, onSearchPage]);
// Global "/" shortcut: focus search
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== '/' || e.metaKey || e.ctrlKey || e.altKey) return;
const el = document.activeElement;
const tag = el?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || (el as HTMLElement)?.isContentEditable) return;
e.preventDefault();
inputRef.current?.focus();
inputRef.current?.select();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (q.trim()) void navigate({ to: '/search', search: { q: q.trim() } as any });
};
return (
<header className="glass h-12 border-b border-border flex items-center px-2.5 gap-2 sm:px-3 sm:gap-3 shrink-0 z-20">
<button
type="button"
onClick={onToggleNavigation}
className="rounded-md p-2 text-muted hover:bg-surface0 hover:text-text lg:hidden"
aria-label={navigationOpen ? 'Close navigation' : 'Open navigation'}
aria-expanded={navigationOpen}
>
{navigationOpen ? <X size={18} /> : <Menu size={18} />}
</button>
{/* Breadcrumbs */}
<div className="hidden sm:flex items-center min-w-0 flex-none max-w-[200px]">
<Breadcrumbs pathname={pathname} />
</div>
{/* Universal search */}
<form onSubmit={handleSubmit} className="min-w-0 flex-1 max-w-md">
<div className="relative group">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted pointer-events-none transition-colors group-focus-within:text-accent" />
<input
ref={inputRef}
type="text"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search…"
/* bg-bg2, not bg-surface0/70: Tailwind cannot alpha-modify these
var() colors, so that class emitted nothing and the field fell
back to the UA's white — a white pill in a warm dark room. */
className="w-full bg-bg2 border border-border rounded-md pl-8 pr-8 py-1.5 text-xs text-text placeholder:text-muted outline-none focus:border-accent focus:bg-surface1 transition-colors"
/>
{q ? (
<button
type="button"
onClick={() => setQ('')}
aria-label="Clear search"
className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-0.5 text-muted hover:text-text hover:bg-surface1 transition-colors"
>
<X size={12} />
</button>
) : (
<kbd className="absolute right-2 top-1/2 -translate-y-1/2 hidden sm:flex items-center rounded border border-border bg-surface1 px-1 py-0.5 text-[10px] font-medium text-muted pointer-events-none">
/
</kbd>
)}
</div>
</form>
{/* Right section */}
<div className="flex items-center gap-1.5 sm:gap-2 flex-none">
{/* Connection status */}
<ConnectionStatus />
{/* Command palette toggle */}
<button
onClick={onToggleCommandPalette}
className="flex items-center gap-1.5 rounded-md border border-border bg-surface0 px-2 py-1 text-[11px] font-medium text-muted hover:text-text hover:bg-surface1 transition-colors"
title="Command palette (Ctrl+K)"
>
<Command size={12} />
<span className="hidden sm:inline">Commands</span>
<kbd className="hidden md:inline rounded border border-border bg-bg2 px-1 text-[10px] text-muted">
Ctrl+K
</kbd>
</button>
</div>
</header>
);
}