initial state: muzick music player + recommendation engine
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Re-export from the Ethos component library.
|
||||
* All existing imports continue to work.
|
||||
*/
|
||||
export { EmptyState } from './ethos/EmptyState';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user