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>
180 lines
5.9 KiB
TypeScript
180 lines
5.9 KiB
TypeScript
import { Link } from '@tanstack/react-router';
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import type { LucideIcon } from 'lucide-react';
|
|
import {
|
|
Home, Music, Disc3, Users, Tag, Compass,
|
|
Terminal, ShieldAlert,
|
|
Zap,
|
|
Settings, Sparkles, X,
|
|
} from 'lucide-react';
|
|
|
|
interface NavItem {
|
|
to: string;
|
|
icon: LucideIcon;
|
|
label: string;
|
|
exact?: boolean;
|
|
}
|
|
|
|
interface NavGroup {
|
|
label: string;
|
|
items: NavItem[];
|
|
}
|
|
|
|
const NAV_GROUPS: NavGroup[] = [
|
|
{
|
|
label: 'Workspace',
|
|
items: [
|
|
{ to: '/', icon: Home, label: 'Home', exact: true },
|
|
{ to: '/tracks', icon: Music, label: 'Songs' },
|
|
{ to: '/albums', icon: Disc3, label: 'Albums' },
|
|
{ to: '/artists', icon: Users, label: 'Artists' },
|
|
{ to: '/genres', icon: Tag, label: 'Genres' },
|
|
],
|
|
},
|
|
{
|
|
label: 'AI',
|
|
items: [
|
|
{ to: '/vibe', icon: Zap, label: 'Vibe' },
|
|
{ to: '/recommendations', icon: Compass, label: 'Found' },
|
|
],
|
|
},
|
|
{
|
|
label: 'Infrastructure',
|
|
items: [
|
|
{ to: '/jobs', icon: Terminal, label: 'Jobs' },
|
|
{ to: '/quarantine', icon: ShieldAlert, label: 'Quarantine' },
|
|
],
|
|
},
|
|
{
|
|
label: 'Settings',
|
|
items: [
|
|
{ to: '/settings', icon: Settings, label: 'Settings' },
|
|
],
|
|
},
|
|
];
|
|
|
|
const base =
|
|
'group relative flex items-center gap-2.5 px-3 py-1.5 rounded-md text-xs w-full transition-all duration-100';
|
|
const inactive = 'text-secondary hover:bg-surface0 hover:text-text';
|
|
const active =
|
|
'bg-accent/10 text-accent font-medium ' +
|
|
"before:content-[''] before:absolute before:left-0 before:top-1 before:bottom-1 before:w-0.5 before:rounded-full before:bg-accent";
|
|
|
|
interface NavRailProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function NavRail({ open, onClose }: NavRailProps) {
|
|
const [desktop, setDesktop] = useState(false);
|
|
const drawerRef = useRef<HTMLElement>(null);
|
|
const closeRef = useRef<HTMLButtonElement>(null);
|
|
useEffect(() => {
|
|
const query = window.matchMedia('(min-width: 1024px)');
|
|
const update = () => setDesktop(query.matches);
|
|
update();
|
|
query.addEventListener('change', update);
|
|
return () => query.removeEventListener('change', update);
|
|
}, []);
|
|
useEffect(() => {
|
|
if (open && !desktop) closeRef.current?.focus();
|
|
}, [open, desktop]);
|
|
useEffect(() => {
|
|
if (!open || desktop || !drawerRef.current) return;
|
|
const previous = document.activeElement as HTMLElement | null;
|
|
const trap = (event: KeyboardEvent) => {
|
|
if (event.key !== 'Tab' || !drawerRef.current) return;
|
|
const focusable = [...drawerRef.current.querySelectorAll<HTMLElement>(
|
|
'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
)].filter((element) => !element.hasAttribute('hidden'));
|
|
if (focusable.length === 0) return;
|
|
const first = focusable[0];
|
|
const last = focusable[focusable.length - 1];
|
|
if (event.shiftKey && document.activeElement === first) {
|
|
event.preventDefault(); last.focus();
|
|
} else if (!event.shiftKey && document.activeElement === last) {
|
|
event.preventDefault(); first.focus();
|
|
}
|
|
};
|
|
document.addEventListener('keydown', trap);
|
|
return () => {
|
|
document.removeEventListener('keydown', trap);
|
|
previous?.focus();
|
|
};
|
|
}, [open, desktop]);
|
|
|
|
// Do not leave an off-screen mobile drawer in the tab order. The desktop
|
|
// rail remains mounted independently of the drawer state.
|
|
if (!desktop && !open) return null;
|
|
return (
|
|
<>
|
|
{open && (
|
|
<button
|
|
type="button"
|
|
className="absolute inset-0 z-30 bg-black/60 lg:hidden"
|
|
aria-label="Close navigation"
|
|
onClick={onClose}
|
|
/>
|
|
)}
|
|
<aside
|
|
ref={drawerRef}
|
|
aria-label="Main navigation"
|
|
aria-modal={!desktop || undefined}
|
|
role={desktop ? undefined : 'dialog'}
|
|
className={`absolute inset-y-0 left-0 z-40 flex w-72 max-w-[85vw] flex-col overflow-y-auto border-r border-border bg-bg1 shadow-2xl transition-transform duration-200 lg:relative lg:z-auto lg:w-48 lg:max-w-none lg:translate-x-0 lg:shadow-none ${
|
|
open ? 'translate-x-0' : '-translate-x-full'
|
|
}`}
|
|
>
|
|
{/* App branding */}
|
|
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-border">
|
|
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-accent text-on-accent">
|
|
<Sparkles size={14} />
|
|
</span>
|
|
<span className="text-sm font-semibold text-text tracking-tight">muzick</span>
|
|
<button
|
|
ref={closeRef}
|
|
type="button"
|
|
onClick={onClose}
|
|
className="ml-auto rounded-md p-2 text-muted hover:bg-surface0 hover:text-text lg:hidden"
|
|
aria-label="Close navigation"
|
|
>
|
|
<X size={18} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Navigation */}
|
|
<nav className="flex-1 px-2 space-y-4 pb-3 pt-3">
|
|
{NAV_GROUPS.map((group) => (
|
|
<div key={group.label}>
|
|
<div className="px-3 mb-1 text-[10px] font-semibold uppercase tracking-[0.1em] text-disabled">
|
|
{group.label}
|
|
</div>
|
|
<ul className="space-y-0.5">
|
|
{group.items.map(({ to, icon: Icon, label, exact }) => (
|
|
<li key={to}>
|
|
<Link
|
|
to={to}
|
|
activeOptions={{ exact: exact ?? false }}
|
|
activeProps={{ className: `${base} ${active}` }}
|
|
inactiveProps={{ className: `${base} ${inactive}` }}
|
|
onClick={onClose}
|
|
>
|
|
<Icon size={15} className="flex-none transition-transform group-hover:scale-110" />
|
|
<span className="truncate">{label}</span>
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
))}
|
|
</nav>
|
|
|
|
{/* Footer */}
|
|
<div className="px-3 py-2 text-[10px] text-disabled border-t border-border">
|
|
muzick · v0.1
|
|
</div>
|
|
</aside>
|
|
</>
|
|
);
|
|
}
|