bfe22745bc
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>
209 lines
7.1 KiB
TypeScript
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>
|
|
);
|
|
}
|