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
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.git
.env
+12
View File
@@ -0,0 +1,12 @@
FROM node:20-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:stable-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Muzick</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+18
View File
@@ -0,0 +1,18 @@
server {
listen 80;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
+3208
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
{
"name": "muzick",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "tsc --noEmit",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-router": "^1.170.15",
"axios": "^1.17.0",
"date-fns": "^4.4.0",
"geist": "^1.7.2",
"lucide-react": "^1.17.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"zod": "^4.4.3",
"zustand": "^5.0.14"
},
"devDependencies": {
"@types/react": "^18.3.31",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.5.0",
"postcss": "^8.5.15",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
"vite": "^5.2.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+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>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { favoritesService } from '../services/favoritesService';
import { quarantineService } from '../services/quarantineService';
import { toast } from '../store/useToastStore';
/**
* Returns a stable `dislikeTrack` callback that:
* 1. POSTs the dislike to the API
* 2. Invalidates the dislikes query
* 3. Shows an undo-able toast
* 4. On undo: restores the track + invalidates queries
*
* Extracted because the exact same flow was duplicated in TrackRow and PlaybackBar.
*/
export function useDislikeTrack() {
const qc = useQueryClient();
const dislikeTrack = useCallback(
(trackId: string) => {
void favoritesService
.dislike(trackId)
.then(() => {
qc.invalidateQueries({ queryKey: ['dislikes'] });
toast.info('Moved to quarantine. Will be deleted after the grace period.', {
ttl: 7000,
action: {
label: 'Undo',
onClick: () => {
void quarantineService
.restore(trackId)
.then(() => {
qc.invalidateQueries({ queryKey: ['dislikes'] });
qc.invalidateQueries({ queryKey: ['tracks'] });
toast.success('Track restored to library');
})
.catch(() => toast.error("Couldn't undo. Restore it from Quarantine."));
},
},
});
})
.catch(() => toast.error("Couldn't dislike this track. Try again."));
},
[qc],
);
return dislikeTrack;
}
+125
View File
@@ -0,0 +1,125 @@
import { useEffect, useRef } from 'react';
export type ShortcutHandler = (e: KeyboardEvent) => void;
interface Shortcut {
/**
* Match by `e.key` (logical character — layout/language dependent).
* Use for character shortcuts like `/`, `?`.
*/
key?: string;
/**
* Match by `e.code` (physical key position — layout/language independent).
* Use for modifier shortcuts like Ctrl+K, Alt+←.
* `e.code` values: `KeyK`, `KeyS`, `ArrowLeft`, `ArrowRight`, `Escape`, etc.
*/
code?: string;
ctrl?: boolean;
meta?: boolean;
alt?: boolean;
shift?: boolean;
handler: ShortcutHandler;
/** Don't trigger when typing in inputs/textareas (default: true) */
ignoreInput?: boolean;
}
const registered: Shortcut[] = [];
function matches(e: KeyboardEvent, s: Shortcut): boolean {
// check modifiers first
const modsMatch =
(s.ctrl || false) === (e.ctrlKey || e.metaKey) &&
(s.alt || false) === e.altKey &&
(s.shift || false) === e.shiftKey;
if (!modsMatch) return false;
// check key (logical character) or code (physical key position)
if (s.code) {
return e.code === s.code;
}
if (s.key) {
return e.key.toLowerCase() === s.key.toLowerCase();
}
return false;
}
function isInputFocused(): boolean {
const el = document.activeElement;
if (!el) return false;
const tag = el.tagName;
return (
tag === 'INPUT' ||
tag === 'TEXTAREA' ||
tag === 'SELECT' ||
(el as HTMLElement).isContentEditable
);
}
/**
* Global keyboard shortcut listener. Registers at capture phase so browser
* default actions (like Chrome's Ctrl+K omnibox) get intercepted before they
* fire. Mount once in AppShell.
*/
export function KeyboardListener() {
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
for (const s of registered) {
// ignoreInput defaults to true — skip when user is typing
if ((s.ignoreInput ?? true) && isInputFocused()) continue;
if (matches(e, s)) {
e.preventDefault();
e.stopImmediatePropagation();
s.handler(e);
return;
}
}
};
// Capture phase intercepts before browser defaults (e.g. Chrome Ctrl+K)
window.addEventListener('keydown', onKeyDown, { capture: true });
return () => window.removeEventListener('keydown', onKeyDown, { capture: true });
}, []);
return null;
}
/**
* Register a keyboard shortcut. Handles cleanup automatically on unmount.
* The handler is kept stable via ref so the effect doesn't thrash.
*
* Use `.code` for layout-independent shortcuts (Ctrl+K, Alt+←, etc.).
* Use `.key` for character-based shortcuts (/, ?, etc.).
*/
export function useKeyboard(
shortcut: Omit<Shortcut, 'handler'> & { handler: ShortcutHandler },
) {
const handlerRef = useRef<ShortcutHandler>(shortcut.handler);
handlerRef.current = shortcut.handler;
useEffect(() => {
const entry: Shortcut = {
key: shortcut.key,
code: shortcut.code,
ctrl: shortcut.ctrl,
meta: shortcut.meta,
alt: shortcut.alt,
shift: shortcut.shift,
ignoreInput: shortcut.ignoreInput,
handler: (e: KeyboardEvent) => handlerRef.current(e),
};
registered.push(entry);
return () => {
const idx = registered.indexOf(entry);
if (idx >= 0) registered.splice(idx, 1);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [shortcut.key, shortcut.code, shortcut.ctrl, shortcut.meta, shortcut.alt, shortcut.shift, shortcut.ignoreInput]);
}
/**
* Check if a keyboard event matches a known Ctrl/Cmd+key pattern.
*/
export function isCtrlCmd(e: KeyboardEvent): boolean {
return e.ctrlKey || e.metaKey;
}
+283
View File
@@ -0,0 +1,283 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ── Self-hosted fonts (no CDN — see Ethos threat model) ─────────────────── */
@font-face {
font-family: 'Geist';
src: url('/fonts/Geist-Light.woff2') format('woff2');
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist';
src: url('/fonts/Geist-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist';
src: url('/fonts/Geist-Medium.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist';
src: url('/fonts/Geist-SemiBold.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist';
src: url('/fonts/Geist-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist Mono';
src: url('/fonts/GeistMono-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist Mono';
src: url('/fonts/GeistMono-Medium.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
/* ── Ethos Design Tokens (default dark) ────────────────────────────────────── */
:root {
/* warm brown-tinted neutral ramp — Ethos law, not cool grey */
--ethos-bg0: #14110D;
--ethos-bg1: #1B1712;
--ethos-bg2: #221D17;
--ethos-surface0: #2C261D;
--ethos-surface1: #372F24;
--ethos-surface2: #423927;
--ethos-border: rgba(244,234,220,0.09);
--ethos-border-hi: rgba(244,234,220,0.16);
--ethos-text: #F4EEE4;
--ethos-secondary: #B4AA98;
--ethos-muted: #756C5C;
--ethos-disabled: #5a5347;
/* muzick fingerprint: honey amber */
--ethos-accent: #EDA24E;
--ethos-accent-hover: #E08F32;
--ethos-on-accent: #14110D;
/* semantic colors */
--ethos-green: #22c55e;
--ethos-amber: #eab308;
--ethos-red: #ef4444;
--ethos-purple: #a855f7;
--ethos-cyan: #22d3ee;
--ethos-orange: #f97316;
}
/* ── Base reset ───────────────────────────────────────────────────────────── */
body {
margin: 0;
padding: 0;
background-color: var(--ethos-bg0);
color: var(--ethos-text);
font-family: 'Geist', -apple-system, BlinkMacSystemFont,
'Segoe UI', 'Roboto', sans-serif;
font-size: 14px;
line-height: 20px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
position: relative;
z-index: 1;
}
/* ── Focus ring: 2px accent, for keyboard users only ──────────────────────── */
:focus-visible {
outline: 2px solid var(--ethos-accent);
outline-offset: 2px;
border-radius: 4px;
}
/* ── Selection ────────────────────────────────────────────────────────────── */
::selection {
background: color-mix(in srgb, var(--ethos-accent) 35%, transparent);
color: var(--ethos-text);
}
/* ── Scrollbars (thin, themed) ────────────────────────────────────────────── */
* {
scrollbar-width: thin;
scrollbar-color: var(--ethos-border) transparent;
}
*::-webkit-scrollbar { width: 8px; height: 8px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--ethos-border) 80%, transparent);
border-radius: 9999px;
border: 2px solid transparent;
background-clip: padding-box;
}
*::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--ethos-muted) 60%, transparent);
background-clip: padding-box;
}
/* ── Smooth scroll for main content ───────────────────────────────────────── */
html { scroll-behavior: smooth; }
/* ── Utility classes ──────────────────────────────────────────────────────── */
/* Surfaced bar (top bar / playback bar) — depth from light, not blur */
.glass {
background: var(--ethos-bg1);
border-top: 1px solid var(--ethos-border);
box-shadow: 0 2px 8px rgba(0,0,0,.35), 0 12px 32px rgba(0,0,0,.28);
}
/* Hide scrollbars but keep scroll behavior */
.scrollbar-hide {
scrollbar-width: none;
-ms-overflow-style: none;
}
.scrollbar-hide::-webkit-scrollbar { display: none; }
/* Skeleton shimmer */
@keyframes shimmer {
0% { opacity: 0.4; }
50% { opacity: 0.8; }
100% { opacity: 0.4; }
}
.skeleton {
animation: shimmer 1.5s ease-in-out infinite;
background: var(--ethos-surface1);
border-radius: 4px;
}
/* ── Component base classes ────────────────────────────────────────────────── */
/* Card surface — standard container */
.card-surface {
display: flex;
flex-direction: column;
gap: 12px;
border-radius: 8px;
border: 1px solid var(--ethos-border);
background: var(--ethos-surface0);
padding: 12px;
transition: background 150ms ease, border-color 150ms ease;
}
.card-surface:hover {
background: var(--ethos-surface1);
border-color: color-mix(in srgb, var(--ethos-accent) 40%, transparent);
}
/* Artwork frame */
.artwork-frame {
aspect-ratio: 1 / 1;
border-radius: 8px;
overflow: hidden;
}
/* Play button overlay */
.play-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: flex-end;
justify-content: flex-end;
padding: 8px;
background: linear-gradient(to top, rgba(0,0,0,0.5), transparent 60%);
opacity: 0;
transition: opacity 150ms ease;
}
.group:hover .play-overlay { opacity: 1; }
.play-overlay-btn {
width: 44px;
height: 44px;
border-radius: 9999px;
background: var(--ethos-accent);
display: flex;
align-items: center;
justify-content: center;
color: var(--ethos-on-accent);
box-shadow: 0 8px 24px -4px color-mix(in srgb, var(--ethos-accent) 40%, transparent);
transform: translateY(8px);
transition: transform 150ms ease;
}
.group:hover .play-overlay-btn { transform: translateY(0); }
/* Transport button */
.transport-btn {
width: 40px;
height: 40px;
border-radius: 9999px;
background: var(--ethos-accent);
display: flex;
align-items: center;
justify-content: center;
color: var(--ethos-on-accent);
transition: background 150ms ease, transform 150ms ease;
}
.transport-btn:hover { background: var(--ethos-accent-hover); transform: scale(1.06); }
.transport-btn:active { transform: scale(0.94); }
.transport-btn:disabled { opacity: 0.4; }
/* Gradient text — used sparingly for page headings */
.text-gradient {
background: linear-gradient(120deg, var(--ethos-text), color-mix(in srgb, var(--ethos-accent) 75%, var(--ethos-text)));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/* Range slider styling */
input[type='range'] {
-webkit-appearance: none;
appearance: none;
background: transparent;
cursor: pointer;
}
input[type='range']::-webkit-slider-runnable-track {
height: 4px;
border-radius: 9999px;
background: color-mix(in srgb, var(--ethos-border) 90%, transparent);
}
input[type='range']::-moz-range-track {
height: 4px;
border-radius: 9999px;
background: color-mix(in srgb, var(--ethos-border) 90%, transparent);
}
input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
border-radius: 9999px;
background: var(--ethos-accent);
margin-top: -4px;
transition: transform 150ms ease;
}
input[type='range']:hover::-webkit-slider-thumb { transform: scale(1.15); }
input[type='range']::-moz-range-thumb {
width: 12px; height: 12px; border: none;
border-radius: 9999px; background: var(--ethos-accent);
}
input[type='range']:focus-visible {
outline: none;
}
input[type='range']:focus-visible::-webkit-slider-thumb {
box-shadow: 0 0 0 6px color-mix(in srgb, var(--ethos-accent) 30%, transparent);
}
input[type='range']:focus-visible::-moz-range-thumb {
box-shadow: 0 0 0 6px color-mix(in srgb, var(--ethos-accent) 30%, transparent);
}
+30
View File
@@ -0,0 +1,30 @@
/**
* Shared colour utilities.
*
* `hueFromString` is used by `Artwork` (gradient placeholders) and `Genres`
* (genre-card gradients). Previously duplicated in both files — extracted here
* as the single source of truth.
*/
/** Deterministic hash → hue (0..359) from an arbitrary string. */
export function hueFromString(s: string): number {
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
return Math.abs(h) % 360;
}
/**
* Build a two-stop diagonal HSL gradient from a seed string. Used by genre
* cards and artwork placeholders so they get a distinct but deterministic
* colour per name.
*/
export function gradientFromSeed(
seed: string,
sat = 48,
light = 23,
light2 = 11,
hueOffset = 55,
): string {
const hue = hueFromString(seed);
return `linear-gradient(160deg, hsl(${hue},${sat}%,${light}%), hsl(${(hue + hueOffset) % 360},${Math.max(sat - 8, 20)}%,${light2}%))`;
}
+67
View File
@@ -0,0 +1,67 @@
// Parse LRC-format synced lyrics into timestamped lines.
//
// The worker stores lrclib's `syncedLyrics` (a raw LRC string) JSON-encoded in
// the track_lyrics.synced_lyrics JSONB column, so it comes back over the API as
// a JSON string. LRC looks like:
// [ar:Artist] <- metadata tag, ignored
// [00:12.34]First line
// [00:15.80]Second line
// [01:02.5] <- empty line (instrumental gap) kept as ''
// A single text line can carry several timestamps ("[00:01][00:05]chorus").
export interface LyricLine {
/** Start time in seconds. */
time: number;
text: string;
}
const TIMESTAMP_RE = /\[(\d{1,2}):(\d{2}(?:[.:]\d{1,3})?)\]/g;
/**
* Parse an LRC string into time-sorted lines. Returns [] when the input has no
* timestamped lines (e.g. plain-text lyrics or null), so callers can fall back.
*/
export function parseLrc(input: unknown): LyricLine[] {
if (typeof input !== 'string' || input.trim() === '') return [];
const lines: LyricLine[] = [];
for (const raw of input.split(/\r?\n/)) {
TIMESTAMP_RE.lastIndex = 0;
const stamps: number[] = [];
let match: RegExpExecArray | null;
let lastEnd = 0;
while ((match = TIMESTAMP_RE.exec(raw)) !== null) {
const min = parseInt(match[1], 10);
const sec = parseFloat(match[2].replace(':', '.'));
stamps.push(min * 60 + sec);
lastEnd = match.index + match[0].length;
}
if (stamps.length === 0) continue; // metadata tag or untimed line
const text = raw.slice(lastEnd).trim();
for (const time of stamps) lines.push({ time, text });
}
lines.sort((a, b) => a.time - b.time);
return lines;
}
/**
* Index of the active line for a given playback position (the last line whose
* timestamp is <= position). Returns -1 before the first line. Lines must be
* time-sorted (as returned by parseLrc).
*/
export function activeLineIndex(lines: LyricLine[], position: number): number {
let lo = 0;
let hi = lines.length - 1;
let result = -1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (lines[mid].time <= position) {
result = mid;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
return result;
}
+49
View File
@@ -0,0 +1,49 @@
/**
* Muzick's fixed Ethos fingerprint: honey amber accent on the warm-brown
* neutral ramp. Ethos apps get exactly one accent — no runtime theme
* picker — so these are applied once at boot, not user-selectable.
*/
const ETHOS_VARS: Record<string, string> = {
'--ethos-bg0': '#14110D',
'--ethos-bg1': '#1B1712',
'--ethos-bg2': '#221D17',
'--ethos-surface0': '#2C261D',
'--ethos-surface1': '#372F24',
'--ethos-surface2': '#423927',
'--ethos-border': 'rgba(244,234,220,0.09)',
'--ethos-text': '#F4EEE4',
'--ethos-secondary': '#B4AA98',
'--ethos-muted': '#756C5C',
'--ethos-disabled': '#5a5347',
'--ethos-accent': '#EDA24E',
'--ethos-accent-hover': '#E08F32',
'--ethos-on-accent': '#14110D',
'--ethos-green': '#22c55e',
'--ethos-amber': '#eab308',
'--ethos-red': '#ef4444',
'--ethos-purple': '#a855f7',
'--ethos-cyan': '#22d3ee',
'--ethos-orange': '#f97316',
};
export const STORAGE_KEYS = {
volume: 'muzick.settings.volume',
} as const;
export function initTheme(): void {
const root = document.documentElement;
for (const [key, value] of Object.entries(ETHOS_VARS)) {
root.style.setProperty(key, value);
}
}
export function readStoredVolume(fallback: number): number {
try {
const stored = localStorage.getItem(STORAGE_KEYS.volume);
if (stored !== null) {
const parsed = Number(stored);
if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 1) return parsed;
}
} catch { /* ignore */ }
return fallback;
}
+29
View File
@@ -0,0 +1,29 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RouterProvider } from '@tanstack/react-router';
import { router } from './router';
import { initTheme } from './lib/theme';
import './index.css';
// Apply the persisted theme before first paint so the whole app is themed.
initTheme();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Per-query staleTime is tuned later (aggressive for static data). This is
// just a sane baseline.
staleTime: 30_000,
refetchOnWindowFocus: false,
},
},
});
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</React.StrictMode>
);
+81
View File
@@ -0,0 +1,81 @@
import { useQuery } from '@tanstack/react-query';
import { Play, AlertCircle, Music } from 'lucide-react';
import { albumDetailRoute } from '../router';
import { albumService } from '../services/albumService';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { Artwork } from '../components/Artwork';
import { TrackRow } from '../components/TrackRow';
import { ArtistLinks } from '../components/ArtistLinks';
import { BackLink } from '../components/BackLink';
import { Button } from '../components/ethos/Button';
import { PageContainer } from '../components/PageContainer';
import { EmptyState } from '../components/EmptyState';
import { LoadingState } from '../components/LoadingState';
import type { AlbumWithTracks } from '../types';
export default function AlbumDetail() {
const { albumId } = albumDetailRoute.useParams();
const { setQueue, playTrack } = usePlaybackStore();
const { data, isLoading, isError } = useQuery<AlbumWithTracks>({
queryKey: ['album', albumId],
queryFn: () => albumService.getAlbum(albumId),
});
if (isLoading) return <LoadingState className="py-20" />;
if (isError || !data) return <EmptyState icon={<AlertCircle size={28} />} title="Couldn't load album" subtitle="Something went wrong. Try reloading the page." />;
// Tracks inherit the album's artwork when they lack their own — this is the
// most common case since artwork is stored at the album level, not per-track.
const tracks = (data.tracks ?? []).map((t) => ({
...t,
artwork_id: t.artwork_id || data.artwork_id,
}));
// Distinct artists across the album's tracks. The album's primary artist
// (artist_id) comes first, then remaining mains, then featured guests.
const artistMap = new Map<string, { id: string; name: string; role: 'main' | 'featured' }>();
for (const t of tracks) {
for (const a of t.artists ?? []) {
const existing = artistMap.get(a.id);
// Prefer 'main' if seen in any role.
if (!existing || (existing.role === 'featured' && a.role === 'main')) {
artistMap.set(a.id, { id: a.id, name: a.name, role: a.role });
}
}
}
const artists = [...artistMap.values()].sort((x, y) => {
const rank = (a: typeof x) => (a.id === data.artist_id ? 0 : a.role === 'main' ? 1 : 2);
return rank(x) - rank(y);
});
return (
<PageContainer className="space-y-8">
<BackLink to="/albums" label="Albums" />
<div className="flex items-end gap-5">
<div className="w-40 h-40 flex-none rounded-xl overflow-hidden">
<Artwork seed={data.title} src={data.artwork_id} className="w-full h-full" rounded="xl" eager />
</div>
<div className="space-y-2">
<h1 className="text-4xl font-bold text-text">{data.title}</h1>
{artists.length > 0 && (
<ArtistLinks
artists={artists}
fallback="Unknown artist"
className="flex flex-wrap items-center gap-x-1 gap-y-0.5 text-sm"
/>
)}
<p className="text-sm text-muted">{data.year ? `${data.year} · ` : ''}{tracks.length} {tracks.length === 1 ? 'track' : 'tracks'}</p>
<Button
variant="primary"
icon={<Play size={16} fill="currentColor" />}
onClick={() => { if (tracks.length) { setQueue(tracks); playTrack(tracks[0]); } }}
disabled={!tracks.length}
>
Play album
</Button>
</div>
</div>
<section className="space-y-1">
{tracks.length === 0 ? <EmptyState compact icon={<Music size={28} />} title="No tracks in this album" />
: tracks.map((t, i) => <TrackRow key={t.id} track={t} queue={tracks} index={i} />)}
</section>
</PageContainer>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { useState } from 'react';
import { useQuery, keepPreviousData } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { Disc3, AlertCircle } from 'lucide-react';
import { albumService } from '../services/albumService';
import { Artwork } from '../components/Artwork';
import { PageContainer } from '../components/PageContainer';
import { PageHeader } from '../components/PageHeader';
import { Pagination } from '../components/Pagination';
import { EmptyState } from '../components/EmptyState';
import { SkeletonGrid } from '../components/LoadingState';
import type { Album } from '../types';
const PAGE_SIZE = 50;
export default function Albums() {
const [page, setPage] = useState(0);
const { data, isLoading, isError, isPlaceholderData } = useQuery<Album[]>({
queryKey: ['albums', page],
queryFn: () => albumService.listAlbums({ limit: PAGE_SIZE, offset: page * PAGE_SIZE }),
placeholderData: keepPreviousData,
});
const albums = data ?? [];
const hasNext = albums.length === PAGE_SIZE;
return (
<PageContainer>
<PageHeader icon={Disc3} title="Albums" subtitle={albums.length ? `${albums.length} on this page` : undefined} />
{isLoading ? <SkeletonGrid count={10} />
: isError ? <EmptyState compact icon={<AlertCircle size={28} />} title="Couldn't load albums" subtitle="Something went wrong. Try reloading the page." />
: !albums?.length ? <EmptyState compact icon={<Disc3 size={28} />} title="No albums yet" subtitle="Run a library scan in Settings to populate it." />
: (
<>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{albums.map((album) => (
<Link key={album.id} to="/albums/$albumId" params={{ albumId: album.id }}
className="card-surface group">
<div className="artwork-frame">
<Artwork seed={album.title} src={album.artwork_id} className="w-full h-full transition-transform duration-500 group-hover:scale-105" rounded="xl" />
</div>
<div>
<div className="truncate text-sm font-semibold text-text">{album.title}</div>
<div className="truncate text-xs text-muted mt-0.5">
{album.artist_name || 'Unknown artist'}{album.year ? ` · ${album.year}` : ''}
</div>
</div>
</Link>
))}
</div>
<Pagination
page={page}
hasNext={hasNext}
isLoading={isPlaceholderData}
onPrev={() => setPage((p) => Math.max(0, p - 1))}
onNext={() => setPage((p) => p + 1)}
/>
</>
)}
</PageContainer>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { useQuery } from '@tanstack/react-query';
import { AlertCircle, Disc3 } from 'lucide-react';
import { artistDetailRoute } from '../router';
import { artistService } from '../services/artistService';
import { Artwork } from '../components/Artwork';
import { BackLink } from '../components/BackLink';
import { PageContainer } from '../components/PageContainer';
import { EmptyState } from '../components/EmptyState';
import { LoadingState } from '../components/LoadingState';
import { Link } from '@tanstack/react-router';
import type { ArtistWithAlbums } from '../types';
export default function ArtistDetail() {
const { artistId } = artistDetailRoute.useParams();
const { data, isLoading, isError } = useQuery<ArtistWithAlbums>({
queryKey: ['artist', artistId],
queryFn: () => artistService.getArtist(artistId),
});
if (isLoading) return <LoadingState className="py-20" />;
if (isError || !data) return <EmptyState icon={<AlertCircle size={28} />} title="Couldn't load artist" subtitle="Something went wrong. Try reloading the page." />;
const albums = data.albums ?? [];
return (
<PageContainer width="lg" className="space-y-8">
<BackLink to="/artists" label="Artists" />
{/* Hero: blurred backdrop of the artist image + avatar + name */}
<div className="relative overflow-hidden rounded-3xl border border-border/70 animate-rise">
<div className="absolute inset-0">
<Artwork seed={data.name} src={data.image_path} className="h-full w-full scale-110 blur-2xl opacity-40" eager />
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/70 to-background/30" />
</div>
<div className="relative flex items-end gap-5 p-6 sm:p-8">
<div className="h-28 w-28 sm:h-36 sm:w-36 flex-none rounded-full overflow-hidden ring-4 ring-background shadow-2xl shadow-black/50">
<Artwork seed={data.name} src={data.image_path} className="h-full w-full" rounded="full" eager />
</div>
<div className="min-w-0 pb-1">
<div className="text-xs font-semibold uppercase tracking-wider text-muted">Artist</div>
<h1 className="text-gradient text-4xl sm:text-5xl font-extrabold tracking-tight truncate">{data.name}</h1>
<p className="mt-1.5 text-sm text-muted">{albums.length} {albums.length === 1 ? 'album' : 'albums'}</p>
</div>
</div>
</div>
<section className="space-y-4">
<h2 className="text-xl font-semibold text-text">Albums</h2>
{albums.length === 0 ? <EmptyState compact icon={<Disc3 size={28} />} title="No albums by this artist" /> : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{albums.map((album) => (
<Link key={album.id} to="/albums/$albumId" params={{ albumId: album.id }}
className="card-surface group">
<div className="artwork-frame">
<Artwork seed={`${album.title} ${data.name}`} src={album.artwork_id} className="h-full w-full transition-transform duration-500 group-hover:scale-105" rounded="xl" />
</div>
<div>
<div className="truncate text-sm font-semibold text-text">{album.title}</div>
{album.year && <div className="text-xs text-muted mt-0.5">{album.year}</div>}
</div>
</Link>
))}
</div>
)}
</section>
</PageContainer>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { useState } from 'react';
import { useQuery, keepPreviousData } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { Users, AlertCircle } from 'lucide-react';
import { artistService } from '../services/artistService';
import { Artwork } from '../components/Artwork';
import { PageContainer } from '../components/PageContainer';
import { PageHeader } from '../components/PageHeader';
import { Pagination } from '../components/Pagination';
import { EmptyState } from '../components/EmptyState';
import { SkeletonGrid } from '../components/LoadingState';
import type { Artist } from '../types';
const PAGE_SIZE = 50;
export default function Artists() {
const [page, setPage] = useState(0);
const { data, isLoading, isError, isPlaceholderData } = useQuery<Artist[]>({
queryKey: ['artists', page],
queryFn: () => artistService.listArtists({ limit: PAGE_SIZE, offset: page * PAGE_SIZE }),
placeholderData: keepPreviousData,
});
const artists = data ?? [];
const hasNext = artists.length === PAGE_SIZE;
return (
<PageContainer>
<PageHeader icon={Users} title="Artists" subtitle="Browse by artist" />
{isLoading ? <SkeletonGrid count={10} />
: isError ? <EmptyState compact icon={<AlertCircle size={28} />} title="Couldn't load artists" subtitle="Something went wrong. Try reloading the page." />
: !artists?.length ? <EmptyState compact icon={<Users size={28} />} title="No artists yet" subtitle="Run a library scan in Settings to populate it." />
: (
<>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{artists.map((artist) => (
<Link key={artist.id} to="/artists/$artistId" params={{ artistId: artist.id }}
className="card-surface group items-center p-4">
<div className="w-24 h-24 rounded-full overflow-hidden ring-2 ring-transparent group-hover:ring-accent/40 shadow-lg shadow-black/30 transition-all">
<Artwork seed={artist.name} src={artist.image_path} className="w-full h-full transition-transform duration-500 group-hover:scale-105" rounded="full" />
</div>
<div className="text-sm font-semibold text-text truncate w-full text-center group-hover:text-accent transition-colors">{artist.name}</div>
</Link>
))}
</div>
<Pagination
page={page}
hasNext={hasNext}
isLoading={isPlaceholderData}
onPrev={() => setPage((p) => Math.max(0, p - 1))}
onNext={() => setPage((p) => p + 1)}
/>
</>
)}
</PageContainer>
);
}
+120
View File
@@ -0,0 +1,120 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Disc3, Sparkles } from 'lucide-react';
import { genreService } from '../services/genreService';
import { vibeService } from '../services/vibeService';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { useVibeStore } from '../store/useVibeStore';
import { TrackRow } from '../components/TrackRow';
import { PageContainer } from '../components/PageContainer';
import type { Genre, Track } from '../types';
export default function Discover() {
const [selected, setSelected] = useState<Genre | null>(null);
const { setQueue, playTrack } = usePlaybackStore();
const { setActiveSession, setSeedTrackId, setBuffer } = useVibeStore();
const genres = useQuery<Genre[]>({
queryKey: ['genres'],
queryFn: () => genreService.listGenres(),
});
const genreTracks = useQuery<Track[]>({
queryKey: ['genre-tracks', selected?.id],
queryFn: () => genreService.getGenreTracks(selected!.id),
enabled: !!selected,
});
const startGenreVibe = async () => {
const tracks = genreTracks.data;
if (!tracks || tracks.length === 0) return;
const seed = tracks[0];
try {
const { sessionId } = await vibeService.start(seed.id);
setActiveSession({ sessionId, seedTrackId: seed.id });
setSeedTrackId(seed.id);
setBuffer(tracks);
setQueue(tracks);
playTrack(tracks[0]);
} catch {
setQueue(tracks);
playTrack(tracks[0]);
}
};
return (
<PageContainer>
<header className="space-y-1">
<h1 className="text-3xl font-bold text-text">Discover</h1>
<p className="text-muted">Browse by genre, then play tracks or start a vibe.</p>
</header>
{genres.isLoading ? (
<p className="text-sm text-muted">Loading genres</p>
) : genres.isError ? (
<p className="text-sm text-muted">Couldn't load genres.</p>
) : (genres.data ?? []).length === 0 ? (
<p className="text-sm text-muted">No genres available yet.</p>
) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{(genres.data ?? []).map((genre) => {
const active = selected?.id === genre.id;
return (
<button
key={genre.id}
onClick={() => setSelected(genre)}
className={`flex flex-col gap-2 rounded-xl border p-4 text-left transition-colors ${
active
? 'border-accent/60 bg-accent/10'
: 'border-border bg-surface0 hover:bg-surface1'
}`}
>
<Disc3 size={22} className={active ? 'text-accent' : 'text-muted'} />
<div className="truncate font-semibold text-text">{genre.name}</div>
{typeof genre.track_count === 'number' && (
<div className="text-xs text-muted">{genre.track_count} tracks</div>
)}
</button>
);
})}
</div>
)}
{selected && (
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-text">{selected.name}</h2>
<button
onClick={() => void startGenreVibe()}
disabled={!genreTracks.data || genreTracks.data.length === 0}
className="flex items-center gap-2 rounded-lg border border-accent/60 bg-accent/10 px-3 py-1.5 text-sm font-medium text-accent transition-colors hover:bg-accent/20 disabled:opacity-50"
>
<Sparkles size={16} />
Start a vibe
</button>
</div>
{genreTracks.isLoading ? (
<p className="text-sm text-muted">Loading tracks…</p>
) : genreTracks.isError ? (
<p className="text-sm text-muted">Couldn't load tracks for this genre.</p>
) : (genreTracks.data ?? []).length === 0 ? (
<p className="text-sm text-muted">No tracks found for this genre.</p>
) : (
<div className="space-y-1">
{(genreTracks.data ?? []).map((track, i) => (
<TrackRow
key={`${track.id}-${i}`}
track={track}
queue={genreTracks.data ?? []}
index={i}
showActions={false}
/>
))}
</div>
)}
</section>
)}
</PageContainer>
);
}
+129
View File
@@ -0,0 +1,129 @@
import { useState } from 'react';
import { useQuery, keepPreviousData } from '@tanstack/react-query';
import { Tag, Play, AlertCircle, Music } from 'lucide-react';
import { genreService, GetGenreTracksParams } from '../services/genreService';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { TrackRow } from '../components/TrackRow';
import { BackLink } from '../components/BackLink';
import { Button } from '../components/ethos/Button';
import { PageContainer } from '../components/PageContainer';
import { Pagination } from '../components/Pagination';
import { EmptyState } from '../components/EmptyState';
import { SkeletonRows, SkeletonGrid } from '../components/LoadingState';
import { hueFromString } from '../lib/color';
import type { Genre, Track } from '../types';
const GENRE_TRACKS_PAGE_SIZE = 50;
export default function Genres() {
const [selected, setSelected] = useState<Genre | null>(null);
const [genrePage, setGenrePage] = useState(0);
const { setQueue, playTrack } = usePlaybackStore();
const genresQ = useQuery<Genre[]>({ queryKey: ['genres'], queryFn: () => genreService.listGenres() });
const params: GetGenreTracksParams = { limit: GENRE_TRACKS_PAGE_SIZE, offset: genrePage * GENRE_TRACKS_PAGE_SIZE };
const tracksQ = useQuery<Track[]>({
queryKey: ['genre-tracks', selected?.id, genrePage],
queryFn: () => genreService.getGenreTracks(selected!.id, params),
enabled: !!selected,
placeholderData: keepPreviousData,
});
if (selected) {
const tracks = tracksQ.data ?? [];
const hasNext = tracks.length === GENRE_TRACKS_PAGE_SIZE;
return (
<PageContainer>
<BackLink to="/genres" label="Genres" />
<div className="flex items-center justify-between">
<h1 className="flex items-center gap-3 text-3xl font-bold text-text"><Tag size={28} className="text-accent" />{selected.name}</h1>
<Button
variant="primary"
icon={<Play size={16} fill="currentColor" />}
onClick={() => { if (tracks.length) { setQueue(tracks); playTrack(tracks[0]); } }}
disabled={!tracks.length}
>
Play all
</Button>
</div>
{tracksQ.isLoading ? <SkeletonRows count={8} />
: tracks.length === 0 ? <EmptyState compact icon={<Music size={28} />} title="No tracks in this genre" />
: (
<>
<div className="space-y-1">{tracks.map((t, i) => <TrackRow key={t.id} track={t} queue={tracks} index={i} />)}</div>
<Pagination
page={genrePage}
hasNext={hasNext}
isLoading={tracksQ.isPlaceholderData}
onPrev={() => setGenrePage((p) => Math.max(0, p - 1))}
onNext={() => setGenrePage((p) => p + 1)}
/>
</>
)}
</PageContainer>
);
}
const genres = genresQ.data ?? [];
const roots = genres.filter((g) => !g.parent_id);
const childrenOf = (id: string) => genres.filter((g) => g.parent_id === id);
return (
<PageContainer>
<h1 className="flex items-center gap-3 text-3xl font-bold text-text"><Tag size={28} className="text-accent" />Genres</h1>
{genresQ.isLoading ? <SkeletonGrid count={10} />
: genresQ.isError ? <EmptyState compact icon={<AlertCircle size={28} />} title="Couldn't load genres" subtitle="Something went wrong. Try reloading the page." />
: !genres.length ? <EmptyState compact icon={<Tag size={28} />} title="No genres yet" subtitle="Genres appear after you scan and enrich your library." />
: (
<div className="space-y-6">
{roots.map((genre) => {
const hue = hueFromString(genre.name);
const subs = childrenOf(genre.id);
return (
<div key={genre.id} className="space-y-2">
<button onClick={() => setSelected(genre)}
className="group flex items-center gap-3 rounded-xl border border-border/70 p-4 w-full text-left transition-colors hover:border-accent/40"
style={{ background: `linear-gradient(135deg, hsl(${hue},40%,15%), hsl(${(hue+60)%360},30%,10%))` }}>
<Tag size={20} className="text-muted group-hover:text-accent flex-none" />
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-text">{genre.name}</div>
<div className="text-xs text-muted">{genre.track_count ?? 0} tracks</div>
</div>
</button>
{subs.length > 0 && (
<div className="ml-6 grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4">
{subs.map((sub) => {
const subHue = hueFromString(sub.name);
return (
<button key={sub.id} onClick={() => setSelected(sub)}
className="group flex flex-col items-start gap-1 rounded-lg border border-border/70 p-3 text-left transition-colors hover:border-accent/40"
style={{ background: `linear-gradient(135deg, hsl(${subHue},30%,12%), hsl(${(subHue+60)%360},25%,8%))` }}>
<div className="w-full truncate text-sm font-medium text-text">{sub.name}</div>
<div className="text-xs text-muted">{sub.track_count ?? 0} tracks</div>
</button>
);
})}
</div>
)}
</div>
);
})}
{/* Orphan genres (parent_id set but parent not in list) fall back to flat display */}
{genres.filter((g) => g.parent_id && !genres.find((p) => p.id === g.parent_id)).map((genre) => {
const hue = hueFromString(genre.name);
return (
<button key={genre.id} onClick={() => setSelected(genre)}
className="group flex flex-col items-start gap-2 rounded-xl border border-border/70 p-4 text-left transition-colors hover:border-accent/40"
style={{ background: `linear-gradient(135deg, hsl(${hue},40%,15%), hsl(${(hue+60)%360},30%,10%))` }}>
<Tag size={20} className="text-muted group-hover:text-accent" />
<div className="w-full truncate font-medium text-text">{genre.name}</div>
<div className="text-xs text-muted">{genre.track_count ?? 0} tracks</div>
</button>
);
})}
</div>
)}
</PageContainer>
);
}
+128
View File
@@ -0,0 +1,128 @@
import { useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { Zap, Music, Disc3, Users } from 'lucide-react';
import { ShelfRow } from '../components/ShelfRow';
import { MediaCard } from '../components/MediaCard';
import { PageContainer } from '../components/PageContainer';
import { Skeleton } from '../components/LoadingState';
import { historyService } from '../services/historyService';
import { trackService } from '../services/trackService';
import { usePlaybackStore } from '../store/usePlaybackStore';
import type { HistoryEntry, Track } from '../types';
const SHORTCUTS = [
{ label: 'Songs', icon: Music, to: '/tracks' as const },
{ label: 'Albums', icon: Disc3, to: '/albums' as const },
{ label: 'Artists', icon: Users, to: '/artists' as const },
];
/** A row of placeholder cards matching MediaCard's shelf width. */
function ShelfSkeleton({ count = 6 }: { count?: number }) {
return (
<div className="flex gap-4 pb-2">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="w-36 shrink-0">
<Skeleton className="aspect-square rounded-xl" />
<Skeleton className="mt-2 h-3 w-3/4" />
<Skeleton className="mt-1.5 h-2.5 w-1/2" />
</div>
))}
</div>
);
}
export default function Home() {
const { setQueue, playTrack } = usePlaybackStore();
const history = useQuery<HistoryEntry[]>({
queryKey: ['history'],
queryFn: () => historyService.list(),
});
const mostPlayed = useQuery<Track[]>({
queryKey: ['most-played'],
queryFn: () => trackService.listTracks({ limit: 12, sort_by: 'play_count', order: 'DESC' }),
});
const playFrom = (list: Track[], index: number) => {
setQueue(list.slice(index));
playTrack(list[index]);
};
const historyTracks: Track[] = (history.data ?? []).slice(0, 12);
return (
<PageContainer width="lg" className="space-y-8">
<div className="animate-rise">
<h1 className="text-gradient text-4xl font-extrabold tracking-tight">Good listening</h1>
<p className="text-muted mt-1.5">Your music, your way.</p>
</div>
{/* Vibe hero + quick shortcuts */}
<section className="grid gap-3 sm:grid-cols-[2fr_3fr]">
<Link
to="/vibe"
className="group relative overflow-hidden rounded-2xl border border-accent/30 bg-gradient-to-br from-accent/30 to-accent/5 p-5 flex flex-col justify-between min-h-[7rem] transition-all hover:-translate-y-0.5 hover:shadow-xl hover:shadow-accent/10"
>
<Zap size={22} className="text-accent transition-transform group-hover:scale-110" />
<div>
<div className="text-lg font-bold text-text">Start a Vibe</div>
<div className="text-xs text-muted">Endless recommendations from your library.</div>
</div>
</Link>
<div className="grid grid-cols-3 gap-3">
{SHORTCUTS.map(({ label, icon: Icon, to }) => (
<Link
key={to}
to={to}
className="group flex flex-col items-center justify-center gap-2 rounded-2xl border border-border/70 bg-surface0/60 p-4 transition-all hover:-translate-y-0.5 hover:border-accent/40 hover:bg-surface1"
>
<Icon size={22} className="text-muted transition-colors group-hover:text-accent" />
<span className="text-sm font-semibold text-text">{label}</span>
</Link>
))}
</div>
</section>
<ShelfRow title="Continue Listening" viewAllTo="/tracks">
{history.isLoading ? (
<ShelfSkeleton />
) : historyTracks.length === 0 ? (
<p className="text-sm text-muted py-4">Nothing played yet.</p>
) : (
historyTracks.map((track, i) => (
<div key={`${track.id}-${i}`} className="w-36 shrink-0">
<MediaCard
seed={`${track.title} ${track.artist}`}
title={track.title}
subtitle={track.artist}
artSrc={track.artwork_id}
onClick={() => playFrom(historyTracks, i)}
/>
</div>
))
)}
</ShelfRow>
<ShelfRow title="Most Played" viewAllTo="/tracks">
{mostPlayed.isLoading ? (
<ShelfSkeleton />
) : (mostPlayed.data ?? []).length === 0 ? (
<p className="text-sm text-muted py-4">No tracks yet.</p>
) : (
(mostPlayed.data ?? []).map((track, i) => (
<div key={track.id} className="w-36 shrink-0">
<MediaCard
seed={`${track.title} ${track.artist}`}
title={track.title}
subtitle={`${track.play_count} plays`}
artSrc={track.artwork_id}
onClick={() => playFrom(mostPlayed.data!, i)}
/>
</div>
))
)}
</ShelfRow>
</PageContainer>
);
}
+478
View File
@@ -0,0 +1,478 @@
import { useQuery } from '@tanstack/react-query';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Activity,
Play,
Pause,
RotateCcw,
Terminal,
CheckCircle,
AlertCircle,
Clock,
ChevronDown,
ChevronRight,
Copy,
Filter,
X,
RefreshCw,
} from 'lucide-react';
import {
jobsService,
JobHistoryEntry,
JobStatus,
JOB_LABELS,
getJobDataSummary,
getJobDataDetails,
getJobStatus,
} from '../services/jobsService';
import { formatDistanceToNow, format } from 'date-fns';
/* ─────────────────────────────────────────── Filter state ─────────────────────────────────────────── */
interface Filters {
status: JobStatus | 'all';
type: string;
}
const INITIAL_FILTERS: Filters = { status: 'all', type: 'all' };
/* ─────────────────────────────────────────── Helpers ─────────────────────────────────────────────── */
const JOB_TYPES = Object.keys(JOB_LABELS);
function statusBgColor(status: JobStatus): string {
switch (status) {
case 'running':
return 'bg-accent/10 border-accent/25';
case 'failed':
return 'bg-red-400/10 border-red-400/25';
case 'completed':
return 'bg-green-400/10 border-green-400/25';
}
}
function formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainSec = seconds % 60;
return `${minutes}m ${remainSec}s`;
}
/* ─────────────────────────────────────────── Sub-components ──────────────────────────────────────── */
function StatCard({ icon: Icon, label, value, color }: { icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>; label: string; value: number; color: string }) {
return (
<div className="bg-surface0 border border-border rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted uppercase tracking-wide">{label}</p>
<p className="text-3xl font-bold mt-1" style={{ color }}>{value.toLocaleString()}</p>
</div>
<Icon className="w-8 h-8 opacity-30" style={{ color }} />
</div>
</div>
);
}
function StatusBadge({ status }: { status: JobStatus }) {
const dotColor = status === 'running' ? 'bg-accent' : status === 'failed' ? 'bg-red-400' : 'bg-green-400';
const label = status === 'running' ? 'Running' : status === 'failed' ? 'Failed' : 'Completed';
return (
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${statusBgColor(status)}`}>
<span className={`w-1.5 h-1.5 rounded-full ${dotColor} ${status === 'running' ? 'animate-pulse' : ''}`} />
{label}
</span>
);
}
function ExpandedDetail({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-start gap-2 text-xs">
<span className="text-muted shrink-0 w-28">{label}</span>
<span className="text-text font-mono break-all">{value || '—'}</span>
</div>
);
}
/* ─────────────────────────────────────────── Job row ─────────────────────────────────────────────── */
function JobRow({
job,
isExpanded,
onToggle,
}: {
job: JobHistoryEntry;
isExpanded: boolean;
onToggle: () => void;
}) {
const status = getJobStatus(job);
const summary = getJobDataSummary(job.name, job.data);
const details = useMemo(() => getJobDataDetails(job.name, job.data), [job.name, job.data]);
const duration = job.finishedOn && job.timestamp ? job.finishedOn - job.timestamp : null;
const hasReturnValue = job.returnvalue !== undefined && job.returnvalue !== null;
return (
<div
className={`bg-surface0 border border-border rounded-lg transition-colors ${
isExpanded ? 'border-accent/40' : 'hover:border-accent/30'
}`}
>
{/* ── collapsed row ── */}
<button
onClick={onToggle}
className="w-full flex items-center gap-3 px-4 py-3 text-left"
>
{/* status icon */}
<div className="shrink-0">
{status === 'running' && <Activity className="w-4 h-4 text-accent animate-spin" />}
{status === 'failed' && <AlertCircle className="w-4 h-4 text-red-400" />}
{status === 'completed' && <CheckCircle className="w-4 h-4 text-green-400" />}
</div>
{/* job name + summary */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{JOB_LABELS[job.name] || job.name}</span>
<StatusBadge status={status} />
</div>
{summary && (
<p className="text-xs text-muted mt-0.5 truncate max-w-md">{summary}</p>
)}
</div>
{/* timestamp + duration */}
<div className="hidden sm:flex flex-col items-end text-xs text-muted shrink-0">
<span>{formatDistanceToNow(new Date(job.timestamp), { addSuffix: true })}</span>
{duration !== null && (
<span className="opacity-60">{formatDuration(duration)}</span>
)}
</div>
{/* expand icon */}
<div className="shrink-0 text-muted">
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</div>
</button>
{/* ── expanded detail ── */}
{isExpanded && (
<div className="border-t border-border px-4 py-3 space-y-3">
{/* Job metadata */}
<div className="space-y-1">
<ExpandedDetail label="Job ID" value={job.id} />
<ExpandedDetail label="Created" value={format(new Date(job.timestamp), 'PPpp')} />
{job.finishedOn && (
<ExpandedDetail label="Finished" value={format(new Date(job.finishedOn), 'PPpp')} />
)}
{duration !== null && (
<ExpandedDetail label="Duration" value={formatDuration(duration)} />
)}
</div>
{/* Job-specific payload */}
{details.length > 0 && (
<div>
<p className="text-xs font-medium text-muted mb-1 uppercase tracking-wider">Payload</p>
<div className="space-y-1">
{details.map((d) => (
<ExpandedDetail key={d.label} label={d.label} value={d.value} />
))}
</div>
</div>
)}
{/* Raw data (collapsible) */}
{Object.keys(job.data).length > 0 && (
<RawDataBlock label="Raw Payload" data={job.data} />
)}
{/* Return value */}
{hasReturnValue && <RawDataBlock label="Return Value" data={job.returnvalue} />}
{/* Failed reason */}
{job.failedReason && (
<div>
<p className="text-xs font-medium text-red-400 mb-1 uppercase tracking-wider">Error</p>
<div className="bg-red-400/5 border border-red-400/20 rounded p-2">
<pre className="text-xs text-red-300 whitespace-pre-wrap font-mono">{job.failedReason}</pre>
</div>
</div>
)}
</div>
)}
</div>
);
}
/* ── Raw JSON expandable block ── */
function RawDataBlock({ label, data }: { label: string; data: unknown }) {
const [open, setOpen] = useState(false);
const text = JSON.stringify(data, null, 2);
return (
<div>
<button
onClick={() => setOpen((p) => !p)}
className="flex items-center gap-1 text-xs font-medium text-muted uppercase tracking-wider hover:text-text transition-colors"
>
{open ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
{label}
</button>
{open && (
<div className="relative mt-1">
<pre className="text-xs text-muted bg-bg1/50 border border-border/70 rounded p-2 overflow-x-auto max-h-48 font-mono">
{text}
</pre>
<button
onClick={() => navigator.clipboard.writeText(text)}
className="absolute top-1 right-1 p-1 rounded text-muted hover:text-text hover:bg-surface0 transition-colors"
title="Copy to clipboard"
>
<Copy className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
);
}
/* ─────────────────────────────────────────── Filter bar ──────────────────────────────────────────── */
function FilterBar({
filters,
onChange,
totalCount,
filteredCount,
}: {
filters: Filters;
onChange: (f: Filters) => void;
totalCount: number;
filteredCount: number;
}) {
const setStatus = (status: Filters['status']) => onChange({ ...filters, status });
const setType = (type: string) => onChange({ ...filters, type });
const hasActiveFilters = filters.status !== 'all' || filters.type !== 'all';
const statusOptions: { value: Filters['status']; label: string }[] = [
{ value: 'all', label: 'All' },
{ value: 'completed', label: 'Completed' },
{ value: 'failed', label: 'Failed' },
{ value: 'running', label: 'Running' },
];
const typeOptions = JOB_TYPES;
return (
<div className="flex items-center gap-3 flex-wrap">
{/* Status pills */}
<div className="flex items-center gap-1 bg-surface0 border border-border rounded-lg p-0.5">
{statusOptions.map((opt) => (
<button
key={opt.value}
onClick={() => setStatus(opt.value)}
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
filters.status === opt.value
? 'bg-accent/15 text-accent'
: 'text-muted hover:text-text'
}`}
>
{opt.label}
</button>
))}
</div>
{/* Type dropdown */}
<div className="relative">
<select
value={filters.type}
onChange={(e) => setType(e.target.value)}
className="appearance-none bg-surface0 border border-border rounded-lg px-3 py-1.5 pr-8 text-xs font-medium text-text cursor-pointer focus:outline-none focus:border-accent/50"
>
<option value="all">All Types</option>
{typeOptions.map((t) => (
<option key={t} value={t}>{JOB_LABELS[t] || t}</option>
))}
</select>
<Filter className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted" />
</div>
{/* Result count */}
<span className="text-xs text-muted">
{filteredCount} / {totalCount} jobs
</span>
{/* Clear filters */}
{hasActiveFilters && (
<button
onClick={() => onChange(INITIAL_FILTERS)}
className="flex items-center gap-1 text-xs text-muted hover:text-text transition-colors"
>
<X className="w-3 h-3" />
Clear
</button>
)}
</div>
);
}
/* ─────────────────────────────────────────── Page ────────────────────────────────────────────────── */
export default function JobsPage() {
const [autoRefresh, setAutoRefresh] = useState(true);
const [selectedTab, setSelectedTab] = useState<'overview' | 'history'>('overview');
const [filters, setFilters] = useState<Filters>(INITIAL_FILTERS);
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const { data: stats, refetch: refetchStats } = useQuery({
queryKey: ['queueStats'],
queryFn: () => jobsService.getQueueStats(),
refetchInterval: autoRefresh ? 3000 : false,
staleTime: 1000,
});
const { data: history, refetch: refetchHistory } = useQuery({
queryKey: ['jobHistory'],
queryFn: () => jobsService.getJobHistory(200),
refetchInterval: autoRefresh ? 5000 : false,
staleTime: 2000,
});
useEffect(() => {
if (selectedTab === 'overview') refetchStats();
else refetchHistory();
}, [selectedTab, refetchStats, refetchHistory]);
const toggleExpanded = useCallback((id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
// Filtered & sorted history
const filteredHistory = useMemo(() => {
if (!history) return [];
return history
.filter((job) => {
if (filters.status !== 'all' && getJobStatus(job) !== filters.status) return false;
if (filters.type !== 'all' && job.name !== filters.type) return false;
return true;
});
}, [history, filters]);
return (
<div className="flex-1 flex flex-col overflow-hidden">
{/* ── Header ── */}
<div className="flex items-center justify-between p-4 border-b border-border">
<div>
<h1 className="text-xl font-semibold">Jobs</h1>
<p className="text-sm text-muted">Background task queue monitoring</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => { refetchStats(); refetchHistory(); }}
className="flex items-center gap-1.5 text-xs text-muted hover:text-text transition-colors"
title="Refresh now"
>
<RefreshCw className="w-3.5 h-3.5" />
Refresh
</button>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
className="w-4 h-4 accent-accent"
/>
Auto-refresh
</label>
</div>
</div>
{/* ── Tabs ── */}
<div className="flex border-b border-border px-4">
<button
onClick={() => setSelectedTab('overview')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
selectedTab === 'overview' ? 'border-accent text-text' : 'border-transparent text-muted hover:text-text'
}`}
>
Overview
</button>
<button
onClick={() => setSelectedTab('history')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
selectedTab === 'history' ? 'border-accent text-text' : 'border-transparent text-muted hover:text-text'
}`}
>
History
</button>
</div>
{/* ── Content ── */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* ── Overview tab ── */}
{selectedTab === 'overview' && stats && (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<StatCard icon={Clock} label="Waiting" value={stats.waiting} color="#eab308" />
<StatCard icon={Play} label="Active" value={stats.active} color="#a78bfa" />
<StatCard icon={CheckCircle} label="Completed" value={stats.completed} color="#22c55e" />
<StatCard icon={AlertCircle} label="Failed" value={stats.failed} color="#f87171" />
<StatCard icon={RotateCcw} label="Delayed" value={stats.delayed} color="#60a5fa" />
<StatCard icon={Pause} label="Paused" value={stats.paused} color="#6b7280" />
</div>
)}
{/* ── History tab ── */}
{selectedTab === 'history' && (
<>
{/* Filter bar */}
{history && history.length > 0 && (
<FilterBar
filters={filters}
onChange={setFilters}
totalCount={history.length}
filteredCount={filteredHistory.length}
/>
)}
{/* Job list */}
{filteredHistory.length === 0 ? (
<div className="text-center py-16 text-muted">
<Terminal className="w-12 h-12 mx-auto mb-4 opacity-30" />
<p className="text-sm font-medium">
{history && history.length > 0
? 'No jobs match the current filters'
: 'No job history yet'}
</p>
{(history && history.length > 0) && (
<button
onClick={() => setFilters(INITIAL_FILTERS)}
className="mt-2 text-xs text-accent hover:underline"
>
Clear filters
</button>
)}
</div>
) : (
<div className="space-y-2">
{filteredHistory.map((job) => (
<JobRow
key={job.id}
job={job}
isExpanded={expandedIds.has(job.id)}
onToggle={() => toggleExpanded(job.id)}
/>
))}
</div>
)}
</>
)}
</div>
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ShieldAlert, RotateCcw, Trash2, Clock, AlertCircle } from 'lucide-react';
import { quarantineService } from '../services/quarantineService';
import { Badge } from '../components/ethos/Badge';
import { PageContainer } from '../components/PageContainer';
import { EmptyState } from '../components/EmptyState';
import { SkeletonRows } from '../components/LoadingState';
import { toast } from '../store/useToastStore';
import type { DislikeEntry } from '../types';
function countdown(entry: DislikeEntry): string {
const base = entry.warned_at
? new Date(entry.warned_at).getTime() + 24 * 3600 * 1000
: new Date(entry.disliked_at).getTime() + entry.grace_hours * 3600 * 1000;
const ms = base - Date.now();
if (ms <= 0) return 'Deleting soon';
const h = Math.floor(ms / 3600000);
const m = Math.floor((ms % 3600000) / 60000);
return h > 0 ? `${h}h ${m}m remaining` : `${m}m remaining`;
}
function stateLabel(state: string) {
if (state === 'WARNED') return <Badge color="amber">Warning sent</Badge>;
return <Badge color="neutral">Grace period</Badge>;
}
export default function Quarantine() {
const qc = useQueryClient();
const { data, isLoading, isError } = useQuery<DislikeEntry[]>({
queryKey: ['dislikes'],
queryFn: () => quarantineService.list(),
refetchInterval: 60_000,
});
const restore = useMutation({
mutationFn: (trackId: string) => quarantineService.restore(trackId),
onSuccess: (_d, trackId) => {
qc.invalidateQueries({ queryKey: ['dislikes'] });
qc.invalidateQueries({ queryKey: ['tracks'] });
toast.success('Track restored to library', { ttl: 5000 });
void trackId;
},
onError: () => toast.error("Couldn't restore the track. Try again."),
});
const hardDelete = useMutation({
mutationFn: (trackId: string) => quarantineService.hardDelete(trackId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['dislikes'] });
toast.success('Track deleted permanently');
},
onError: () => toast.error("Couldn't delete the track. Try again."),
});
const entries = data ?? [];
return (
<PageContainer>
<div>
<h1 className="flex items-center gap-3 text-3xl font-bold text-text">
<ShieldAlert size={28} className="text-accent" /> Quarantine
</h1>
<p className="text-muted mt-1">Disliked tracks pending deletion. Restore before the timer expires.</p>
</div>
{isLoading ? <SkeletonRows count={3} />
: isError ? <EmptyState compact icon={<AlertCircle size={28} />} title="Couldn't load quarantine list" subtitle="Something went wrong. Try reloading the page." />
: entries.length === 0 ? (
<EmptyState
icon={<ShieldAlert size={28} />}
title="No tracks in quarantine"
subtitle="Disliked tracks will appear here during the grace period before being deleted."
/>
) : (
<ul className="space-y-2">
{entries.map((entry) => (
<li key={entry.track_id} className="flex items-center gap-3 rounded-lg border border-border bg-surface0 p-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-text truncate">{entry.track_title}</span>
{stateLabel(entry.state)}
</div>
<div className="text-xs text-muted">{entry.track_artist}</div>
<div className="flex items-center gap-1 mt-1 text-xs text-muted">
<Clock size={12} /> {countdown(entry)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => restore.mutate(entry.track_id)}
disabled={restore.isPending}
title="Restore to library"
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm text-text hover:bg-surface1 disabled:opacity-50 transition-colors"
>
<RotateCcw size={14} /> Restore
</button>
<button
onClick={() => { if (confirm(`Permanently delete "${entry.track_title}"?`)) hardDelete.mutate(entry.track_id); }}
disabled={hardDelete.isPending}
title="Delete now"
className="flex items-center gap-1.5 rounded-lg border border-red-500/40 px-3 py-1.5 text-sm text-red-400 hover:bg-red-500/10 disabled:opacity-50 transition-colors"
>
<Trash2 size={14} /> Delete
</button>
</div>
</li>
))}
</ul>
)}
</PageContainer>
);
}
+62
View File
@@ -0,0 +1,62 @@
import { useQuery } from '@tanstack/react-query';
import { useSearch } from '@tanstack/react-router';
import { Search as SearchIcon, SearchX } from 'lucide-react';
import { searchService } from '../services/searchService';
import { TrackRow } from '../components/TrackRow';
import { PageContainer } from '../components/PageContainer';
import { EmptyState } from '../components/EmptyState';
import { LoadingState } from '../components/LoadingState';
import type { SearchResponse, Track } from '../types';
export default function Search() {
// The query lives entirely in the URL; the top bar is the input (it pushes
// ?q= live as you type). This page just reflects whatever's in the URL.
const query = (useSearch({ from: '/search', select: (s) => s.q }) ?? '').trim();
const { data, isLoading, isError, isFetching } = useQuery<SearchResponse>({
queryKey: ['search', query],
queryFn: () => searchService.search(query),
enabled: query.length > 0,
});
const tracks: Track[] = (data?.hits ?? [])
.map((h) => h.document)
.filter((t): t is Track => Boolean(t));
const busy = isLoading || isFetching;
return (
<PageContainer>
<div className="animate-rise">
<h1 className="text-gradient text-3xl font-extrabold tracking-tight sm:text-4xl">Search</h1>
{query.length > 0 ? (
<p className="mt-1.5 text-sm text-muted">
{busy ? 'Searching' : `${tracks.length} ${tracks.length === 1 ? 'result' : 'results'}`} for{' '}
<span className="font-semibold text-text">{query}</span>
</p>
) : (
<p className="mt-1.5 text-sm text-muted">Search your library from the bar above.</p>
)}
</div>
{query.length === 0 ? (
<EmptyState
icon={<SearchIcon size={28} />}
title="Start typing to search"
subtitle="Find songs by title or artist — results appear as you type."
/>
) : busy ? (
<LoadingState label="Searching…" />
) : isError ? (
<EmptyState icon={<SearchX size={28} />} title="Search failed" subtitle="Something went wrong. Try again." />
) : tracks.length === 0 ? (
<EmptyState icon={<SearchX size={28} />} title="No results" subtitle={`Nothing matched “${query}”.`} />
) : (
<section className="space-y-1 rounded-2xl border border-border/70 bg-surface0/40 p-2 animate-rise">
{tracks.map((t, i) => (
<TrackRow key={t.id} track={t} queue={tracks} index={i} />
))}
</section>
)}
</PageContainer>
);
}
+318
View File
@@ -0,0 +1,318 @@
import { useEffect, useState } from 'react';
import { Volume2, Info, Scan, RefreshCw, Globe, Copy, ChevronDown, ChevronRight, Trash2, Users, Sparkles } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { PageContainer } from '../components/PageContainer';
import api from '../services/api';
import { settingsService, type EnrichSettingKey, type EnrichSettings } from '../services/settingsService';
import { STORAGE_KEYS, readStoredVolume } from '../lib/theme';
import { toast } from '../store/useToastStore';
import type { Track } from '../types';
const ENRICH_LABELS: Record<EnrichSettingKey, { label: string; desc: string }> = {
enrich_metadata: { label: 'Album info', desc: 'MusicBrainz ID, Discogs ID, release year' },
enrich_genres: { label: 'Genre tags', desc: 'Last.fm & MusicBrainz tags → genres' },
enrich_cover_art: { label: 'Cover art', desc: 'Discogs + Cover Art Archive images' },
enrich_lyrics: { label: 'Lyrics', desc: 'LRCLib synced & plain-text lyrics' },
enrich_artist_similarity: { label: 'Similar artists', desc: 'Last.fm similar-artist recommendations' },
enrich_audio_analysis: { label: 'Audio analysis', desc: 'BPM & key from embedded tags; energy from ReplayGain' },
};
function EnrichToggles() {
const [settings, setSettings] = useState<EnrichSettings | null>(null);
const [saving, setSaving] = useState<EnrichSettingKey | null>(null);
useEffect(() => {
settingsService.load().then(setSettings).catch(() => {});
}, []);
const toggle = async (key: EnrichSettingKey) => {
if (!settings || saving) return;
const next = !settings[key];
setSaving(key);
// Optimistic update.
setSettings((prev) => prev ? { ...prev, [key]: next } : prev);
try {
await settingsService.update(key, next);
} catch {
// Revert on failure.
setSettings((prev) => prev ? { ...prev, [key]: !next } : prev);
} finally {
setSaving(null);
}
};
if (!settings) return null;
return (
<div className="space-y-3 pt-2 border-t border-border">
<h3 className="text-sm font-medium text-muted flex items-center gap-2">
<Globe size={14} />Enrichment
</h3>
<p className="text-xs text-muted/70 -mt-1">
Which external services to query during library scan. Changes apply to
the <strong>next scan</strong>.
</p>
{Object.entries(ENRICH_LABELS).map(([key, { label, desc }]) => {
const k = key as EnrichSettingKey;
const on = settings[k];
return (
<button key={k} onClick={() => toggle(k)} disabled={saving === k}
className="w-full flex items-center justify-between gap-3 rounded-lg border border-border/70 px-4 py-3 text-left transition-colors hover:bg-surface1 disabled:opacity-50">
<div className="min-w-0">
<div className="text-sm font-medium text-text">{label}</div>
<div className="text-xs text-muted truncate">{desc}</div>
</div>
<div className={`shrink-0 relative w-10 h-5 rounded-full transition-colors ${on ? 'bg-accent' : 'bg-surface2'}`}>
<div className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full shadow-sm transition-transform ${on ? 'bg-on-accent translate-x-5' : 'bg-secondary'}`} />
</div>
</button>
);
})}
</div>
);
}
function AdminAction({
icon: Icon,
label,
busyLabel = 'Working…',
doneLabel = 'Done',
errorLabel = 'Failed',
action,
successMsg,
errorMsg,
}: {
icon: typeof Scan;
label: string;
busyLabel?: string;
doneLabel?: string;
errorLabel?: string;
action: () => Promise<unknown>;
successMsg: string;
errorMsg: string;
}) {
const [state, setState] = useState<'idle' | 'loading' | 'done' | 'error'>('idle');
const handleClick = async () => {
setState('loading');
try {
await action();
setState('done');
toast.success(successMsg);
setTimeout(() => setState('idle'), 3000);
} catch {
setState('error');
toast.error(errorMsg);
setTimeout(() => setState('idle'), 3000);
}
};
return (
<button
onClick={handleClick}
disabled={state === 'loading'}
className={`admin-btn ${state === 'done' ? 'admin-btn--done' : state === 'error' ? 'admin-btn--error' : ''}`}
>
<Icon size={16} className={state === 'loading' ? 'animate-spin' : ''} />
{state === 'loading' ? busyLabel
: state === 'done' ? doneLabel
: state === 'error' ? errorLabel
: label}
</button>
);
}
type DupMode = 'hash' | 'title-artist';
interface DupGroup {
key: string;
tracks: Track[];
}
const DUP_MODE_LABELS: Record<DupMode, { label: string; desc: string }> = {
hash: { label: 'Same file', desc: 'Byte-identical copies (same content hash)' },
'title-artist': { label: 'Same track', desc: 'Same title + artist, different files' },
};
function DuplicatesSection() {
const qc = useQueryClient();
const [mode, setMode] = useState<DupMode>('hash');
const [expanded, setExpanded] = useState<string | null>(null);
const { data, isLoading, isError, refetch } = useQuery<DupGroup[]>({
queryKey: ['duplicates', mode],
queryFn: () => api.get('/admin/duplicates', { params: { mode } }).then((r) => r.data),
enabled: false,
});
const merge = useMutation({
mutationFn: ({ keepId, deleteIds }: { keepId: string; deleteIds: string[] }) =>
api.post('/admin/duplicates/merge', { keepId, deleteIds }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['duplicates', mode] });
qc.invalidateQueries({ queryKey: ['tracks'] });
refetch();
toast.success('Duplicate merged. File deleted from disk.');
},
onError: () => toast.error('Could not merge the duplicate. Try again.'),
});
const groups = data ?? [];
return (
<div className="space-y-3 pt-2 border-t border-border">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-muted flex items-center gap-2">
<Copy size={14} /> Duplicates
</h3>
<button onClick={() => refetch()}
disabled={isLoading}
className="text-xs px-3 py-1.5 rounded-lg border border-border text-text hover:bg-surface1 disabled:opacity-50">
{isLoading ? 'Scanning…' : 'Scan'}
</button>
</div>
{/* Mode tabs */}
<div className="flex gap-1 rounded-lg bg-surface1 p-1">
{(Object.keys(DUP_MODE_LABELS) as DupMode[]).map((m) => (
<button key={m} onClick={() => { setMode(m); setExpanded(null); }}
className={`flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
mode === m ? 'bg-surface0 text-text shadow-sm' : 'text-muted hover:text-text'
}`}>
{DUP_MODE_LABELS[m].label}
</button>
))}
</div>
<p className="text-xs text-muted/70 -mt-1">{DUP_MODE_LABELS[mode].desc}</p>
{isError && <p className="text-xs text-red-400">Scan failed.</p>}
{data && groups.length === 0 && (
<p className="text-sm text-muted">No duplicates found.</p>
)}
{groups.map((group) => {
const isOpen = expanded === group.key;
const [keep, ...rest] = group.tracks;
const label = mode === 'hash' ? keep.title : group.key;
return (
<div key={group.key} className="rounded-lg border border-border overflow-hidden">
<button onClick={() => setExpanded(isOpen ? null : group.key)}
className="w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-surface1">
{isOpen ? <ChevronDown size={14} className="text-muted flex-none" /> : <ChevronRight size={14} className="text-muted flex-none" />}
<span className="text-sm font-medium text-text flex-1 truncate">{label}</span>
<span className="text-xs text-muted flex-none">{group.tracks.length} copies</span>
</button>
{isOpen && (
<div className="border-t border-border divide-y divide-line">
{group.tracks.map((t, i) => (
<div key={t.id} className="flex items-start gap-3 px-4 py-2.5">
<div className="flex-1 min-w-0">
<div className="text-xs text-text truncate">{t.path}</div>
<div className="text-xs text-muted">{t.artist} · {t.play_count} plays{i === 0 ? ' · kept by default' : ''}</div>
</div>
{i > 0 && (
<button
onClick={() => merge.mutate({ keepId: keep.id, deleteIds: [t.id] })}
disabled={merge.isPending}
title="Delete this duplicate"
className="flex-none text-red-400 hover:text-red-300 disabled:opacity-50 p-1">
<Trash2 size={14} />
</button>
)}
</div>
))}
<div className="px-4 py-2">
<button
onClick={() => merge.mutate({ keepId: keep.id, deleteIds: rest.map((t) => t.id) })}
disabled={merge.isPending || rest.length === 0}
className="text-xs px-3 py-1.5 rounded-lg border border-red-500/40 text-red-400 hover:bg-red-500/10 disabled:opacity-50">
Delete all duplicates, keep most-played
</button>
</div>
</div>
)}
</div>
);
})}
</div>
);
}
export default function Settings() {
const volume = usePlaybackStore((s) => s.volume);
const setVolume = usePlaybackStore((s) => s.setVolume);
useEffect(() => {
setVolume(readStoredVolume(volume));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleVolume = (v: number) => {
setVolume(v);
try { localStorage.setItem(STORAGE_KEYS.volume, String(v)); } catch { /**/ }
};
return (
<PageContainer width="sm" className="space-y-8 lg:max-w-3xl">
<div>
<h1 className="text-3xl font-bold text-text">Settings</h1>
<p className="text-muted mt-1">Preferences are stored locally in this browser.</p>
</div>
<section className="rounded-xl border border-border/70 bg-surface0 p-5 space-y-4">
<h2 className="text-lg font-semibold text-text flex items-center gap-2"><Volume2 size={20} className="text-accent" />Default volume</h2>
<div className="flex items-center gap-4">
<input type="range" min={0} max={1} step={0.01} value={volume}
onChange={(e) => handleVolume(Number(e.target.value))}
className="flex-1" aria-label="Volume" />
<span className="w-12 text-right text-sm tabular-nums text-text">{Math.round(volume * 100)}%</span>
</div>
</section>
<section className="rounded-xl border border-border/70 bg-surface0 p-5 space-y-4">
<h2 className="text-lg font-semibold text-text flex items-center gap-2"><Scan size={20} className="text-accent" />Library</h2>
<div className="flex flex-wrap gap-3">
<AdminAction icon={Scan} label="Scan library" busyLabel="Scanning…" doneLabel="Scan enqueued"
action={() => api.post('/admin/scan', { directory: '/music' })}
successMsg="Library scan enqueued. Track progress in Jobs."
errorMsg="Could not start scan. Check that the backend is running." />
<AdminAction icon={RefreshCw} label="Reindex search" busyLabel="Indexing…" doneLabel="Index enqueued"
action={() => api.post('/admin/reindex-tracks')}
successMsg="Search reindex enqueued."
errorMsg="Could not start reindex." />
<AdminAction icon={Users} label="Reprocess artists" busyLabel="Enqueuing…" doneLabel="Reprocess enqueued"
action={() => api.post('/admin/reprocess-artists')}
successMsg="Artist reprocess enqueued. Runs in the background."
errorMsg="Could not start artist reprocess." />
<AdminAction icon={Sparkles} label="Re-enrich metadata" busyLabel="Enqueuing…" doneLabel="Re-enrich enqueued"
action={async () => {
const res = await api.post('/admin/reenrich-tracks');
const count = res.data?.trackCount ?? 0;
toast.success(`Re-enrich enqueued for ${count} tracks. Runs in the background.`);
}}
successMsg="Re-enrich enqueued."
errorMsg="Could not start re-enrich." />
</div>
<p className="text-xs text-muted/70 -mt-1">
<strong>Reprocess artists</strong> re-resolves canonical names, MBIDs and
images for every artist, and merges duplicates. <strong>Re-enrich metadata</strong>
re-queries MusicBrainz/Discogs for all tracks (album titles, years, cover
art) without re-scanning files. Both run in the background.
</p>
<EnrichToggles />
<DuplicatesSection />
</section>
<section className="rounded-xl border border-border/70 bg-surface0 p-5 space-y-3">
<h2 className="text-lg font-semibold text-text flex items-center gap-2"><Info size={20} className="text-accent" />About</h2>
<dl className="space-y-2 text-sm">
<div className="flex justify-between"><dt className="text-muted">Application</dt><dd className="text-text font-medium">muzick</dd></div>
<div className="flex justify-between"><dt className="text-muted">Version</dt><dd className="text-text font-medium tabular-nums">0.1.0</dd></div>
<div className="flex justify-between gap-4"><dt className="text-muted">API base</dt><dd className="font-mono text-xs text-text break-all">{api.defaults.baseURL ?? '/api'}</dd></div>
</dl>
</section>
</PageContainer>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { useState } from 'react';
import { useQuery, keepPreviousData } from '@tanstack/react-query';
import { Music, AlertCircle } from 'lucide-react';
import { trackService } from '../services/trackService';
import { TrackRow } from '../components/TrackRow';
import { PageHeader } from '../components/PageHeader';
import { PageContainer } from '../components/PageContainer';
import { Pagination } from '../components/Pagination';
import { EmptyState } from '../components/EmptyState';
import { SkeletonRows } from '../components/LoadingState';
import type { Track } from '../types';
const PAGE_SIZE = 50;
export default function Tracks() {
const [page, setPage] = useState(0);
const { data, isLoading, isError, isPlaceholderData } = useQuery<Track[]>({
queryKey: ['tracks', page],
queryFn: () => trackService.listTracks({ limit: PAGE_SIZE, offset: page * PAGE_SIZE, sort_by: 'title', order: 'ASC' }),
placeholderData: keepPreviousData,
});
const tracks = data ?? [];
const hasNext = tracks.length === PAGE_SIZE;
return (
<PageContainer>
<PageHeader icon={Music} title="Songs" subtitle="Everything in your library" />
{isLoading ? <SkeletonRows count={8} />
: isError ? <EmptyState compact icon={<AlertCircle size={28} />} title="Couldn't load tracks" subtitle="Something went wrong. Try reloading the page." />
: tracks.length === 0 ? <EmptyState compact icon={<Music size={28} />} title={page === 0 ? 'No tracks yet' : 'No more tracks'} subtitle={page === 0 ? 'Run a library scan in Settings to populate it.' : undefined} />
: <div className="space-y-1">{tracks.map((t, i) => <TrackRow key={t.id} track={t} queue={tracks} index={i} />)}</div>}
{(tracks.length > 0 || page > 0) && (
<Pagination
page={page}
hasNext={hasNext}
isLoading={isPlaceholderData}
onPrev={() => setPage((p) => Math.max(0, p - 1))}
onNext={() => setPage((p) => p + 1)}
/>
)}
</PageContainer>
);
}
+274
View File
@@ -0,0 +1,274 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Heart, Loader2, Play, Shuffle, ThumbsDown, Sparkles, Square } from 'lucide-react';
import { vibeService, fetchNextBatch } from '../services/vibeService';
import { trackService } from '../services/trackService';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { useVibeStore } from '../store/useVibeStore';
import { TrackRow } from '../components/TrackRow';
import { PageContainer } from '../components/PageContainer';
import type { Track } from '../types';
import { VibeTimeline } from '../components/VibeTimeline';
const INITIAL_BATCH_SIZE = 5;
const PREFETCH_THRESHOLD = 3;
const PREFETCH_BATCH_SIZE = 3;
function bestEffort(p: Promise<unknown>): void {
void p.catch(() => undefined);
}
export default function Vibe() {
const { currentTrack, queue, setQueue, playTrack, next: playNext } = usePlaybackStore();
const {
activeSessionId,
buffer,
setActiveSession,
setSeedTrackId,
setCenterTrack,
setBuffer,
appendBuffer,
reset,
} = useVibeStore();
const [starting, setStarting] = useState(false);
const [prefetching, setPrefetching] = useState(false);
const [error, setError] = useState<string | null>(null);
const [empty, setEmpty] = useState(false);
const prefetchingRef = useRef(false);
const { data: libraryTracks = [], isLoading: libraryLoading } = useQuery<Track[]>({
queryKey: ['library-seed'],
queryFn: () => trackService.listTracks({ limit: 50, sort_by: 'play_count', order: 'DESC' }),
enabled: !activeSessionId,
});
const startSession = useCallback(
async (seed: Track) => {
setStarting(true);
setError(null);
setEmpty(false);
try {
const { sessionId } = await vibeService.start(seed.id);
setActiveSession({ sessionId, seedTrackId: seed.id });
setSeedTrackId(seed.id);
setCenterTrack(seed);
const chunk = await fetchNextBatch(INITIAL_BATCH_SIZE);
if (chunk.length === 0) {
setBuffer([]);
setEmpty(true);
return;
}
setBuffer(chunk);
setQueue(chunk);
playTrack(chunk[0]);
} catch {
setError('Could not start a vibe session. Please try again.');
reset();
} finally {
setStarting(false);
}
},
[setActiveSession, setSeedTrackId, setCenterTrack, setBuffer, setQueue, playTrack, reset]
);
const startFromCurrent = useCallback(() => {
if (currentTrack) void startSession(currentTrack);
}, [currentTrack, startSession]);
const surpriseMe = useCallback(() => {
if (libraryTracks.length === 0) return;
const seed = libraryTracks[Math.floor(Math.random() * libraryTracks.length)];
void startSession(seed);
}, [libraryTracks, startSession]);
const remaining = currentTrack
? queue.length - (queue.findIndex((t) => t.id === currentTrack.id) + 1)
: queue.length;
useEffect(() => {
if (!activeSessionId || prefetchingRef.current) return;
if (remaining > PREFETCH_THRESHOLD) return;
prefetchingRef.current = true;
setPrefetching(true);
fetchNextBatch(PREFETCH_BATCH_SIZE)
.then((chunk) => {
if (chunk.length > 0) {
appendBuffer(chunk);
const current = usePlaybackStore.getState().queue;
const currentIds = new Set(current.map((t) => t.id));
const fresh = chunk.filter((t) => !currentIds.has(t.id));
if (fresh.length > 0) setQueue([...current, ...fresh]);
}
})
.catch(() => undefined)
.finally(() => {
prefetchingRef.current = false;
setPrefetching(false);
});
}, [activeSessionId, remaining, appendBuffer, setQueue]);
// Trim buffer to prevent unbounded growth — keep only from currentTrack onward.
useEffect(() => {
if (!activeSessionId || !currentTrack || buffer.length === 0) return;
const idx = buffer.findIndex((t) => t.id === currentTrack.id);
if (idx > 0) {
setBuffer(buffer.slice(idx));
}
}, [activeSessionId, currentTrack]);
const handleKeep = useCallback(() => {
if (currentTrack) bestEffort(vibeService.feedback(currentTrack.id, 'promoted'));
}, [currentTrack]);
const handleDislike = useCallback(() => {
if (currentTrack) bestEffort(vibeService.feedback(currentTrack.id, 'disliked'));
playNext();
}, [currentTrack, playNext]);
const handleEnd = useCallback(() => {
// V2 plan expires via Redis TTL (2h). No explicit end endpoint.
reset();
setEmpty(false);
setError(null);
}, [reset]);
const upcoming = currentTrack
? (() => {
const idx = buffer.findIndex((t) => t.id === currentTrack.id);
return idx >= 0 ? buffer.slice(idx + 1) : buffer;
})()
: buffer;
// ---- Start screen (no active session) ----
if (!activeSessionId) {
return (
<PageContainer width="sm" className="py-8">
<header className="space-y-2 text-center">
<div className="inline-flex items-center gap-2 rounded-full bg-accent/10 px-4 py-1.5 text-sm text-accent">
<Sparkles size={16} />
Rolling Vibe
</div>
<h1 className="text-4xl font-bold text-text">Start a Vibe</h1>
<p className="text-muted">
An infinite, ever-rolling stream of recommendations seeded from a track you love.
</p>
<p className="mx-auto max-w-md text-xs text-muted/70">
Pick a seed below and playback starts immediately, with a rolling
timeline of upcoming tracks. <strong className="text-muted">Keep</strong> what you love,
<strong className="text-muted"> Dislike &amp; skip</strong> what you don&apos;t the vibe
adapts as you go.
</p>
</header>
{error && (
<div className="rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-300">
{error}
</div>
)}
<div className="space-y-3">
{currentTrack && (
<button
onClick={startFromCurrent}
disabled={starting}
className="flex w-full items-center gap-3 rounded-lg border border-accent/60 bg-accent/10 p-4 text-left transition-colors hover:bg-accent/20 disabled:opacity-60"
>
<Play size={20} className="text-accent" />
<div className="flex-1">
<div className="font-semibold text-text">Vibe from &ldquo;{currentTrack.title}&rdquo;</div>
<div className="text-sm text-muted">{currentTrack.artist}</div>
</div>
</button>
)}
<button
onClick={surpriseMe}
disabled={starting || libraryLoading || libraryTracks.length === 0}
className="flex w-full items-center gap-3 rounded-lg border border-border bg-surface0 p-4 text-left transition-colors hover:bg-surface1 disabled:opacity-60"
>
<Shuffle size={20} className="text-text" />
<div className="flex-1">
<div className="font-semibold text-text">Surprise me</div>
<div className="text-sm text-muted">
{libraryLoading
? 'Loading your library…'
: libraryTracks.length === 0
? 'No library tracks available to seed a vibe.'
: 'Start from a random track in your library.'}
</div>
</div>
{starting && <Loader2 size={18} className="animate-spin text-muted" />}
</button>
</div>
{!libraryLoading && libraryTracks.length > 0 && (
<div className="space-y-2">
<h2 className="text-sm font-semibold text-muted">Or pick a seed track</h2>
<ul className="max-h-72 space-y-1 overflow-y-auto">
{libraryTracks.map((track) => (
<li key={track.id}>
<TrackRow
track={track}
queue={libraryTracks}
index={libraryTracks.findIndex((t) => t.id === track.id)}
showActions={false}
/>
</li>
))}
</ul>
</div>
)}
</PageContainer>
);
}
// ---- Active session ----
return (
<PageContainer width="sm" className="py-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Sparkles size={22} className="text-accent" />
<h1 className="text-2xl font-bold text-text">Vibing</h1>
{prefetching && <Loader2 size={16} className="animate-spin text-muted" />}
</div>
<button
onClick={handleEnd}
className="flex items-center gap-2 rounded-lg border border-border px-3 py-1.5 text-sm text-text transition-colors hover:border-red-500/60 hover:text-red-300"
>
<Square size={14} />
End Vibe
</button>
</header>
{empty && (
<div className="rounded-lg border border-border bg-surface0 p-4 text-sm text-muted">
No recommendations came back for this seed yet. Try ending and starting a different vibe.
</div>
)}
{currentTrack && (
<div className="flex items-center gap-3">
<button
onClick={handleKeep}
className="flex items-center gap-2 rounded-lg border border-border px-4 py-2 text-sm font-medium text-text transition-colors hover:border-pink-500/60 hover:text-pink-300"
>
<Heart size={16} />
Keep
</button>
<button
onClick={handleDislike}
className="flex items-center gap-2 rounded-lg border border-border px-4 py-2 text-sm font-medium text-text transition-colors hover:border-red-500/60 hover:text-red-300"
>
<ThumbsDown size={16} />
Dislike & skip
</button>
</div>
)}
<VibeTimeline currentTrack={currentTrack} upcoming={upcoming} />
</PageContainer>
);
}
+132
View File
@@ -0,0 +1,132 @@
import {
createRootRoute,
createRoute,
createRouter,
} from '@tanstack/react-router';
import { z } from 'zod';
import AppShell from './components/AppShell';
import Home from './pages/Home';
import Artists from './pages/Artists';
import ArtistDetail from './pages/ArtistDetail';
import Albums from './pages/Albums';
import AlbumDetail from './pages/AlbumDetail';
import Tracks from './pages/Tracks';
import Genres from './pages/Genres';
import Vibe from './pages/Vibe';
import Discover from './pages/Discover';
import Search from './pages/Search';
import Settings from './pages/Settings';
import Quarantine from './pages/Quarantine';
import Jobs from './pages/Jobs';
// Root route renders the AppShell (NavRail + TopBar + PlaybackBar + NowPlayingPanel)
// with an <Outlet/> where the active child route renders.
export const rootRoute = createRootRoute({
component: AppShell,
});
export const homeRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: Home,
});
export const artistsRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/artists',
component: Artists,
});
export const artistDetailRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/artists/$artistId',
component: ArtistDetail,
});
export const albumsRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/albums',
component: Albums,
});
export const albumDetailRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/albums/$albumId',
component: AlbumDetail,
});
export const tracksRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/tracks',
component: Tracks,
});
export const genresRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/genres',
component: Genres,
});
export const vibeRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/vibe',
component: Vibe,
});
export const discoverRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/discover',
component: Discover,
});
export const searchRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/search',
component: Search,
validateSearch: z.object({
q: z.string().optional(),
}),
});
export const settingsRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/settings',
component: Settings,
});
export const quarantineRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/quarantine',
component: Quarantine,
});
export const jobsRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/jobs',
component: Jobs,
});
const routeTree = rootRoute.addChildren([
homeRoute,
artistsRoute,
artistDetailRoute,
albumsRoute,
albumDetailRoute,
tracksRoute,
genresRoute,
vibeRoute,
discoverRoute,
searchRoute,
settingsRoute,
quarantineRoute,
jobsRoute,
]);
export const router = createRouter({ routeTree });
// Type-safety: register the router instance type globally.
declare module '@tanstack/react-router' {
interface Register {
router: typeof router;
}
}
+39
View File
@@ -0,0 +1,39 @@
import api from './api';
import type { Album, AlbumWithTracks } from '../types';
export interface ListAlbumsParams {
limit?: number;
offset?: number;
}
export const albumService = {
// GET /api/albums
async listAlbums(params?: ListAlbumsParams): Promise<Album[]> {
const res = await api.get<Album[]>('/albums', { params });
return res.data;
},
// GET /api/albums/:id (returns album + tracks)
async getAlbum(id: string): Promise<AlbumWithTracks> {
const res = await api.get<AlbumWithTracks>(`/albums/${id}`);
return res.data;
},
// POST /api/albums
async createAlbum(data: Partial<Album>): Promise<Album> {
const res = await api.post<Album>('/albums', data);
return res.data;
},
// PUT /api/albums/:id
async updateAlbum(id: string, data: Partial<Album>): Promise<Album> {
const res = await api.put<Album>(`/albums/${id}`, data);
return res.data;
},
// DELETE /api/albums/:id
async deleteAlbum(id: string): Promise<{ status: string }> {
const res = await api.delete<{ status: string }>(`/albums/${id}`);
return res.data;
},
};
+7
View File
@@ -0,0 +1,7 @@
import axios from 'axios';
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || '/api',
});
export default api;
+39
View File
@@ -0,0 +1,39 @@
import api from './api';
import type { Artist, ArtistWithAlbums } from '../types';
export interface ListArtistsParams {
limit?: number;
offset?: number;
}
export const artistService = {
// GET /api/artists
async listArtists(params?: ListArtistsParams): Promise<Artist[]> {
const res = await api.get<Artist[]>('/artists', { params });
return res.data;
},
// GET /api/artists/:id (returns artist + albums)
async getArtist(id: string): Promise<ArtistWithAlbums> {
const res = await api.get<ArtistWithAlbums>(`/artists/${id}`);
return res.data;
},
// POST /api/artists
async createArtist(data: Partial<Artist>): Promise<Artist> {
const res = await api.post<Artist>('/artists', data);
return res.data;
},
// PUT /api/artists/:id
async updateArtist(id: string, data: Partial<Artist>): Promise<Artist> {
const res = await api.put<Artist>(`/artists/${id}`, data);
return res.data;
},
// DELETE /api/artists/:id
async deleteArtist(id: string): Promise<{ status: string }> {
const res = await api.delete<{ status: string }>(`/artists/${id}`);
return res.data;
},
};
+28
View File
@@ -0,0 +1,28 @@
import api from './api';
import type { Track } from '../types';
export const favoritesService = {
// GET /api/favorites -> Track[]
async list(): Promise<Track[]> {
const res = await api.get<Track[]>('/favorites');
return res.data;
},
// POST /api/favorites/:trackId -> { status: 'added' }
async add(trackId: string): Promise<{ status: string }> {
const res = await api.post<{ status: string }>(`/favorites/${trackId}`);
return res.data;
},
// DELETE /api/favorites/:trackId -> { status: 'removed' }
async remove(trackId: string): Promise<{ status: string }> {
const res = await api.delete<{ status: string }>(`/favorites/${trackId}`);
return res.data;
},
// POST /api/tracks/:trackId/dislike -> { status: 'disliked' }
async dislike(trackId: string): Promise<{ status: string }> {
const res = await api.post<{ status: string }>(`/tracks/${trackId}/dislike`);
return res.data;
},
};
+31
View File
@@ -0,0 +1,31 @@
import api from './api';
import type { Genre, Track } from '../types';
export interface GetGenreTracksParams {
limit?: number;
offset?: number;
}
export const genreService = {
// GET /api/genres — all genres with a track_count, ordered by popularity.
async listGenres(): Promise<Genre[]> {
const res = await api.get<Genre[]>('/genres');
return res.data;
},
// GET /api/genres/:id — a single genre (with track_count), or null if missing.
async getGenre(id: string): Promise<Genre | null> {
try {
const res = await api.get<Genre>(`/genres/${id}`);
return res.data;
} catch {
return null;
}
},
// GET /api/genres/:id/tracks — LIBRARY tracks in this genre, by weight DESC.
async getGenreTracks(id: string, params?: GetGenreTracksParams): Promise<Track[]> {
const res = await api.get<Track[]>(`/genres/${id}/tracks`, { params });
return res.data;
},
};
+10
View File
@@ -0,0 +1,10 @@
import api from './api';
import type { HealthResponse } from '../types';
export type { HealthResponse };
// GET /api/health
export const fetchHealthStatus = async (): Promise<HealthResponse> => {
const response = await api.get<HealthResponse>('/health');
return response.data;
};
+36
View File
@@ -0,0 +1,36 @@
import api from './api';
import type { FeedbackAction, HistoryEntry } from '../types';
export const historyService = {
// POST /api/history { trackId, completed?, batchId? } -> { historyId }
async recordPlay(
trackId: string,
completed?: boolean,
batchId?: string
): Promise<{ historyId: string }> {
const res = await api.post<{ historyId: string }>('/history', {
trackId,
completed,
batchId,
});
return res.data;
},
// POST /api/history/skip { trackId } -> { status }
async skip(trackId: string): Promise<{ status: string }> {
const res = await api.post<{ status: string }>('/history/skip', { trackId });
return res.data;
},
// GET /api/history -> recent play history (Track + playback metadata)
async list(): Promise<HistoryEntry[]> {
const res = await api.get<HistoryEntry[]>('/history');
return res.data;
},
// POST /api/feedback { trackId, action } -> { status }
async feedback(trackId: string, action: FeedbackAction): Promise<{ status: string }> {
const res = await api.post<{ status: string }>('/feedback', { trackId, action });
return res.data;
},
};
+140
View File
@@ -0,0 +1,140 @@
import api from './api';
export interface QueueStats {
waiting: number;
active: number;
completed: number;
failed: number;
delayed: number;
paused: number;
}
export interface JobHistoryEntry {
id: string;
name: string;
data: Record<string, unknown>;
timestamp: number;
finishedOn?: number;
failedReason?: string;
returnvalue?: unknown;
progress?: number;
attemptsMade?: number;
}
export type JobStatus = 'completed' | 'failed' | 'running';
export const JOB_LABELS: Record<string, string> = {
scan_library: 'Library Scan',
metadata_refresh: 'Metadata Refresh',
artist_similarity: 'Artist Similarity',
artist_image: 'Artist Image',
album_cover: 'Album Cover',
audio_analysis: 'Audio Analysis',
integrity_sweep: 'Integrity Sweep',
cleanup_sweep: 'Cleanup Sweep',
cleanup: 'Cleanup',
reindex_tracks: 'Reindex Tracks',
reprocess_artists: 'Reprocess Artists',
};
/** Human-readable summary of job payload data for display in the list. */
export function getJobDataSummary(name: string, data: Record<string, unknown>): string {
switch (name) {
case 'scan_library':
return `📁 ${String(data.directory ?? '?')}`;
case 'metadata_refresh':
return `🎵 track: ${String(data.trackId ?? '?').slice(0, 12)} · ${String(data.refreshType ?? '?')}`;
case 'audio_analysis':
return `🎵 track: ${String(data.trackId ?? '?').slice(0, 12)} · features: ${Array.isArray(data.features) ? data.features.length : '?'}`;
case 'artist_similarity':
return `👤 artist: ${String(data.artistId ?? '?').slice(0, 12)}`;
case 'artist_image':
return `🖼️ artist: ${String(data.artistId ?? '?').slice(0, 12)}`;
case 'album_cover':
return `💿 album: ${String(data.albumId ?? '?').slice(0, 12)}`;
case 'integrity_sweep':
return `🔍 ${String(data.reason ?? 'scheduled')}`;
case 'cleanup_sweep':
return `🧹 ${String(data.reason ?? 'scheduled')}`;
case 'cleanup':
return `🧹 ${String(data.reason ?? '?')} · ${Array.isArray(data.targetFiles) ? `${data.targetFiles.length} files` : '?'}`;
case 'reindex_tracks':
return `🔄 all tracks → Typesense`;
case 'reprocess_artists':
return `👤 batch=${String(data.batchSize ?? '?')} offset=${String(data.offset ?? '?')}`;
default:
return '';
}
}
/** Human-readable detail lines for the expanded view. */
export function getJobDataDetails(name: string, data: Record<string, unknown>): { label: string; value: string }[] {
const details: { label: string; value: string }[] = [];
switch (name) {
case 'scan_library':
details.push({ label: 'Directory', value: String(data.directory ?? '?') });
break;
case 'metadata_refresh':
details.push({ label: 'Track ID', value: String(data.trackId ?? '?') });
details.push({ label: 'Refresh Type', value: String(data.refreshType ?? '?') });
break;
case 'audio_analysis':
details.push({ label: 'Track ID', value: String(data.trackId ?? '?') });
details.push({ label: 'Features', value: Array.isArray(data.features) ? data.features.join(', ') : '?' });
break;
case 'artist_similarity':
details.push({ label: 'Artist ID', value: String(data.artistId ?? '?') });
break;
case 'artist_image':
details.push({ label: 'Artist ID', value: String(data.artistId ?? '?') });
break;
case 'album_cover':
details.push({ label: 'Album ID', value: String(data.albumId ?? '?') });
break;
case 'integrity_sweep':
details.push({ label: 'Reason', value: String(data.reason ?? 'scheduled') });
break;
case 'cleanup_sweep':
details.push({ label: 'Reason', value: String(data.reason ?? 'scheduled') });
break;
case 'cleanup':
details.push({ label: 'Reason', value: String(data.reason ?? '?') });
details.push({ label: 'Target Files', value: Array.isArray(data.targetFiles) ? data.targetFiles.join(', ') : '?' });
break;
case 'reprocess_artists':
details.push({ label: 'Batch Size', value: String(data.batchSize ?? '?') });
details.push({ label: 'Offset', value: String(data.offset ?? '?') });
break;
}
return details;
}
export function getJobStatus(job: JobHistoryEntry): JobStatus {
if (!job.finishedOn && !job.failedReason) return 'running';
if (job.failedReason) return 'failed';
return 'completed';
}
export const jobsService = {
async getQueueStats(): Promise<QueueStats> {
const response = await api.get('/admin/queue-stats');
return response.data;
},
async getJobHistory(limit = 100): Promise<JobHistoryEntry[]> {
const response = await api.get('/admin/job-history', { params: { limit } });
return response.data;
},
async triggerScan(directory: string) {
const response = await api.post('/admin/scan', { directory });
return response.data;
},
async triggerReindex() {
const response = await api.post('/admin/reindex-tracks');
return response.data;
},
};
+8
View File
@@ -0,0 +1,8 @@
// Barrel re-export for the library-related services. The previous version of this
// file pointed at `/library/*` paths, but the backend registers library routes at
// the `/api` root (see backend/src/app.ts). Use the per-entity services instead.
export { trackService } from './trackService';
export { artistService } from './artistService';
export { albumService } from './albumService';
export { favoritesService } from './favoritesService';
export type { Track, Artist, Album } from '../types';
@@ -0,0 +1,17 @@
import api from './api';
import type { DislikeEntry } from '../types';
export const quarantineService = {
async list(): Promise<DislikeEntry[]> {
const res = await api.get<DislikeEntry[]>('/dislikes');
return res.data;
},
async restore(trackId: string): Promise<void> {
await api.post(`/dislikes/${trackId}/restore`);
},
async hardDelete(trackId: string): Promise<void> {
await api.delete(`/dislikes/${trackId}`);
},
};
+10
View File
@@ -0,0 +1,10 @@
import api from './api';
import type { SearchResponse } from '../types';
export const searchService = {
// GET /api/search?q= — Typesense-backed search over tracks (title, artist).
async search(q: string): Promise<SearchResponse> {
const res = await api.get<SearchResponse>('/search', { params: { q } });
return res.data;
},
};
+48
View File
@@ -0,0 +1,48 @@
import api from './api';
const SETTING_KEYS = [
'enrich_metadata',
'enrich_cover_art',
'enrich_genres',
'enrich_lyrics',
'enrich_artist_similarity',
'enrich_audio_analysis',
] as const;
export type EnrichSettingKey = typeof SETTING_KEYS[number];
export interface EnrichSettings {
enrich_metadata: boolean;
enrich_cover_art: boolean;
enrich_genres: boolean;
enrich_lyrics: boolean;
enrich_artist_similarity: boolean;
enrich_audio_analysis: boolean;
}
const DEFAULTS: EnrichSettings = {
enrich_metadata: true,
enrich_cover_art: true,
enrich_genres: true,
enrich_lyrics: true,
enrich_artist_similarity: true,
enrich_audio_analysis: false,
};
export const settingsService = {
async load(): Promise<EnrichSettings> {
const res = await api.get<Record<string, string>>('/settings');
const raw = res.data;
const out = { ...DEFAULTS };
for (const key of SETTING_KEYS) {
if (raw[key] !== undefined) {
(out as any)[key] = raw[key] === 'true';
}
}
return out;
},
async update(key: EnrichSettingKey, value: boolean): Promise<void> {
await api.put(`/settings/${key}`, { value: String(value) });
},
};
+54
View File
@@ -0,0 +1,54 @@
import api from './api';
import type { Track } from '../types';
export interface ListTracksParams {
limit?: number;
offset?: number;
sort_by?: 'title' | 'artist' | 'album_id' | 'duration' | 'play_count';
order?: 'ASC' | 'DESC';
search?: string;
}
export const trackService = {
// GET /api/tracks
async listTracks(params?: ListTracksParams): Promise<Track[]> {
const res = await api.get<Track[]>('/tracks', { params });
return res.data;
},
// GET /api/tracks/:id
async getTrack(id: string): Promise<Track> {
const res = await api.get<Track>(`/tracks/${id}`);
return res.data;
},
// Plain URL for an <audio> src. GET /api/tracks/:id/stream (supports Range).
getStreamUrl(id: string): string {
const base = api.defaults.baseURL ?? '/api';
return `${base}/tracks/${id}/stream`;
},
// POST /api/tracks
async createTrack(data: Partial<Track>): Promise<Track> {
const res = await api.post<Track>('/tracks', data);
return res.data;
},
// PUT /api/tracks/:id
async updateTrack(id: string, data: Partial<Track>): Promise<Track> {
const res = await api.put<Track>(`/tracks/${id}`, data);
return res.data;
},
// DELETE /api/tracks/:id
async deleteTrack(id: string): Promise<{ status: string }> {
const res = await api.delete<{ status: string }>(`/tracks/${id}`);
return res.data;
},
// GET /api/tracks/:id/lyrics
async getLyrics(id: string): Promise<{ lyrics_text: string | null; synced_lyrics: unknown | null; provider: string | null }> {
const res = await api.get(`/tracks/${id}/lyrics`);
return res.data;
},
};
+73
View File
@@ -0,0 +1,73 @@
import api from './api';
import type { Track } from '../types';
// A candidate from the v2 recommendation plan. The plan is stored server-side
// in Redis; the frontend only needs trackId + explanation for display.
export interface VibePlanItem {
trackId: string;
generatorId: string;
explanation: unknown[];
relevance: number;
}
export interface VibeStartResponse {
sessionId: string;
plan: VibePlanItem[];
}
export interface VibeNextResponse {
track: Track;
explanation: unknown[] | null;
planRemaining: number;
}
export type VibeFeedbackAction = 'completed' | 'skipped' | 'promoted' | 'disliked';
// V2 recommendation engine. The backend stores the plan in Redis (2h TTL) and
// serves tracks one at a time via GET /next. Feedback triggers replanning.
export const vibeService = {
// POST /api/v2/vibe/start { seedTrackId? } -> { sessionId, plan }
async start(seedTrackId?: string): Promise<VibeStartResponse> {
const res = await api.post<VibeStartResponse>('/v2/vibe/start', { seedTrackId });
return res.data;
},
// GET /api/v2/vibe/next -> { track, explanation, planRemaining }
// Returns one track at a time, shifting the server-side plan.
// 404 if no active plan — caller should handle gracefully.
async next(): Promise<VibeNextResponse> {
const res = await api.get<VibeNextResponse>('/v2/vibe/next');
return res.data;
},
// POST /api/v2/vibe/feedback { trackId, action } -> { status, planRemaining }
// Action 'promoted' also calls addFavorite; 'disliked' also calls dislikeTrack.
// Triggers replan of the remaining plan.
async feedback(trackId: string, action: VibeFeedbackAction): Promise<{ status: string; planRemaining: number }> {
const res = await api.post<{ status: string; planRemaining: number }>('/v2/vibe/feedback', { trackId, action });
return res.data;
},
// GET /api/v2/vibe/plan -> { sessionId, planRemaining, plan }
// Debug endpoint — returns the full remaining plan.
async getPlan(): Promise<{ sessionId: string; planRemaining: number; plan: VibePlanItem[] }> {
const res = await api.get('/v2/vibe/plan');
return res.data;
},
};
// Fetch N tracks from the v2 plan sequentially. Each call to /next shifts the
// server-side plan, so calls must be sequential (not parallel). Stops early on
// 404 (plan exhausted or expired).
export async function fetchNextBatch(count: number): Promise<Track[]> {
const tracks: Track[] = [];
for (let i = 0; i < count; i++) {
try {
const { track } = await vibeService.next();
tracks.push(track);
} catch {
break;
}
}
return tracks;
}
+116
View File
@@ -0,0 +1,116 @@
import { create } from 'zustand';
import type { Track } from '../types';
export type RepeatMode = 'none' | 'all' | 'one';
interface PlaybackState {
currentTrack: Track | null;
queue: Track[];
isPlaying: boolean;
position: number;
duration: number;
volume: number;
shuffle: boolean;
repeat: RepeatMode;
setQueue: (queue: Track[]) => void;
playTrack: (track: Track) => void;
play: () => void;
pause: () => void;
next: () => void;
prev: () => void;
setPosition: (position: number) => void;
setDuration: (duration: number) => void;
setVolume: (volume: number) => void;
setCurrentTrack: (track: Track | null) => void;
toggleShuffle: () => void;
cycleRepeat: () => void;
}
export const usePlaybackStore = create<PlaybackState>((set, get) => ({
currentTrack: null,
queue: [],
isPlaying: false,
position: 0,
duration: 0,
volume: 1,
shuffle: false,
repeat: 'none',
setQueue: (queue) => set({ queue }),
playTrack: (track) =>
set({
currentTrack: track,
isPlaying: true,
position: 0,
duration: track.duration ?? 0,
}),
play: () => set({ isPlaying: true }),
pause: () => set({ isPlaying: false }),
next: () => {
const { queue, currentTrack, shuffle, repeat } = get();
if (queue.length === 0) return;
const idx = currentTrack ? queue.findIndex((t) => t.id === currentTrack.id) : -1;
// Repeat one: replay current track
if (repeat === 'one' && currentTrack) {
set({ position: 0, isPlaying: true });
return;
}
if (shuffle) {
// Shuffle: pick a random track from the remaining queue (excluding current)
const remaining = queue.filter((t) => t.id !== currentTrack?.id);
if (remaining.length === 0) {
if (repeat === 'all') {
const pick = queue[Math.floor(Math.random() * queue.length)];
set({ currentTrack: pick, position: 0, duration: pick.duration ?? 0, isPlaying: true });
}
return;
}
const pick = remaining[Math.floor(Math.random() * remaining.length)];
set({ currentTrack: pick, position: 0, duration: pick.duration ?? 0, isPlaying: true });
return;
}
// Sequential
const nextTrack = idx >= 0 ? queue[idx + 1] : null;
if (nextTrack) {
// Trim played tracks from queue to prevent unbounded growth (Vibe prefetch leak)
const trimmed = queue.slice(idx + 1);
set({ queue: trimmed, currentTrack: nextTrack, position: 0, duration: nextTrack.duration ?? 0, isPlaying: true });
} else if (repeat === 'all') {
const first = queue[0];
if (first) {
set({ currentTrack: first, position: 0, duration: first.duration ?? 0, isPlaying: true });
}
}
},
prev: () => {
const { queue, currentTrack } = get();
if (queue.length === 0) return;
const idx = currentTrack ? queue.findIndex((t) => t.id === currentTrack.id) : -1;
const prevTrack = idx > 0 ? queue[idx - 1] : null;
if (prevTrack) {
set({ currentTrack: prevTrack, position: 0, duration: prevTrack.duration ?? 0, isPlaying: true });
}
},
setPosition: (position) => set({ position }),
setDuration: (duration) => set({ duration }),
setVolume: (volume) => set({ volume }),
setCurrentTrack: (currentTrack) => set({ currentTrack }),
toggleShuffle: () => set((state) => ({ shuffle: !state.shuffle })),
cycleRepeat: () =>
set((state) => {
const modes: RepeatMode[] = ['none', 'all', 'one'];
const next = modes[(modes.indexOf(state.repeat) + 1) % modes.length];
return { repeat: next };
}),
}));
+45
View File
@@ -0,0 +1,45 @@
import { create } from 'zustand';
export type ToastKind = 'success' | 'error' | 'info';
export interface Toast {
id: number;
kind: ToastKind;
message: string;
/** Optional action label + handler (e.g. "Undo"). */
action?: { label: string; onClick: () => void };
/** Auto-dismiss timeout in ms. 0 keeps it until dismissed. */
ttl: number;
}
interface ToastState {
toasts: Toast[];
push: (t: Omit<Toast, 'id'> & { id?: number }) => number;
dismiss: (id: number) => void;
}
let nextId = 1;
export const useToastStore = create<ToastState>((set, get) => ({
toasts: [],
push: ({ id, ...rest }) => {
const toastId = id ?? nextId++;
const toast: Toast = { id: toastId, ...rest };
set((s) => ({ toasts: [...s.toasts, toast] }));
if (toast.ttl > 0) {
setTimeout(() => get().dismiss(toastId), toast.ttl);
}
return toastId;
},
dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
}));
/** Convenience helpers for the common kinds. */
export const toast = {
success: (message: string, opts: Partial<Omit<Toast, 'id' | 'kind' | 'message'>> = {}) =>
useToastStore.getState().push({ kind: 'success', message, ttl: 4000, ...opts }),
error: (message: string, opts: Partial<Omit<Toast, 'id' | 'kind' | 'message'>> = {}) =>
useToastStore.getState().push({ kind: 'error', message, ttl: 6000, ...opts }),
info: (message: string, opts: Partial<Omit<Toast, 'id' | 'kind' | 'message'>> = {}) =>
useToastStore.getState().push({ kind: 'info', message, ttl: 4000, ...opts }),
};
+53
View File
@@ -0,0 +1,53 @@
import { create } from 'zustand';
import type { Track, VibeSession } from '../types';
// V2 recommendation session state. The backend stores the plan in Redis
// (keyed by sessionId) and serves tracks one at a time via GET /v2/vibe/next.
// We keep a lookahead buffer of upcoming Track[] to feed playback.
interface VibeState {
activeSessionId: string | null;
seedTrackId: string | null;
centerTrack: Track | null;
buffer: Track[]; // lookahead buffer of upcoming recommended tracks
setActiveSession: (session: VibeSession | null) => void;
setSeedTrackId: (seedTrackId: string | null) => void;
setCenterTrack: (track: Track | null) => void;
setBuffer: (buffer: Track[]) => void;
appendBuffer: (tracks: Track[]) => void;
shiftBuffer: () => Track | undefined;
reset: () => void;
}
const initialState = {
activeSessionId: null as string | null,
seedTrackId: null as string | null,
centerTrack: null as Track | null,
buffer: [] as Track[],
};
export const useVibeStore = create<VibeState>((set, get) => ({
...initialState,
setActiveSession: (session) =>
set(
session
? { activeSessionId: session.sessionId, seedTrackId: session.seedTrackId }
: { activeSessionId: null, seedTrackId: null }
),
setSeedTrackId: (seedTrackId) => set({ seedTrackId }),
setCenterTrack: (centerTrack) => set({ centerTrack }),
setBuffer: (buffer) => set({ buffer }),
appendBuffer: (tracks) => set((state) => ({ buffer: [...state.buffer, ...tracks] })),
shiftBuffer: () => {
const { buffer } = get();
if (buffer.length === 0) return undefined;
const [head, ...rest] = buffer;
set({ buffer: rest });
return head;
},
reset: () => set({ ...initialState }),
}));
+123
View File
@@ -0,0 +1,123 @@
// Shared TypeScript types mirroring the backend (backend/src/services/db.service.ts).
// Single-user app: the backend defaults the user when no `x-user-id` header is sent,
// so the frontend currently omits the header. Add it here later for multi-user.
export type TrackState =
| 'LIBRARY'
| 'RECOMMENDED'
| 'HIDDEN'
| 'MISSING'
| 'DELETED';
export type SourceType = 'MANUAL' | 'RECOMMENDATION';
export interface TrackArtist {
id: string;
name: string;
role: 'main' | 'featured';
}
export interface Track {
id: string;
path: string;
hash: string;
title: string;
artist: string;
album_id: string;
duration: number;
state: TrackState | string;
play_count: number;
skip_count: number;
dislike_count: number;
last_played_at?: string | null;
mtime?: number | null;
source_type: SourceType | string;
artists?: TrackArtist[];
artwork_id?: string | null; // album cover art
}
export interface Artist {
id: string;
name: string;
mbid?: string | null;
discogs_id?: string | null;
image_path?: string | null;
}
export interface Album {
id: string;
artist_id: string;
title: string;
year?: number | null;
artwork_id?: string | null;
artist_name?: string | null;
}
export interface ArtistWithAlbums extends Artist {
albums: Album[];
}
export interface AlbumWithTracks extends Album {
tracks: Track[];
}
// Genres are listed via GET /api/genres (with track_count), fetched individually
// via GET /api/genres/:id, and their tracks via GET /api/genres/:id/tracks.
export interface Genre {
id: string;
name: string;
parent_id?: string | null;
track_count?: number;
}
export interface HealthResponse {
postgres: 'ok' | 'error' | 'unknown';
redis: 'ok' | 'error' | 'unknown';
}
// Active v2 recommendation session. The sessionId comes from
// POST /api/v2/vibe/start and identifies the Redis-stored plan.
export interface VibeSession {
sessionId: string;
seedTrackId: string | null;
}
// A row from GET /api/history: a Track plus playback metadata.
export interface HistoryEntry extends Track {
history_id: string;
batch_id: string | null;
played_at: string;
completed: boolean;
}
export interface DislikeEntry {
track_id: string;
disliked_at: string;
warned_at: string | null;
deleted_at: string | null;
grace_hours: number;
state: 'HIDDEN' | 'WARNED' | 'DELETED' | string;
track_title: string;
track_artist: string;
track_path: string;
}
export const FEEDBACK_ACTIONS = [
'promoted',
'disliked',
'skipped',
'deleted_permanent',
] as const;
export type FeedbackAction = (typeof FEEDBACK_ACTIONS)[number];
// Typesense-style search response surfaced by GET /api/search?q=.
export interface SearchHit<T> {
document: T;
}
export interface SearchResponse {
found?: number;
hits?: SearchHit<Track>[];
[key: string]: unknown;
}
+84
View File
@@ -0,0 +1,84 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
fontFamily: {
sans: ['Geist', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'],
mono: ['Geist Mono', 'ui-monospace', 'SFMono-Regular', 'Menlo', 'monospace'],
},
fontSize: {
'2xs': ['11px', '14px'],
xs: ['12px', '16px'],
sm: ['14px', '20px'],
base: ['16px', '24px'],
lg: ['18px', '26px'],
xl: ['24px', '32px'],
'2xl': ['32px', '40px'],
},
colors: {
bg0: 'var(--ethos-bg0)',
bg1: 'var(--ethos-bg1)',
bg2: 'var(--ethos-bg2)',
surface0: 'var(--ethos-surface0)',
surface1: 'var(--ethos-surface1)',
surface2: 'var(--ethos-surface2)',
border: 'var(--ethos-border)',
text: 'var(--ethos-text)',
secondary: 'var(--ethos-secondary)',
muted: 'var(--ethos-muted)',
disabled: 'var(--ethos-disabled)',
accent: 'var(--ethos-accent)',
'accent-h': 'var(--ethos-accent-hover)',
green: 'var(--ethos-green)',
amber: 'var(--ethos-amber)',
red: 'var(--ethos-red)',
purple: 'var(--ethos-purple)',
cyan: 'var(--ethos-cyan)',
orange: 'var(--ethos-orange)',
'on-accent': 'var(--ethos-on-accent)',
},
borderRadius: {
DEFAULT: '8px',
sm: '4px',
md: '8px',
lg: '12px',
xl: '16px',
full: '9999px',
},
spacing: {
0.5: '4px',
1: '8px',
1.5: '12px',
2: '16px',
2.5: '20px',
3: '24px',
4: '32px',
5: '40px',
6: '48px',
7: '56px',
8: '64px',
},
animation: {
'fade-in': 'fadeIn 150ms ease both',
'rise': 'rise 150ms ease both',
'slide-in': 'slideIn 150ms ease both',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
rise: {
'0%': { opacity: '0', transform: 'translateY(6px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
slideIn: {
'0%': { opacity: '0', transform: 'translateX(8px)' },
'100%': { opacity: '1', transform: 'translateX(0)' },
},
},
},
},
plugins: [],
};
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["vite/client"]
},
"include": ["src"]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:3000',
}
},
build: {
outDir: 'dist',
emptyOutDir: true,
}
})