Files
muzick/frontend/src/components/TopBar.tsx
T
kami bfe22745bc feat(discovery): acquire recommendations that keep their names
Acquisition ran yt-dlp without --embed-metadata, so every download
arrived untagged. The scanner then stored the video id as the title and
"Unknown Artist" as the artist, the vetted-candidate tag check rejected
the mismatch, and all 18 acquired tracks were hidden and retired.

- Pass --embed-metadata so downloads carry real tags.
- Let a scan take fallback title/artist from the candidate, for sources
  that still ship untagged files.
- Install Deno alongside yt-dlp: YouTube guards some formats with a JS
  challenge yt-dlp must execute, and no other runtime is enabled.
- Dedupe candidates by artist and title. The (source, external_id) key
  misses the same song reaching us under two Deezer release ids.

Also carries the in-flight discovery work this builds on: the
Recommendations page replacing Discover, the discovery source service,
and the acquisition spec tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 18:43:58 +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',
'/recommendations': 'Found',
'/vibe': 'Vibe',
'/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>
);
}