initial state: muzick music player + recommendation engine

This commit is contained in:
kami
2026-07-14 01:35:52 +04:00
commit 737bf19fd1
196 changed files with 32431 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
import { useState, useCallback } from 'react';
import { Outlet } from '@tanstack/react-router';
import { AudioEngine } from './AudioEngine';
import { NavRail } from './NavRail';
import { TopBar } from './TopBar';
import { PlaybackBar } from './PlaybackBar';
import { NowPlayingPanel } from './NowPlayingPanel';
import { LyricsOverlay } from './LyricsOverlay';
import { Toaster } from './Toaster';
import { CommandPalette } from './CommandPalette';
import { KeyboardListener, useKeyboard } from '../hooks/useKeyboard';
import { Inspector, type InspectorMode } from './Inspector';
export default function AppShell() {
const [queueOpen, setQueueOpen] = useState(false);
const [lyricsOpen, setLyricsOpen] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
const [inspector, setInspector] = useState<{ mode: InspectorMode; id: string } | null>(null);
const togglePalette = useCallback(() => setPaletteOpen((p) => !p), []);
const closeInspector = useCallback(() => setInspector(null), []);
// Ctrl+K — command palette (uses `code` so it works on any keyboard layout)
useKeyboard({
code: 'KeyK',
ctrl: true,
handler: () => setPaletteOpen((p) => !p),
});
// Alt+← / Alt+→ — back / forward (uses `code` for layout independence)
useKeyboard({
code: 'ArrowLeft',
alt: true,
handler: () => window.history.back(),
});
useKeyboard({
code: 'ArrowRight',
alt: true,
handler: () => window.history.forward(),
});
// Esc — closes inspector, palette, etc.
useKeyboard({
code: 'Escape',
handler: () => {
if (inspector) closeInspector();
},
});
return (
<div className="flex flex-col h-screen bg-bg0 text-text overflow-hidden">
<KeyboardListener />
<TopBar onToggleCommandPalette={togglePalette} />
<div className="relative flex flex-1 overflow-hidden">
<NavRail />
<main className="flex-1 overflow-y-auto p-4 pb-8">
<Outlet />
</main>
{inspector && <Inspector mode={inspector.mode} id={inspector.id} onClose={closeInspector} />}
{queueOpen && <NowPlayingPanel onClose={() => setQueueOpen(false)} />}
{lyricsOpen && <LyricsOverlay onClose={() => setLyricsOpen(false)} />}
</div>
<PlaybackBar
queueOpen={queueOpen}
lyricsOpen={lyricsOpen}
onToggleQueue={() => setQueueOpen((o) => !o)}
onToggleLyrics={() => setLyricsOpen((o) => !o)}
/>
<AudioEngine />
<Toaster />
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} />
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { Link } from '@tanstack/react-router';
import type { TrackArtist } from '../types';
interface ArtistLinksProps {
/** Ordered artists (first = main, rest = featured). */
artists?: TrackArtist[] | null;
/** Shown when there are no structured artists (plain text, not a link). */
fallback?: string;
className?: string;
/** Stop row/card click handlers from firing when an artist link is clicked. */
stopPropagation?: boolean;
}
/**
* Deduplicates artists by ID, preferring `main` over `featured` when the
* same artist has both roles (can happen because track_artists has a composite
* PK of track_id + artist_id + role).
*/
function deduplicateArtists(artists: TrackArtist[]): TrackArtist[] {
const map = new Map<string, TrackArtist>();
for (const a of artists) {
const existing = map.get(a.id);
if (!existing || (existing.role === 'featured' && a.role === 'main')) {
map.set(a.id, a);
}
}
// Preserve original order, skipping duplicates.
const seen = new Set<string>();
return artists.filter((a) => {
if (seen.has(a.id)) return false;
seen.add(a.id);
return true;
});
}
/**
* Renders a track/album's artists as clickable links — main artist(s) then
* "feat." guests. Single source of truth used by TrackRow, the playback bar,
* the now-playing panel and album pages so artist navigation looks and behaves
* the same everywhere.
*
* Artists are deduplicated by id — if the same artist appears as both main
* and featured, only the main entry is shown.
*/
export function ArtistLinks({ artists, fallback, className = '', stopPropagation }: ArtistLinksProps) {
if (!artists || artists.length === 0) {
return <span className={className}>{fallback || 'Unknown artist'}</span>;
}
const unique = deduplicateArtists(artists);
return (
<span className={className}>
{unique.map((a, i) => (
<span key={a.id}>
{i > 0 && <span className="opacity-60">{a.role === 'featured' && unique[i - 1].role !== 'featured' ? ' feat. ' : ', '}</span>}
<Link
to="/artists/$artistId"
params={{ artistId: a.id }}
onClick={stopPropagation ? (e) => e.stopPropagation() : undefined}
className={`hover:text-text hover:underline ${a.role === 'featured' ? 'opacity-75' : ''}`}
>
{a.name}
</Link>
</span>
))}
</span>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { useState } from 'react';
import { Music } from 'lucide-react';
import { hueFromString } from '../lib/color';
interface ArtworkProps {
seed: string;
src?: string | null;
className?: string;
rounded?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
/** Skip native lazy-loading — set true for above-the-fold artwork (e.g. PlaybackBar). */
eager?: boolean;
}
const API_BASE = import.meta.env.VITE_API_URL || '/api';
/**
* Rewrite external image URLs through the backend proxy so the browser gets
* cache headers (1 year, immutable) and avoids per-domain connection limits
* to Discogs / Cover Art Archive etc.
*/
function proxySrc(src: string): string {
if (src.startsWith('http://') || src.startsWith('https://')) {
return `${API_BASE}/images/proxy?url=${encodeURIComponent(src)}`;
}
return src;
}
export function Artwork({ seed, src, className = '', rounded = 'md', eager = false }: ArtworkProps) {
const hue = hueFromString(seed);
// Symmetric top sheen over a diagonal base — the highlight is centered
// horizontally so it reads as even behind the centered note glyph.
const gradient =
`radial-gradient(110% 90% at 50% 0%, hsl(${hue},55%,30%) 0%, transparent 60%), ` +
`linear-gradient(160deg, hsl(${hue},48%,23%), hsl(${(hue + 55) % 360},40%,11%))`;
const r = { sm: 'rounded-sm', md: 'rounded-md', lg: 'rounded-lg', xl: 'rounded-xl', full: 'rounded-full' }[rounded];
// If an image source is supplied we render it lazily over the gradient
// fallback, so a slow or broken cover never produces a blank rectangle:
// the gradient (with the music glyph) is painted underneath and only
// swapped out once the <img> fires its onLoad. A 404 falls back too.
const [loaded, setLoaded] = useState(false);
const [errored, setErrored] = useState(false);
if (src && !errored) {
const proxied = proxySrc(src);
return (
<div className={`relative overflow-hidden ${r} ${className}`} style={{ background: gradient }}>
{!loaded && <Music className="absolute inset-0 m-auto h-2/5 w-2/5 text-on-accent/20" strokeWidth={1.5} aria-hidden />}
<img
src={proxied}
alt={seed}
loading={eager ? 'eager' : 'lazy'}
decoding="async"
onLoad={() => setLoaded(true)}
onError={() => setErrored(true)}
className={`h-full w-full object-cover transition-opacity duration-300 ${loaded ? 'opacity-100' : 'opacity-0'}`}
/>
</div>
);
}
return (
<div className={`relative flex items-center justify-center overflow-hidden ${r} ${className}`} style={{ background: gradient }}>
<Music className="h-2/5 w-2/5 text-on-accent/20" strokeWidth={1.5} aria-hidden />
</div>
);
}
+291
View File
@@ -0,0 +1,291 @@
import { useEffect, useRef } from 'react';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { trackService } from '../services/trackService';
import { vibeService } from '../services/vibeService';
import type { Track } from '../types';
// Threshold (seconds) above which a store position change is treated as a user
// scrub and applied to the audio element. Keeps the timeupdate -> setPosition ->
// effect loop from fighting itself.
const SEEK_THRESHOLD = 1;
// If a track reaches this fraction of its duration, treat it as "effectively
// completed" even if the user clicks Next before the very end.
const COMPLETION_THRESHOLD = 0.95;
// Relative seek increment (seconds) for MediaSession seekforward/seekbackward.
const SEEK_INCREMENT = 10;
/** Build the artwork URLs for MediaSession metadata (OS media controls). */
function buildArtwork(track: Track): MediaImage[] {
const sizes = [96, 128, 192, 256, 384, 512];
const artwork = track.artwork_id;
if (!artwork) return [];
// artwork_id is either a full URL (external) or a relative path served by us.
const url = artwork.startsWith('http')
? artwork
: `${window.location.origin}${artwork}`;
return sizes.map((s) => ({ src: url, sizes: `${s}x${s}`, type: 'image/jpeg' }));
}
// Headless audio engine: one shared <audio> element driven by the playback store.
// State -> DOM via store subscriptions; DOM -> state via media events.
export const AudioEngine = () => {
const audioRef = useRef<HTMLAudioElement>(null);
// Track which id is currently loaded into the element, and whether it ended
// naturally (so we record COMPLETED, not skip, on the resulting track change).
const loadedIdRef = useRef<string | null>(null);
const endedNaturallyRef = useRef(false);
// Track whether the current track has crossed the completion threshold.
const crossedThresholdRef = useRef(false);
// Track whether we've already recorded a completed play for the current track
// (to avoid double-recording when both threshold crossed AND ended fires).
const recordedCompletedRef = useRef(false);
// --- DOM -> store: media events -----------------------------------------
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const store = usePlaybackStore.getState;
const onTimeUpdate = () => {
store().setPosition(audio.currentTime);
// Mark as effectively completed if we cross the threshold.
if (
!crossedThresholdRef.current &&
audio.duration &&
audio.currentTime / audio.duration >= COMPLETION_THRESHOLD
) {
crossedThresholdRef.current = true;
}
};
const onLoadedMetadata = () => {
if (Number.isFinite(audio.duration)) store().setDuration(audio.duration);
};
const onPlay = () => {
if (!store().isPlaying) store().play();
};
const onPause = () => {
// Ignore the pause that fires as part of ending a track.
if (audio.ended) return;
if (store().isPlaying) store().pause();
};
const onEnded = () => {
const trackId = loadedIdRef.current;
if (trackId && !recordedCompletedRef.current) {
endedNaturallyRef.current = true;
recordedCompletedRef.current = true;
try {
void vibeService.feedback(trackId, 'completed').catch(() => {});
} catch {
/* best-effort */
}
}
store().next();
};
audio.addEventListener('timeupdate', onTimeUpdate);
audio.addEventListener('loadedmetadata', onLoadedMetadata);
audio.addEventListener('play', onPlay);
audio.addEventListener('pause', onPause);
audio.addEventListener('ended', onEnded);
return () => {
audio.removeEventListener('timeupdate', onTimeUpdate);
audio.removeEventListener('loadedmetadata', onLoadedMetadata);
audio.removeEventListener('play', onPlay);
audio.removeEventListener('pause', onPause);
audio.removeEventListener('ended', onEnded);
};
}, []);
// --- store -> DOM: react to currentTrack changes ------------------------
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const applyTrack = (id: string | null) => {
if (id === loadedIdRef.current) return;
// The previously loaded track is changing. If it didn't end naturally and
// hadn't crossed the completion threshold, record a skip (best-effort).
// If it crossed the threshold OR ended naturally, record as completed.
const prevId = loadedIdRef.current;
const completed = endedNaturallyRef.current || crossedThresholdRef.current;
if (prevId) {
try {
if (completed) {
recordedCompletedRef.current = true;
void vibeService.feedback(prevId, 'completed').catch(() => {});
} else {
void vibeService.feedback(prevId, 'skipped').catch(() => {});
}
} catch {
/* best-effort */
}
}
endedNaturallyRef.current = false;
crossedThresholdRef.current = false;
recordedCompletedRef.current = false;
loadedIdRef.current = id;
if (!id) {
audio.removeAttribute('src');
audio.load();
return;
}
audio.src = trackService.getStreamUrl(id);
audio.load();
if (usePlaybackStore.getState().isPlaying) {
void audio.play().catch(() => {});
}
};
// Apply the current value immediately, then subscribe to future changes.
applyTrack(usePlaybackStore.getState().currentTrack?.id ?? null);
const unsub = usePlaybackStore.subscribe((state) => {
applyTrack(state.currentTrack?.id ?? null);
});
return unsub;
}, []);
// --- store -> DOM: isPlaying ------------------------------------------------
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const apply = (isPlaying: boolean) => {
if (isPlaying) {
if (audio.paused) void audio.play().catch(() => {});
} else {
if (!audio.paused) audio.pause();
}
};
apply(usePlaybackStore.getState().isPlaying);
return usePlaybackStore.subscribe((state) => apply(state.isPlaying));
}, []);
// --- store -> DOM: volume --------------------------------------------------
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const apply = (volume: number) => {
audio.volume = Math.min(1, Math.max(0, volume));
};
apply(usePlaybackStore.getState().volume);
return usePlaybackStore.subscribe((state) => apply(state.volume));
}, []);
// --- store -> DOM: external seeks (user scrubbing) -------------------------
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const apply = (position: number) => {
if (Math.abs(audio.currentTime - position) > SEEK_THRESHOLD) {
audio.currentTime = position;
}
};
return usePlaybackStore.subscribe((state) => apply(state.position));
}, []);
// --- MediaSession: hardware media keys + OS media controls ------------------
//
// Without this, the browser's default media-key handler toggles the <audio>
// element directly, bypassing the store — causing the UI and audio to
// desync. By registering action handlers we route all media-key input
// through the store, so isPlaying stays consistent. We also publish track
// metadata so the OS "now playing" widget shows title/artist/artwork.
useEffect(() => {
if (!('mediaSession' in navigator)) return;
const store = usePlaybackStore.getState;
const handlers: Partial<Record<MediaSessionAction, (details: MediaSessionActionDetails) => void>> = {
play: () => store().play(),
pause: () => store().pause(),
previoustrack: () => store().prev(),
nexttrack: () => store().next(),
seekbackward: (details) => {
const audio = audioRef.current;
if (!audio) return;
const delta = details.seekOffset ?? SEEK_INCREMENT;
audio.currentTime = Math.max(0, audio.currentTime - delta);
},
seekforward: (details) => {
const audio = audioRef.current;
if (!audio) return;
const delta = details.seekOffset ?? SEEK_INCREMENT;
audio.currentTime = Math.min(audio.duration || 0, audio.currentTime + delta);
},
seekto: (details) => {
const audio = audioRef.current;
if (!audio || details.seekTime == null) return;
audio.currentTime = details.seekTime;
},
stop: () => {
store().pause();
store().setPosition(0);
},
};
for (const [action, handler] of Object.entries(handlers)) {
try {
navigator.mediaSession.setActionHandler(
action as MediaSessionAction,
handler ?? null,
);
} catch {
// Some actions aren't supported on every browser/OS — ignore.
}
}
// Clean up handlers on unmount so they don't outlive the engine.
return () => {
for (const action of Object.keys(handlers)) {
try {
navigator.mediaSession.setActionHandler(
action as MediaSessionAction,
null,
);
} catch {
/* ignore */
}
}
};
}, []);
// --- MediaSession: publish metadata + playback state ------------------------
useEffect(() => {
if (!('mediaSession' in navigator)) return;
const update = (track: Track | null, isPlaying: boolean) => {
if (track) {
navigator.mediaSession.metadata = new MediaMetadata({
title: track.title || 'Unknown',
artist: track.artist || 'Unknown',
album: '',
artwork: buildArtwork(track),
});
}
navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused';
};
// Publish immediately for the current state.
update(usePlaybackStore.getState().currentTrack, usePlaybackStore.getState().isPlaying);
// Subscribe to future changes of currentTrack or isPlaying.
return usePlaybackStore.subscribe((state) => {
update(state.currentTrack, state.isPlaying);
});
}, []);
return <audio ref={audioRef} hidden />;
};
+42
View File
@@ -0,0 +1,42 @@
import { Link } from '@tanstack/react-router';
import { ArrowLeft } from 'lucide-react';
interface BackLinkProps {
/** Fallback destination if there's no browser history to go back to (e.g. deep-linked). */
to: string;
label: string;
}
/**
* Browser-history-aware back affordance. Prefers `history.back()` when the
* router has a previous entry (so a user who deep-linked to an album from
* Search returns to Search, not to the Albums index); falls back to a
* normal `<Link>` for direct entries.
*
* Detail pages used to hard-code `<Link to="/albums">` which broke that
* mental model — this fixes it across AlbumDetail / ArtistDetail / Genres.
*/
export function BackLink({ to, label }: BackLinkProps) {
// window.history.length === 1 means this tab was opened directly to the
// current URL — there's nothing to go back to, so render a real link.
const canGoBack = typeof window !== 'undefined' && window.history.length > 1;
if (canGoBack) {
return (
<button
type="button"
onClick={() => window.history.back()}
className="inline-flex items-center gap-1 text-sm text-muted hover:text-text transition-colors"
>
<ArrowLeft size={16} /> {label}
</button>
);
}
return (
<Link
to={to}
className="inline-flex items-center gap-1 text-sm text-muted hover:text-text transition-colors"
>
<ArrowLeft size={16} /> {label}
</Link>
);
}
+222
View File
@@ -0,0 +1,222 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { useNavigate } from '@tanstack/react-router';
import type { LucideIcon } from 'lucide-react';
import {
Search,
Music,
Disc3,
Users,
Tag,
Zap,
Compass,
Settings,
ShieldAlert,
Terminal,
Home,
ArrowRight,
} from 'lucide-react';
interface CommandItem {
id: string;
label: string;
description?: string;
icon: LucideIcon;
action: () => void;
keywords?: string[];
}
const NAV_COMMANDS: CommandItem[] = [
{ id: 'nav-home', label: 'Home', icon: Home, action: () => {}, keywords: ['dashboard', 'start'] },
{ id: 'nav-tracks', label: 'Songs', description: 'Browse all tracks', icon: Music, action: () => {}, keywords: ['tracks', 'music', 'songs'] },
{ id: 'nav-albums', label: 'Albums', icon: Disc3, action: () => {}, keywords: ['albums', 'records'] },
{ id: 'nav-artists', label: 'Artists', icon: Users, action: () => {}, keywords: ['artists', 'bands'] },
{ id: 'nav-genres', label: 'Genres', icon: Tag, action: () => {}, keywords: ['genres', 'tags', 'categories'] },
{ id: 'nav-vibe', label: 'Vibe', description: 'Endless recommendations', icon: Zap, action: () => {}, keywords: ['vibe', 'recommendations', 'radio'] },
{ id: 'nav-discover', label: 'Discover', description: 'Browse by genre', icon: Compass, action: () => {}, keywords: ['discover', 'explore'] },
{ id: 'nav-quarantine', label: 'Quarantine', icon: ShieldAlert, action: () => {}, keywords: ['quarantine', 'disliked', 'trash'] },
{ id: 'nav-jobs', label: 'Jobs', description: 'Background tasks', icon: Terminal, action: () => {}, keywords: ['jobs', 'tasks', 'queue'] },
{ id: 'nav-settings', label: 'Settings', icon: Settings, action: () => {}, keywords: ['settings', 'preferences', 'config'] },
];
interface CommandPaletteProps {
open: boolean;
onClose: () => void;
}
export function CommandPalette({ open, onClose }: CommandPaletteProps) {
const navigate = useNavigate();
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
// Bind navigation to each command action
const commands = useMemo(
() =>
NAV_COMMANDS.map((cmd) => ({
...cmd,
action: () => {
const pathMap: Record<string, string> = {
'nav-home': '/',
'nav-tracks': '/tracks',
'nav-albums': '/albums',
'nav-artists': '/artists',
'nav-genres': '/genres',
'nav-vibe': '/vibe',
'nav-discover': '/discover',
'nav-quarantine': '/quarantine',
'nav-jobs': '/jobs',
'nav-settings': '/settings',
};
const path = pathMap[cmd.id] ?? '/';
void navigate({ to: path as any });
onClose();
},
})),
[navigate, onClose],
);
const filtered = useMemo(() => {
const q = query.toLowerCase().trim();
if (!q) return commands;
return commands.filter(
(cmd) =>
cmd.label.toLowerCase().includes(q) ||
cmd.keywords?.some((kw) => kw.includes(q)) ||
cmd.description?.toLowerCase().includes(q),
);
}, [query, commands]);
// Reset search when opened
useEffect(() => {
if (open) {
setQuery('');
setSelectedIndex(0);
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [open]);
// Scroll selected item into view
useEffect(() => {
if (!listRef.current) return;
const el = listRef.current.children[selectedIndex] as HTMLElement | undefined;
el?.scrollIntoView({ block: 'nearest' });
}, [selectedIndex]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex((i) => Math.max(i - 1, 0));
break;
case 'Enter':
e.preventDefault();
if (filtered[selectedIndex]) {
filtered[selectedIndex].action();
}
break;
case 'Escape':
e.preventDefault();
onClose();
break;
}
},
[filtered, selectedIndex, onClose],
);
if (!open) return null;
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-40 bg-black/50"
onClick={onClose}
aria-hidden="true"
/>
{/* Dialog */}
<div className="fixed left-1/2 top-[15vh] z-50 w-full max-w-lg -translate-x-1/2 animate-rise">
<div className="overflow-hidden rounded-lg border border-border bg-bg1 shadow-2xl shadow-black/60">
{/* Search input */}
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<Search size={16} className="text-muted flex-none" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setSelectedIndex(0);
}}
onKeyDown={handleKeyDown}
placeholder="Search pages, commands…"
className="flex-1 bg-transparent text-sm text-text placeholder:text-muted outline-none"
/>
<kbd className="flex-none rounded border border-border bg-surface0 px-1.5 py-0.5 text-[11px] font-medium text-muted">
Esc
</kbd>
</div>
{/* Results */}
<div ref={listRef} className="max-h-72 overflow-y-auto py-1.5" role="listbox">
{filtered.length === 0 ? (
<div className="px-4 py-6 text-center text-sm text-muted">
No matching pages
</div>
) : (
filtered.map((cmd, i) => (
<button
key={cmd.id}
onClick={cmd.action}
role="option"
aria-selected={i === selectedIndex}
className={`flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm transition-colors ${
i === selectedIndex
? 'bg-accent/10 text-accent'
: 'text-text hover:bg-surface0'
}`}
>
<cmd.icon
size={16}
className={
i === selectedIndex ? 'text-accent' : 'text-muted'
}
/>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{cmd.label}</div>
{cmd.description && (
<div className="truncate text-xs text-muted">
{cmd.description}
</div>
)}
</div>
<ArrowRight
size={14}
className={
i === selectedIndex ? 'text-accent' : 'text-muted/0'
}
/>
</button>
))
)}
</div>
{/* Footer hint */}
<div className="border-t border-border px-4 py-2 text-[11px] text-muted flex items-center gap-3">
<span>
<kbd className="rounded border border-border bg-surface0 px-1 font-medium"></kbd> Navigate
</span>
<span>
<kbd className="rounded border border-border bg-surface0 px-1 font-medium"></kbd> Open
</span>
</div>
</div>
</div>
</>
);
}
+5
View File
@@ -0,0 +1,5 @@
/**
* Re-export from the Ethos component library.
* All existing imports continue to work.
*/
export { EmptyState } from './ethos/EmptyState';
+169
View File
@@ -0,0 +1,169 @@
import { X, Play } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import type { AlbumWithTracks, ArtistWithAlbums } from '../types';
import { albumService } from '../services/albumService';
import { artistService } from '../services/artistService';
import { Artwork } from './Artwork';
import { Button } from './ethos/Button';
import { TrackRow } from './TrackRow';
import { usePlaybackStore } from '../store/usePlaybackStore';
type InspectorMode = 'album' | 'artist' | 'track';
interface InspectorProps {
mode: InspectorMode;
id: string;
onClose: () => void;
}
function AlbumInspector({ id, onClose }: { id: string; onClose: () => void }) {
const { setQueue, playTrack } = usePlaybackStore();
const { data, isLoading } = useQuery<AlbumWithTracks>({
queryKey: ['album', id],
queryFn: () => albumService.getAlbum(id),
});
if (isLoading) {
return (
<div className="p-4 space-y-3">
<div className="skeleton h-40 w-full rounded" />
<div className="skeleton h-4 w-2/3" />
<div className="skeleton h-3 w-1/3" />
</div>
);
}
if (!data) return null;
const tracks = data.tracks ?? [];
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
<span className="text-xs font-semibold uppercase tracking-wider text-muted">Album</span>
<button onClick={onClose} className="text-muted hover:text-text p-0.5 rounded">
<X size={14} />
</button>
</div>
<div className="overflow-y-auto flex-1">
{/* Artwork + meta */}
<div className="p-4 space-y-3">
<div className="w-full aspect-square rounded-md overflow-hidden">
<Artwork seed={data.title} src={data.artwork_id} className="w-full h-full" rounded="md" eager />
</div>
<div>
<h2 className="text-sm font-semibold text-text truncate">{data.title}</h2>
<p className="text-xs text-secondary">{data.artist_name || 'Unknown artist'}{data.year ? ` · ${data.year}` : ''}</p>
<p className="text-xs text-muted mt-0.5">{tracks.length} tracks</p>
</div>
<Button
variant="primary"
size="sm"
icon={<Play size={14} fill="currentColor" />}
onClick={() => { if (tracks.length) { setQueue(tracks); playTrack(tracks[0]); } }}
disabled={!tracks.length}
className="w-full"
>
Play album
</Button>
</div>
{/* Track list */}
<div className="border-t border-border">
<div className="px-4 py-2 text-[10px] font-semibold uppercase tracking-wider text-muted">Tracks</div>
<div className="space-y-0.5 px-2 pb-3">
{tracks.map((t, i) => (
<TrackRow key={t.id} track={t} queue={tracks} index={i} showActions={false} />
))}
</div>
</div>
</div>
</div>
);
}
function ArtistInspector({ id, onClose }: { id: string; onClose: () => void }) {
const { data, isLoading } = useQuery<ArtistWithAlbums>({
queryKey: ['artist', id],
queryFn: () => artistService.getArtist(id),
});
if (isLoading) {
return (
<div className="p-4 space-y-3">
<div className="skeleton h-32 w-32 rounded-full mx-auto" />
<div className="skeleton h-4 w-1/2 mx-auto" />
</div>
);
}
if (!data) return null;
const albums = data.albums ?? [];
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
<span className="text-xs font-semibold uppercase tracking-wider text-muted">Artist</span>
<button onClick={onClose} className="text-muted hover:text-text p-0.5 rounded">
<X size={14} />
</button>
</div>
<div className="overflow-y-auto flex-1">
<div className="p-4 space-y-3 text-center">
<div className="w-24 h-24 rounded-full overflow-hidden mx-auto ring-2 ring-border">
<Artwork seed={data.name} src={data.image_path} className="w-full h-full" rounded="full" eager />
</div>
<div>
<h2 className="text-sm font-semibold text-text">{data.name}</h2>
<p className="text-xs text-muted">{albums.length} albums</p>
</div>
</div>
{albums.length > 0 && (
<div className="border-t border-border">
<div className="px-4 py-2 text-[10px] font-semibold uppercase tracking-wider text-muted">Albums</div>
<div className="grid grid-cols-3 gap-2 p-2">
{albums.map((album) => (
<Link
key={album.id}
to="/albums/$albumId"
params={{ albumId: album.id }}
className="flex flex-col gap-1 rounded-md p-1.5 hover:bg-surface0 transition-colors"
>
<div className="aspect-square rounded-sm overflow-hidden">
<Artwork seed={album.title} src={album.artwork_id} className="w-full h-full" />
</div>
<span className="text-xs text-text truncate">{album.title}</span>
</Link>
))}
</div>
</div>
)}
</div>
</div>
);
}
export type { InspectorMode };
/**
* Inspector panel — right-side detail view for albums, artists, and tracks.
* Replaces full-page navigations with a slide-in panel per Ethos conventions.
*/
export function Inspector({ mode, id, onClose }: InspectorProps) {
return (
<aside className="w-80 flex flex-col border-l border-border bg-bg1 overflow-hidden shrink-0 animate-slide-in">
{mode === 'album' && <AlbumInspector id={id} onClose={onClose} />}
{mode === 'artist' && <ArtistInspector id={id} onClose={onClose} />}
{mode === 'track' && (
<div className="p-4 text-sm text-muted text-center py-10">
Track inspector coming soon
</div>
)}
</aside>
);
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Re-export from the Ethos component library.
* All existing imports continue to work.
*
* Note: `LoadingState` (spinner) has been replaced by Skeletons.
* The export is preserved for backward compatibility.
*/
export { Skeleton, SkeletonRows, SkeletonGrid } from './ethos/Skeleton';
interface LoadingStateProps {
label?: string;
className?: string;
}
/**
* Spinner-based loading — use only for unknown-duration waits.
* Most pages should use SkeletonRows/SkeletonGrid instead.
*/
export function LoadingState({ label = 'Loading…', className = '' }: LoadingStateProps) {
return (
<div className={`flex items-center justify-center gap-2 py-10 text-sm text-muted ${className}`}>
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
{label}
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { useQuery } from '@tanstack/react-query';
import { X } from 'lucide-react';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { trackService } from '../services/trackService';
import { albumService } from '../services/albumService';
import { Artwork } from './Artwork';
import { ArtistLinks } from './ArtistLinks';
import { SyncedLyrics } from './SyncedLyrics';
/**
* Dedicated, roomy lyrics view — overlays the content area (not the playback
* bar, so transport stays usable). Separated from the queue sidebar so the
* karaoke lyrics get the space they deserve.
*/
export function LyricsOverlay({ onClose }: { onClose: () => void }) {
const currentTrack = usePlaybackStore((s) => s.currentTrack);
const albumQ = useQuery({
queryKey: ['album', currentTrack?.album_id],
queryFn: () => albumService.getAlbum(currentTrack!.album_id),
enabled: !!currentTrack?.album_id,
staleTime: 5 * 60 * 1000,
});
const lyricsQ = useQuery({
queryKey: ['lyrics', currentTrack?.id],
queryFn: () => trackService.getLyrics(currentTrack!.id),
enabled: !!currentTrack,
retry: false,
});
return (
<div className="absolute inset-0 z-40 glass flex flex-col animate-rise">
{/* Header */}
<div className="flex items-center gap-4 px-6 py-4 border-b border-border/70">
{currentTrack && (
<div className="w-12 h-12 flex-none rounded-lg overflow-hidden shadow-md shadow-black/40">
<Artwork seed={`${currentTrack.title} ${currentTrack.artist}`} src={albumQ.data?.artwork_id} className="w-full h-full" rounded="lg" />
</div>
)}
<div className="min-w-0 flex-1">
<div className="text-lg font-bold text-text truncate">{currentTrack?.title ?? 'Lyrics'}</div>
{currentTrack && (
<ArtistLinks artists={currentTrack.artists} fallback={currentTrack.artist} className="block text-sm text-muted truncate" />
)}
</div>
<button onClick={onClose} aria-label="Close lyrics" className="rounded-md p-2 text-muted hover:text-text hover:bg-surface1 transition-colors">
<X size={18} />
</button>
</div>
{/* Lyrics */}
<div
className="flex-1 overflow-y-auto mx-auto w-full max-w-2xl"
style={{
WebkitMaskImage: 'linear-gradient(to bottom, transparent 0, #000 10%, #000 90%, transparent 100%)',
maskImage: 'linear-gradient(to bottom, transparent 0, #000 10%, #000 90%, transparent 100%)',
}}
>
{!currentTrack ? (
<p className="px-6 py-10 text-center text-sm text-muted italic">Nothing playing.</p>
) : lyricsQ.isLoading ? (
<p className="px-6 py-10 text-center text-sm text-muted">Loading</p>
) : lyricsQ.isError || !lyricsQ.data ? (
<p className="px-6 py-10 text-center text-sm text-muted italic">No lyrics available.</p>
) : (
<SyncedLyrics synced={lyricsQ.data.synced_lyrics} plain={lyricsQ.data.lyrics_text} />
)}
</div>
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { Play } from 'lucide-react';
import { Artwork } from './Artwork';
interface MediaCardProps {
seed: string;
title: string;
subtitle?: string;
artSrc?: string | null;
onClick?: () => void;
}
export function MediaCard({ seed, title, subtitle, artSrc, onClick }: MediaCardProps) {
return (
<button
onClick={onClick}
className="card-surface group text-left w-full"
>
<div className="artwork-frame relative w-full">
<Artwork seed={seed} src={artSrc} className="w-full h-full transition-transform duration-500 group-hover:scale-105" rounded="xl" />
<div className="play-overlay">
<div className="play-overlay-btn">
<Play size={18} fill="currentColor" className="ml-0.5" />
</div>
</div>
</div>
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-text">{title}</div>
{subtitle && <div className="truncate text-xs text-muted mt-0.5">{subtitle}</div>}
</div>
</button>
);
}
+105
View File
@@ -0,0 +1,105 @@
import { Link } from '@tanstack/react-router';
import type { LucideIcon } from 'lucide-react';
import {
Home, Music, Disc3, Users, Tag, Compass,
Terminal, ShieldAlert,
Zap,
Settings, Sparkles,
} 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: '/discover', icon: Compass, label: 'Discover' },
],
},
{
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";
export function NavRail() {
return (
<aside className="w-48 flex flex-col bg-bg1 border-r border-border shrink-0 overflow-y-auto">
{/* 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>
</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}` }}
>
<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>
);
}
+114
View File
@@ -0,0 +1,114 @@
import { X, Play, Pause, SkipBack, SkipForward, Disc3 } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { Artwork } from './Artwork';
import { ArtistLinks } from './ArtistLinks';
import { TrackRow, formatDuration } from './TrackRow';
import { albumService } from '../services/albumService';
export function NowPlayingPanel({ onClose }: { onClose: () => void }) {
const { currentTrack, queue, isPlaying, position, duration, play, pause, next, prev, setPosition } = usePlaybackStore();
const currentIdx = currentTrack ? queue.findIndex((t) => t.id === currentTrack.id) : -1;
const upNext = currentIdx >= 0 ? queue.slice(currentIdx + 1) : queue;
const albumQ = useQuery({
queryKey: ['album', currentTrack?.album_id],
queryFn: () => albumService.getAlbum(currentTrack!.album_id),
enabled: !!currentTrack?.album_id,
staleTime: 5 * 60 * 1000,
});
const artwork = albumQ.data?.artwork_id ?? currentTrack?.artwork_id ?? null;
return (
<aside className="w-96 flex flex-col border-l border-border/70 bg-bg1/80 backdrop-blur-sm overflow-hidden shrink-0">
<div className="flex items-center justify-between px-4 py-3 border-b border-border/70">
<span className="text-sm font-semibold text-text">Now Playing</span>
<button onClick={onClose} className="text-muted hover:text-text p-1 rounded">
<X size={16} />
</button>
</div>
<div className="p-4 space-y-4">
{currentTrack?.album_id ? (
<Link to="/albums/$albumId" params={{ albumId: currentTrack.album_id }}
className="group block aspect-square rounded-xl overflow-hidden relative shadow-lg shadow-black/40" title="Go to album">
<Artwork seed={`${currentTrack.title} ${currentTrack.artist}`} src={artwork} className="w-full h-full transition-transform group-hover:scale-105" rounded="xl" />
<div className="absolute inset-0 flex items-center justify-center bg-black/0 group-hover:bg-black/40 transition-colors">
<Disc3 size={28} className="text-on-accent opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
</Link>
) : (
<div className="aspect-square rounded-xl overflow-hidden shadow-lg shadow-black/40">
<Artwork seed={currentTrack ? `${currentTrack.title} ${currentTrack.artist}` : 'empty'} src={artwork} className="w-full h-full" rounded="xl" />
</div>
)}
{currentTrack ? (
<div className="text-center space-y-0.5">
{currentTrack.album_id ? (
<Link to="/albums/$albumId" params={{ albumId: currentTrack.album_id }} className="font-bold text-text truncate block hover:underline">
{currentTrack.title}
</Link>
) : (
<div className="font-bold text-text truncate">{currentTrack.title}</div>
)}
<ArtistLinks artists={currentTrack.artists} fallback={currentTrack.artist} className="block text-sm text-muted truncate" />
</div>
) : (
<div className="text-center text-sm text-muted italic">No track playing</div>
)}
<div className="space-y-1">
<input
type="range" min={0} max={Math.max(duration, 0.1)} step={0.1}
value={Math.min(position, duration || 0)}
onChange={(e) => setPosition(Number(e.target.value))}
disabled={!currentTrack || duration <= 0}
className="w-full cursor-pointer"
/>
<div className="flex justify-between text-xs text-muted tabular-nums">
<span>{formatDuration(position)}</span>
<span>{formatDuration(duration)}</span>
</div>
</div>
<div className="flex items-center justify-center gap-6">
<button onClick={prev} className="text-muted hover:text-text"><SkipBack size={20} /></button>
<button
onClick={() => isPlaying ? pause() : play()}
disabled={!currentTrack}
className="transport-btn"
>
{isPlaying ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />}
</button>
<button onClick={next} className="text-muted hover:text-text"><SkipForward size={20} /></button>
</div>
</div>
<div className="px-4 py-2 border-t border-border/70 text-xs font-semibold uppercase tracking-wide text-muted">
Up Next ({upNext.length})
</div>
<div className="flex-1 overflow-y-auto px-2 py-2">
{upNext.length === 0 ? (
<div className="px-2 py-4 text-sm text-muted italic">Queue is empty.</div>
) : (
<div className="space-y-0.5">
{upNext.map((track, i) => (
<TrackRow
key={`${track.id}-${i}`}
track={track}
queue={upNext.slice(i)}
index={0}
showActions={false}
variant="compact"
/>
))}
</div>
)}
</div>
</aside>
);
}
+31
View File
@@ -0,0 +1,31 @@
import type { ReactNode } from 'react';
type ContainerWidth = 'sm' | 'md' | 'lg' | 'full';
interface PageContainerProps {
children: ReactNode;
/** Controls max-width. sm → max-w-2xl, md → max-w-3xl (default), lg → max-w-5xl, full → no constraint. */
width?: ContainerWidth;
className?: string;
}
const WIDTH_CLASSES: Record<ContainerWidth, string> = {
sm: 'max-w-2xl',
md: 'max-w-3xl',
lg: 'max-w-5xl',
full: '',
};
/**
* Ethos page container — enforces consistent horizontal centering and
* vertical spacing so every page opens the same way.
*
* Previously every page hand-rolled its own `mx-auto space-y-* max-w-*`.
*/
export function PageContainer({ children, width = 'md', className = '' }: PageContainerProps) {
return (
<div className={`mx-auto space-y-6 ${WIDTH_CLASSES[width]} ${className}`}>
{children}
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import type { LucideIcon } from 'lucide-react';
interface PageHeaderProps {
icon?: LucideIcon;
title: string;
subtitle?: string;
/** Optional right-aligned actions (buttons, toggles, etc.). */
actions?: React.ReactNode;
}
/**
* Consistent page heading: a gradient title with an optional accent icon chip,
* subtitle, and right-aligned action slot. Used across the library pages so
* every screen opens the same way.
*/
export function PageHeader({ icon: Icon, title, subtitle, actions }: PageHeaderProps) {
return (
<div className="flex items-end justify-between gap-4 animate-rise">
<div className="flex items-center gap-4 min-w-0">
{Icon && (
<div className="flex h-12 w-12 flex-none items-center justify-center rounded-2xl bg-accent/15 text-accent ring-1 ring-accent/25 shadow-lg shadow-accent/10">
<Icon size={24} />
</div>
)}
<div className="min-w-0">
<h1 className="text-gradient truncate text-3xl font-extrabold tracking-tight sm:text-4xl">
{title}
</h1>
{subtitle && <p className="mt-1 truncate text-sm text-muted">{subtitle}</p>}
</div>
</div>
{actions && <div className="flex flex-none items-center gap-2">{actions}</div>}
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { ChevronLeft, ChevronRight } from 'lucide-react';
interface PaginationProps {
page: number;
/** Whether a next page exists (typically `pageResults.length === PAGE_SIZE`). */
hasNext: boolean;
/** Disables both buttons while the next page is still placeholder data. */
isLoading?: boolean;
onPrev: () => void;
onNext: () => void;
}
/**
* Prev/Next pager with a page indicator. Extracted from the duplicated
* implementation in Tracks/Albums/Artists/Genres so the controls stay
* consistent and accessible (aria-labels, disabled semantics) across pages.
*/
export function Pagination({ page, hasNext, isLoading, onPrev, onNext }: PaginationProps) {
const prevDisabled = page === 0 || isLoading;
const nextDisabled = !hasNext || isLoading;
return (
<div className="flex items-center justify-between pt-2">
<button
onClick={onPrev}
disabled={prevDisabled}
aria-label="Previous page"
className="inline-flex items-center gap-1 rounded-lg border border-border px-3 py-1.5 text-sm text-text hover:bg-surface1 disabled:opacity-40 disabled:hover:bg-transparent"
>
<ChevronLeft size={16} /> Prev
</button>
<span className="text-sm text-muted tabular-nums" aria-live="polite">Page {page + 1}</span>
<button
onClick={onNext}
disabled={nextDisabled}
aria-label="Next page"
className="inline-flex items-center gap-1 rounded-lg border border-border px-3 py-1.5 text-sm text-text hover:bg-surface1 disabled:opacity-40 disabled:hover:bg-transparent"
>
Next <ChevronRight size={16} />
</button>
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { X } from 'lucide-react';
interface PanelHeaderProps {
title: string;
onClose?: () => void;
className?: string;
/**
* Title styling intent:
* - `'panel'` (default) — text-xs uppercase muted (Inspector, LyricsOverlay)
* - `'heading'` — text-sm semibold text-text (NowPlayingPanel)
*/
intent?: 'panel' | 'heading';
}
const TITLE_CLASSES = {
panel: 'text-xs font-semibold uppercase tracking-wider text-muted',
heading: 'text-sm font-semibold text-text',
};
/**
* Overlay/panel header bar — title label with an optional close button.
* Standardizes the pattern that was hand-rolled in Inspector (×2),
* NowPlayingPanel, LyricsOverlay, CommandPalette, and more.
*/
export function PanelHeader({ title, onClose, className = '', intent = 'panel' }: PanelHeaderProps) {
return (
<div className={`flex items-center justify-between px-4 py-2.5 border-b border-border ${className}`}>
<span className={TITLE_CLASSES[intent]}>{title}</span>
{onClose && (
<button onClick={onClose} className="text-muted hover:text-text p-0.5 rounded">
<X size={14} />
</button>
)}
</div>
);
}
+143
View File
@@ -0,0 +1,143 @@
import { Play, Pause, SkipBack, SkipForward, Volume2, ListMusic, Shuffle, Repeat, Repeat1, MicVocal, ThumbsDown } from 'lucide-react';
import { Link } from '@tanstack/react-router';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { useDislikeTrack } from '../hooks/useDislikeTrack';
import { Artwork } from './Artwork';
import { ArtistLinks } from './ArtistLinks';
import { formatDuration } from './TrackRow';
interface PlaybackBarProps {
queueOpen: boolean;
lyricsOpen: boolean;
onToggleQueue: () => void;
onToggleLyrics: () => void;
}
export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyrics }: PlaybackBarProps) {
const { currentTrack, isPlaying, position, duration, volume, shuffle, repeat, play, pause, next, prev, setPosition, setVolume, toggleShuffle, cycleRepeat } = usePlaybackStore();
const dislikeTrack = useDislikeTrack();
const handleDislike = () => {
if (!currentTrack) return;
dislikeTrack(currentTrack.id);
};
return (
<div className="glass h-20 border-t border-border/70 px-4 flex items-center gap-4 shrink-0 z-20">
{/* Track info */}
<div className="flex items-center gap-3 w-64 min-w-0 shrink-0">
{currentTrack ? (
<>
{currentTrack.album_id ? (
<Link to="/albums/$albumId" params={{ albumId: currentTrack.album_id }}
className="w-12 h-12 flex-none rounded-lg overflow-hidden shadow-md shadow-black/40 ring-1 ring-border/50 hover:ring-accent/50 transition-shadow" title="Go to album">
<Artwork seed={`${currentTrack.title} ${currentTrack.artist}`} src={currentTrack.artwork_id} className="w-full h-full" rounded="lg" eager />
</Link>
) : (
<div className="w-12 h-12 flex-none rounded-lg overflow-hidden shadow-md shadow-black/40 ring-1 ring-border/50">
<Artwork seed={`${currentTrack.title} ${currentTrack.artist}`} src={currentTrack.artwork_id} className="w-full h-full" rounded="lg" eager />
</div>
)}
<div className="min-w-0">
{currentTrack.album_id ? (
<Link to="/albums/$albumId" params={{ albumId: currentTrack.album_id }}
className="block text-sm font-semibold text-text truncate hover:underline">
{currentTrack.title}
</Link>
) : (
<div className="text-sm font-semibold text-text truncate">{currentTrack.title}</div>
)}
<ArtistLinks artists={currentTrack.artists} fallback={currentTrack.artist} className="block text-xs text-muted truncate" />
</div>
<button
onClick={handleDislike}
title="Dislike (sends to quarantine)"
className="flex-none rounded-md p-1.5 text-muted hover:bg-surface1 hover:text-red-400 transition-colors"
aria-label="Dislike — move to quarantine"
>
<ThumbsDown size={16} />
</button>
</>
) : (
<div className="text-sm text-muted italic">Nothing playing</div>
)}
</div>
{/* Controls + scrubber */}
<div className="flex-1 flex flex-col items-center gap-1">
<div className="flex items-center gap-3">
<button
onClick={toggleShuffle}
className={`p-1.5 rounded-md transition-colors ${shuffle ? 'text-accent' : 'text-muted hover:text-text'}`}
aria-label={shuffle ? 'Disable shuffle' : 'Enable shuffle'}
title={shuffle ? 'Shuffle on' : 'Shuffle off'}
>
<Shuffle size={18} />
</button>
<button onClick={prev} className="text-muted hover:text-text" aria-label="Previous">
<SkipBack size={20} />
</button>
<button
onClick={() => isPlaying ? pause() : play()}
disabled={!currentTrack}
className="transport-btn"
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />}
</button>
<button onClick={next} className="text-muted hover:text-text" aria-label="Next">
<SkipForward size={20} />
</button>
<button
onClick={cycleRepeat}
className={`p-1.5 rounded-md transition-colors ${repeat !== 'none' ? 'text-accent' : 'text-muted hover:text-text'}`}
aria-label={`Repeat: ${repeat}`}
title={repeat === 'none' ? 'Repeat off' : repeat === 'all' ? 'Repeat all' : 'Repeat one'}
>
{repeat === 'one' ? <Repeat1 size={18} /> : <Repeat size={18} />}
</button>
</div>
<div className="flex w-full max-w-lg items-center gap-2">
<span className="text-xs text-muted w-9 text-right tabular-nums">{formatDuration(position)}</span>
<input
type="range" min={0} max={Math.max(duration, 0.1)} step={0.1}
value={Math.min(position, duration || 0)}
onChange={(e) => setPosition(Number(e.target.value))}
disabled={!currentTrack || duration <= 0}
className="flex-1 h-1 cursor-pointer"
aria-label="Seek"
/>
<span className="text-xs text-muted w-9 tabular-nums">{formatDuration(duration)}</span>
</div>
</div>
{/* Volume + panel toggle */}
<div className="flex items-center gap-3 w-48 justify-end shrink-0">
<Volume2 size={18} className="text-muted flex-none" />
<input
type="range" min={0} max={1} step={0.01} value={volume}
onChange={(e) => setVolume(Number(e.target.value))}
className="w-20 h-1 cursor-pointer"
aria-label="Volume"
/>
<button
onClick={onToggleLyrics}
disabled={!currentTrack}
className={`p-2 rounded-md transition-colors disabled:opacity-30 ${lyricsOpen ? 'bg-accent/20 text-accent' : 'text-muted hover:text-text'}`}
aria-label="Toggle lyrics"
title="Lyrics"
>
<MicVocal size={18} />
</button>
<button
onClick={onToggleQueue}
className={`p-2 rounded-md transition-colors ${queueOpen ? 'bg-accent/20 text-accent' : 'text-muted hover:text-text'}`}
aria-label="Toggle queue panel"
title="Up Next"
>
<ListMusic size={18} />
</button>
</div>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { Link } from '@tanstack/react-router';
import { ChevronRight } from 'lucide-react';
interface ShelfRowProps {
title: string;
viewAllTo?: string;
children: React.ReactNode;
}
export function ShelfRow({ title, viewAllTo, children }: ShelfRowProps) {
return (
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold text-text">{title}</h2>
{viewAllTo && (
<Link to={viewAllTo} className="flex items-center gap-0.5 text-xs text-muted hover:text-accent transition-colors">
View all <ChevronRight size={14} />
</Link>
)}
</div>
<div className="flex gap-4 overflow-x-auto pb-2 scrollbar-hide">
{children}
</div>
</section>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { useEffect, useMemo, useRef } from 'react';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { parseLrc, activeLineIndex } from '../lib/lyrics';
interface SyncedLyricsProps {
/** Raw LRC string from track_lyrics.synced_lyrics (may be null/unparseable). */
synced: unknown;
/** Plain lyrics fallback when there are no timestamped lines. */
plain: string | null;
}
/**
* Karaoke-style lyrics. When time-synced lyrics are available it highlights the
* line for the current playback position, dims the rest, smoothly auto-scrolls
* to keep the active line centered, and lets you click any line to seek there.
* Falls back to plain scrollable text when no sync data exists.
*/
export function SyncedLyrics({ synced, plain }: SyncedLyricsProps) {
const position = usePlaybackStore((s) => s.position);
const setPosition = usePlaybackStore((s) => s.setPosition);
const lines = useMemo(() => parseLrc(synced), [synced]);
const active = activeLineIndex(lines, position);
const activeRef = useRef<HTMLButtonElement>(null);
// Keep the active line centered as it changes.
useEffect(() => {
activeRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, [active]);
if (lines.length === 0) {
if (!plain) {
return (
<p className="px-6 py-10 text-center text-sm text-muted italic">No lyrics available.</p>
);
}
return (
<pre className="whitespace-pre-wrap px-6 py-6 text-center font-sans text-base leading-loose text-muted">
{plain}
</pre>
);
}
return (
<div className="px-5 py-[45%] space-y-5">
{lines.map((line, i) => {
const isActive = i === active;
const isPast = i < active;
// Lines near the active one stay a bit more legible than far ones.
const distance = Math.abs(i - active);
const upcomingOpacity = distance <= 2 ? 'opacity-70' : 'opacity-40';
return (
<button
key={`${line.time}-${i}`}
ref={isActive ? activeRef : undefined}
onClick={() => setPosition(line.time)}
className={`block w-full text-left text-2xl font-extrabold leading-tight tracking-tight transition-all duration-500 ease-out hover:text-text ${
isActive
? 'text-accent scale-[1.03] origin-left [text-shadow:0_0_24px_var(--accent)]'
: isPast
? 'text-muted/30'
: `text-muted ${upcomingOpacity}`
}`}
>
{line.text || '♪'}
</button>
);
})}
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { useEffect } from 'react';
import type { LucideIcon } from 'lucide-react';
import { CheckCircle2, XCircle, Info, X } from 'lucide-react';
import { useToastStore, type ToastKind } from '../store/useToastStore';
const ICONS: Record<ToastKind, LucideIcon> = {
success: CheckCircle2,
error: XCircle,
info: Info,
};
const ACCENT: Record<ToastKind, string> = {
success: 'text-green',
error: 'text-red',
info: 'text-accent',
};
/**
* Fixed top-right toast stack. Ethos spec: notifications stack top-right.
* Supports optional action (e.g. "Undo") and auto-dismiss.
*/
export function Toaster() {
const toasts = useToastStore((s) => s.toasts);
const dismiss = useToastStore((s) => s.dismiss);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && toasts.length > 0) {
dismiss(toasts[toasts.length - 1].id);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [toasts, dismiss]);
if (toasts.length === 0) return null;
return (
<div
className="fixed top-14 right-4 z-50 flex flex-col gap-2 w-[min(20rem,calc(100vw-2rem))]"
role="region"
aria-label="Notifications"
aria-live="polite"
>
{toasts.map((t) => {
const Icon = ICONS[t.kind];
return (
<div
key={t.id}
className="flex items-start gap-2.5 rounded-md border border-border bg-bg1 p-3 shadow-lg shadow-black/40 animate-rise"
role="status"
>
<Icon size={16} className={`mt-0.5 flex-none ${ACCENT[t.kind]}`} />
<div className="min-w-0 flex-1 text-sm text-text">{t.message}</div>
{t.action && (
<button
onClick={() => { t.action?.onClick(); dismiss(t.id); }}
className="flex-none text-xs font-semibold text-accent hover:text-accent-h"
>
{t.action.label}
</button>
)}
<button
onClick={() => dismiss(t.id)}
aria-label="Dismiss notification"
className="flex-none rounded p-0.5 text-muted hover:text-text hover:bg-surface0"
>
<X size={12} />
</button>
</div>
);
})}
</div>
);
}
+194
View File
@@ -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>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { Play, Pause, ThumbsDown, Disc3, Sparkles } from 'lucide-react';
import { Link, useRouter } from '@tanstack/react-router';
import type { Track } from '../types';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { useDislikeTrack } from '../hooks/useDislikeTrack';
import { vibeService } from '../services/vibeService';
import { Artwork } from './Artwork';
import { ArtistLinks } from './ArtistLinks';
export function formatDuration(seconds?: number | null): string {
if (!seconds || seconds < 0 || !Number.isFinite(seconds)) return '0:00';
const total = Math.floor(seconds);
return `${Math.floor(total / 60)}:${(total % 60).toString().padStart(2, '0')}`;
}
type TrackRowVariant = 'default' | 'compact';
interface TrackRowProps {
track: Track;
queue: Track[];
index: number;
showActions?: boolean;
/** compact — smaller artwork, no duration, for queue panels / VibeTimeline */
variant?: TrackRowVariant;
/** Show a "Vibe by track" button that starts a vibe session seeded from this track. */
showVibe?: boolean;
}
export function TrackRow({ track, queue, index, showActions = true, variant = 'default', showVibe = false }: TrackRowProps) {
const { setQueue, playTrack, play, pause, currentTrack, isPlaying } = usePlaybackStore();
const dislikeTrack = useDislikeTrack();
const router = useRouter();
const isCurrent = currentTrack?.id === track.id;
const compact = variant === 'compact';
const handlePlay = () => {
if (isCurrent) { isPlaying ? pause() : play(); return; }
setQueue(queue.slice(index));
playTrack(track);
};
const handleDislike = (e: React.MouseEvent) => {
e.stopPropagation();
dislikeTrack(track.id);
};
const handleVibe = (e: React.MouseEvent) => {
e.stopPropagation();
// Start a vibe session then navigate to the vibe page.
vibeService.start(track.id).then(() => {
router.navigate({ to: '/vibe' });
}).catch(() => {
// Session failed — still navigate so the user can try manually.
router.navigate({ to: '/vibe' });
});
};
return (
<div
onClick={handlePlay}
className={`group flex w-full cursor-pointer items-center gap-3 rounded-lg border transition-colors ${
compact ? 'p-2' : 'p-2.5'
} ${
isCurrent
? 'border-accent/60 bg-accent/10'
: 'border-border/70 bg-surface0/50 hover:border-accent/30 hover:bg-surface1'
}`}
>
{/* Artwork + play overlay */}
<div className={`relative flex flex-none items-center justify-center rounded overflow-hidden ${
compact ? 'h-9 w-9' : 'h-10 w-10'
}`}>
<Artwork seed={`${track.title} ${track.artist}`} src={track.artwork_id} className="absolute inset-0 w-full h-full" />
{isCurrent && isPlaying ? (
<Pause size={compact ? 14 : 18} className="absolute z-20 text-text opacity-100" />
) : (
<Play size={compact ? 14 : 18} className="absolute z-20 text-text opacity-0 group-hover:opacity-100" />
)}
</div>
{/* Title + artist */}
<div className="min-w-0 flex-1">
<div className={`truncate font-medium ${isCurrent ? 'text-accent' : 'text-text'} ${compact ? 'text-sm' : 'text-sm'}`}>
{track.title || 'Untitled'}
</div>
<ArtistLinks
artists={track.artists}
fallback={track.artist}
stopPropagation
className={`truncate block text-muted ${compact ? 'text-xs' : 'text-xs'}`}
/>
</div>
{/* Actions (vibe → album link → dislike) */}
{showActions && !compact && (
<div className="flex flex-none items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
{showVibe && (
<button onClick={handleVibe} title="Vibe by track" className="rounded p-1.5 text-muted hover:bg-surface1 hover:text-accent">
<Sparkles size={16} />
</button>
)}
{track.album_id && (
<Link
to="/albums/$albumId"
params={{ albumId: track.album_id }}
onClick={(e) => e.stopPropagation()}
title="Go to album"
className="rounded p-1.5 text-muted hover:bg-surface1 hover:text-text"
>
<Disc3 size={16} />
</Link>
)}
<button onClick={handleDislike} title="Dislike" className="rounded p-1.5 text-muted hover:bg-surface1 hover:text-red-400">
<ThumbsDown size={16} />
</button>
</div>
)}
{/* Duration (hidden in compact) */}
{!compact && (
<div className="flex-none text-xs tabular-nums text-muted">{formatDuration(track.duration)}</div>
)}
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { Radio } from 'lucide-react';
import { Badge } from './ethos/Badge';
import { TrackRow } from './TrackRow';
import type { Track } from '../types';
interface VibeTimelineProps {
currentTrack: Track | null;
upcoming: Track[];
}
export function VibeTimeline({ currentTrack, upcoming }: VibeTimelineProps) {
return (
<div className="space-y-2">
<div className="flex items-center gap-2 text-text">
<Radio size={18} className="text-accent" />
<h2 className="text-lg font-semibold">Incoming recommendations</h2>
<span className="text-xs text-muted">({upcoming.length} buffered)</span>
</div>
{currentTrack && (
<div className="relative">
<TrackRow
track={currentTrack}
queue={[currentTrack]}
index={0}
showActions={false}
variant="compact"
/>
<Badge color="accent" className="absolute right-2 top-1/2 -translate-y-1/2">Now playing</Badge>
</div>
)}
{upcoming.length === 0 ? (
<div className="rounded-lg border border-border bg-surface0 p-4 text-sm text-muted">
No upcoming tracks buffered yet.
</div>
) : (
<div className="space-y-0.5">
{upcoming.map((track, i) => (
<TrackRow
key={`${track.id}-${i}`}
track={track}
queue={upcoming.slice(i)}
index={0}
showActions={false}
variant="compact"
/>
))}
</div>
)}
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
import type { ReactNode } from 'react';
type BadgeColor = 'green' | 'amber' | 'red' | 'purple' | 'cyan' | 'orange' | 'neutral' | 'accent';
interface BadgeProps {
color?: BadgeColor;
dot?: boolean;
children: ReactNode;
className?: string;
}
const COLOR_CLASSES: Record<BadgeColor, string> = {
green: 'bg-green/10 text-green border-green/25',
amber: 'bg-amber/10 text-amber border-amber/25',
red: 'bg-red/10 text-red border-red/25',
purple: 'bg-purple/10 text-purple border-purple/25',
cyan: 'bg-cyan/10 text-cyan border-cyan/25',
orange: 'bg-orange/10 text-orange border-orange/25',
neutral: 'bg-surface1 text-secondary border-border',
accent: 'bg-accent/10 text-accent border-accent/25',
};
const DOT_COLORS: Record<BadgeColor, string> = {
green: 'bg-green',
amber: 'bg-amber',
red: 'bg-red',
purple: 'bg-purple',
cyan: 'bg-cyan',
orange: 'bg-orange',
neutral: 'bg-muted',
accent: 'bg-accent',
};
/**
* Ethos badge — semantic status indicator.
* Only uses colors from the Ethos semantic set. Never decorative.
*/
export function Badge({ color = 'neutral', dot = false, children, className = '' }: BadgeProps) {
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-medium ${COLOR_CLASSES[color]} ${className}`}
>
{dot && <span className={`w-1.5 h-1.5 rounded-full ${DOT_COLORS[color]}`} />}
{children}
</span>
);
}
+60
View File
@@ -0,0 +1,60 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'link';
type ButtonSize = 'sm' | 'md' | 'lg';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
loading?: boolean;
icon?: ReactNode;
children?: ReactNode;
}
const VARIANT_CLASSES: Record<ButtonVariant, string> = {
primary:
'bg-accent text-on-accent hover:bg-accent-h border border-accent shadow-sm',
secondary:
'bg-surface0 text-text hover:bg-surface1 border border-border',
ghost:
'text-secondary hover:text-text hover:bg-surface0 border border-transparent',
danger:
'bg-red/10 text-red hover:bg-red/20 border border-red/30',
link:
'text-accent hover:text-accent-h border border-transparent underline-offset-2 hover:underline p-0',
};
const SIZE_CLASSES: Record<ButtonSize, string> = {
sm: 'px-2 py-1 text-xs rounded',
md: 'px-3 py-1.5 text-sm rounded-md',
lg: 'px-4 py-2 text-sm rounded-md',
};
export function Button({
variant = 'secondary',
size = 'md',
loading = false,
icon,
children,
className = '',
disabled,
...props
}: ButtonProps) {
return (
<button
className={`inline-flex items-center justify-center gap-1.5 font-medium transition-all duration-100 focus-visible:outline-2 focus-visible:outline-accent disabled:opacity-40 disabled:pointer-events-none ${VARIANT_CLASSES[variant]} ${SIZE_CLASSES[size]} ${className}`}
disabled={disabled || loading}
{...props}
>
{loading ? (
<svg className="animate-spin h-3.5 w-3.5" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : icon ? (
<span className="flex-none">{icon}</span>
) : null}
{children}
</button>
);
}
@@ -0,0 +1,60 @@
import type { ReactNode } from 'react';
import { Button } from './Button';
interface EmptyStateProps {
icon?: ReactNode;
title: string;
subtitle?: string;
action?: {
label: string;
onClick: () => void;
};
compact?: boolean;
className?: string;
}
/**
* Ethos empty state — icon, message, primary action. No illustrations.
* Matches the Ethos spec for zero-state surfaces.
*/
export function EmptyState({
icon,
title,
subtitle,
action,
compact = false,
className = '',
}: EmptyStateProps) {
if (compact) {
return (
<div
className={`flex flex-col items-center justify-center gap-2 rounded-md border border-dashed border-border py-10 text-center animate-fade-in ${className}`}
>
{icon && <span className="text-muted">{icon}</span>}
<div className="font-medium text-text text-sm">{title}</div>
{subtitle && <div className="text-xs text-muted">{subtitle}</div>}
{action && (
<Button variant="secondary" size="sm" onClick={action.onClick}>
{action.label}
</Button>
)}
</div>
);
}
return (
<div
className={`flex flex-col items-center justify-center gap-3 rounded-md border border-dashed border-border py-16 text-center animate-fade-in ${className}`}
>
{icon && <span className="text-muted">{icon}</span>}
<div>
<div className="font-semibold text-text">{title}</div>
{subtitle && <div className="mt-1 text-sm text-muted">{subtitle}</div>}
</div>
{action && (
<Button variant="secondary" size="md" onClick={action.onClick}>
{action.label}
</Button>
)}
</div>
);
}
@@ -0,0 +1,42 @@
/**
* Ethos skeleton loading states.
* Uses shimmer animation (defined in index.css). Never uses spinners
* unless waiting on an unknown-duration task.
*/
export function Skeleton({ className = '' }: { className?: string }) {
return <div className={`skeleton ${className}`} />;
}
/** Rows matching TrackRow height */
export function SkeletonRows({ count = 5 }: { count?: number }) {
return (
<div className="space-y-1">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="flex items-center gap-3 rounded-md px-3 py-2">
<Skeleton className="h-8 w-8 flex-none rounded" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-3 w-1/3" />
<Skeleton className="h-2.5 w-1/4" />
</div>
<Skeleton className="h-3 w-8 flex-none" />
</div>
))}
</div>
);
}
/** Grid matching album/artist card layout */
export function SkeletonGrid({ count = 10 }: { count?: number }) {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="flex flex-col gap-2 rounded-md border border-border bg-surface0 p-2">
<Skeleton className="aspect-square rounded" />
<Skeleton className="h-3 w-3/4" />
<Skeleton className="h-2.5 w-1/2" />
</div>
))}
</div>
);
}