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