initial state: muzick music player + recommendation engine
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Search, X, Command, ChevronRight, Wifi, WifiOff } 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;
|
||||
}
|
||||
|
||||
/** 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 }: 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-3 gap-3 shrink-0 z-20">
|
||||
{/* Breadcrumbs */}
|
||||
<div className="flex items-center min-w-0 flex-none max-w-[200px]">
|
||||
<Breadcrumbs pathname={pathname} />
|
||||
</div>
|
||||
|
||||
{/* Universal search */}
|
||||
<form onSubmit={handleSubmit} className="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…"
|
||||
className="w-full bg-surface0/70 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-surface0 transition-all"
|
||||
/>
|
||||
{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-bg2 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-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="rounded border border-border bg-bg2 px-1 text-[10px] text-muted">
|
||||
Ctrl+K
|
||||
</kbd>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user