1707 lines
66 KiB
Markdown
1707 lines
66 KiB
Markdown
# UI Overhaul — Implementation Plan
|
||
**Date:** 2026-06-08
|
||
**Phases:** 1–4 per `docs/ui-rework.md`
|
||
**Stack:** React 18 · TanStack Router · Zustand · react-query · Tailwind CSS 3 · lucide-react
|
||
|
||
---
|
||
|
||
## File map
|
||
|
||
| Action | Path |
|
||
|--------|------|
|
||
| **Modify** | `frontend/tailwind.config.js` |
|
||
| **Modify** | `frontend/src/index.css` |
|
||
| **Modify** | `frontend/src/lib/theme.ts` |
|
||
| **Modify** | `frontend/src/router.tsx` |
|
||
| **Modify** | `frontend/src/types.ts` |
|
||
| **Modify** | `frontend/src/pages/Home.tsx` |
|
||
| **Modify** | `frontend/src/pages/Tracks.tsx` |
|
||
| **Modify** | `frontend/src/pages/Artists.tsx` |
|
||
| **Modify** | `frontend/src/pages/ArtistDetail.tsx` |
|
||
| **Modify** | `frontend/src/pages/Albums.tsx` |
|
||
| **Modify** | `frontend/src/pages/AlbumDetail.tsx` |
|
||
| **Modify** | `frontend/src/pages/Genres.tsx` |
|
||
| **Modify** | `frontend/src/pages/Discover.tsx` |
|
||
| **Modify** | `frontend/src/pages/Vibe.tsx` |
|
||
| **Modify** | `frontend/src/pages/Search.tsx` |
|
||
| **Modify** | `frontend/src/pages/Quarantine.tsx` |
|
||
| **Modify** | `frontend/src/pages/Settings.tsx` |
|
||
| **Create** | `frontend/src/components/AppShell.tsx` |
|
||
| **Create** | `frontend/src/components/NavRail.tsx` |
|
||
| **Create** | `frontend/src/components/TopBar.tsx` |
|
||
| **Create** | `frontend/src/components/PlaybackBar.tsx` |
|
||
| **Create** | `frontend/src/components/NowPlayingPanel.tsx` |
|
||
| **Create** | `frontend/src/components/Artwork.tsx` |
|
||
| **Create** | `frontend/src/components/MediaCard.tsx` |
|
||
| **Create** | `frontend/src/components/ShelfRow.tsx` |
|
||
| **Create** | `frontend/src/components/TrackRow.tsx` |
|
||
| **Create** | `frontend/src/services/quarantineService.ts` |
|
||
| **Delete** | `frontend/src/components/Layout.tsx` |
|
||
| **Delete** | `frontend/src/components/NowPlayingBar.tsx` |
|
||
| **Delete** | `frontend/src/pages/LibraryTrackRow.tsx` |
|
||
|
||
---
|
||
|
||
## Task 1 — Expand design tokens
|
||
|
||
**Goal:** Add 8 new CSS vars, wire every token into `tailwind.config.js` as semantic color keys, update all 4 existing theme presets + add a Default(Purple) preset.
|
||
|
||
**Files:** `frontend/tailwind.config.js`, `frontend/src/index.css`, `frontend/src/lib/theme.ts`
|
||
|
||
**Steps:**
|
||
|
||
1. Replace `frontend/tailwind.config.js`:
|
||
```js
|
||
/** @type {import('tailwindcss').Config} */
|
||
export default {
|
||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||
theme: {
|
||
extend: {
|
||
colors: {
|
||
background: 'var(--bg)',
|
||
elevated: 'var(--bg-elevated)',
|
||
surface: 'var(--surface)',
|
||
'surface-h':'var(--surface-hover)',
|
||
line: 'var(--border)',
|
||
primary: 'var(--text)',
|
||
muted: 'var(--text-muted)',
|
||
accent: 'var(--accent)',
|
||
'accent-h': 'var(--accent-hover)',
|
||
'on-accent':'var(--on-accent)',
|
||
'grad-a': 'var(--card-grad-a)',
|
||
'grad-b': 'var(--card-grad-b)',
|
||
},
|
||
},
|
||
},
|
||
plugins: [],
|
||
};
|
||
```
|
||
|
||
2. Replace the `:root` block in `frontend/src/index.css`:
|
||
```css
|
||
@tailwind base;
|
||
@tailwind components;
|
||
@tailwind utilities;
|
||
|
||
:root {
|
||
--bg: #000000;
|
||
--bg-elevated: #111113;
|
||
--surface: #18181b;
|
||
--surface-hover: #27272a;
|
||
--border: #3f3f46;
|
||
--text: #ffffff;
|
||
--text-muted: #a1a1aa;
|
||
--accent: #3b82f6;
|
||
--accent-hover: #2563eb;
|
||
--on-accent: #ffffff;
|
||
--card-grad-a: #1e293b;
|
||
--card-grad-b: #0f172a;
|
||
}
|
||
|
||
body {
|
||
margin: 0;
|
||
padding: 0;
|
||
background-color: var(--bg);
|
||
color: var(--text);
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||
}
|
||
```
|
||
|
||
3. Replace `frontend/src/lib/theme.ts` — expand every preset's `vars` to include all 12 tokens; add a "Default (Purple)" preset:
|
||
```ts
|
||
export interface ThemePreset {
|
||
id: string;
|
||
name: string;
|
||
vars: Record<string, string>;
|
||
swatch: string;
|
||
}
|
||
|
||
export const THEMES: ThemePreset[] = [
|
||
{
|
||
id: 'purple',
|
||
name: 'Default (Purple)',
|
||
swatch: '#1e1b4b',
|
||
vars: {
|
||
'--bg': '#0d0b1a', '--bg-elevated': '#13102a', '--surface': '#1e1b4b',
|
||
'--surface-hover': '#2d2a5e', '--border': '#4c1d95',
|
||
'--text': '#ede9fe', '--text-muted': '#a78bfa',
|
||
'--accent': '#7c3aed', '--accent-hover': '#6d28d9', '--on-accent': '#ffffff',
|
||
'--card-grad-a': '#1e1b4b', '--card-grad-b': '#0d0b1a',
|
||
},
|
||
},
|
||
{
|
||
id: 'dark',
|
||
name: 'Dark',
|
||
swatch: '#18181b',
|
||
vars: {
|
||
'--bg': '#000000', '--bg-elevated': '#111113', '--surface': '#18181b',
|
||
'--surface-hover': '#27272a', '--border': '#3f3f46',
|
||
'--text': '#ffffff', '--text-muted': '#a1a1aa',
|
||
'--accent': '#3b82f6', '--accent-hover': '#2563eb', '--on-accent': '#ffffff',
|
||
'--card-grad-a': '#1e293b', '--card-grad-b': '#0f172a',
|
||
},
|
||
},
|
||
{
|
||
id: 'midnight',
|
||
name: 'Midnight',
|
||
swatch: '#0f172a',
|
||
vars: {
|
||
'--bg': '#020617', '--bg-elevated': '#0a1120', '--surface': '#0f172a',
|
||
'--surface-hover': '#1e293b', '--border': '#334155',
|
||
'--text': '#e2e8f0', '--text-muted': '#94a3b8',
|
||
'--accent': '#6366f1', '--accent-hover': '#4f46e5', '--on-accent': '#ffffff',
|
||
'--card-grad-a': '#1e1b4b', '--card-grad-b': '#020617',
|
||
},
|
||
},
|
||
{
|
||
id: 'forest',
|
||
name: 'Forest',
|
||
swatch: '#0c1f17',
|
||
vars: {
|
||
'--bg': '#03120c', '--bg-elevated': '#071a10', '--surface': '#0c1f17',
|
||
'--surface-hover': '#163024', '--border': '#1f4a33',
|
||
'--text': '#e7f5ee', '--text-muted': '#86efac',
|
||
'--accent': '#10b981', '--accent-hover': '#059669', '--on-accent': '#ffffff',
|
||
'--card-grad-a': '#0c1f17', '--card-grad-b': '#03120c',
|
||
},
|
||
},
|
||
{
|
||
id: 'plum',
|
||
name: 'Plum',
|
||
swatch: '#1e1029',
|
||
vars: {
|
||
'--bg': '#100619', '--bg-elevated': '#180924', '--surface': '#1e1029',
|
||
'--surface-hover': '#2d1a3d', '--border': '#5b2d7a',
|
||
'--text': '#f3e8ff', '--text-muted': '#d8b4fe',
|
||
'--accent': '#a855f7', '--accent-hover': '#9333ea', '--on-accent': '#ffffff',
|
||
'--card-grad-a': '#1e1029', '--card-grad-b': '#100619',
|
||
},
|
||
},
|
||
];
|
||
|
||
export const DEFAULT_THEME_ID = 'purple';
|
||
|
||
export const STORAGE_KEYS = {
|
||
theme: 'muzick.settings.theme',
|
||
volume: 'muzick.settings.volume',
|
||
} as const;
|
||
|
||
export function applyTheme(theme: ThemePreset): void {
|
||
const root = document.documentElement;
|
||
for (const [key, value] of Object.entries(theme.vars)) {
|
||
root.style.setProperty(key, value);
|
||
}
|
||
}
|
||
|
||
export function readStoredThemeId(): string {
|
||
try {
|
||
const stored = localStorage.getItem(STORAGE_KEYS.theme);
|
||
if (stored && THEMES.some((t) => t.id === stored)) return stored;
|
||
} catch { /* unavailable */ }
|
||
return DEFAULT_THEME_ID;
|
||
}
|
||
|
||
export function initTheme(): void {
|
||
const id = readStoredThemeId();
|
||
const theme = THEMES.find((t) => t.id === id) ?? THEMES[0];
|
||
applyTheme(theme);
|
||
}
|
||
|
||
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;
|
||
}
|
||
```
|
||
|
||
**Acceptance criteria:** `npm run typecheck` in `frontend/` passes; no TS errors in theme.ts.
|
||
|
||
**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0`
|
||
|
||
---
|
||
|
||
## Task 2 — Artwork component
|
||
|
||
**Goal:** A reusable `<Artwork>` that renders a deterministic gradient placeholder derived from a seed string (title/artist), with an optional `src` URL override.
|
||
|
||
**Files:** `frontend/src/components/Artwork.tsx` (create)
|
||
|
||
**Steps:**
|
||
|
||
1. Create `frontend/src/components/Artwork.tsx`:
|
||
```tsx
|
||
interface ArtworkProps {
|
||
seed: string;
|
||
src?: string | null;
|
||
className?: string;
|
||
rounded?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
export function Artwork({ seed, src, className = '', rounded = 'md' }: ArtworkProps) {
|
||
const hue = hueFromString(seed);
|
||
const gradient = `linear-gradient(135deg, hsl(${hue},45%,22%), hsl(${(hue + 60) % 360},35%,12%))`;
|
||
const r = { sm: 'rounded-sm', md: 'rounded-md', lg: 'rounded-lg', xl: 'rounded-xl', full: 'rounded-full' }[rounded];
|
||
|
||
if (src) {
|
||
return <img src={src} alt={seed} className={`object-cover ${r} ${className}`} />;
|
||
}
|
||
return <div className={`${r} ${className}`} style={{ background: gradient }} />;
|
||
}
|
||
```
|
||
|
||
**Acceptance criteria:** Component renders with a gradient when no `src` given; renders an `<img>` when `src` is provided.
|
||
|
||
**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0`
|
||
|
||
---
|
||
|
||
## Task 3 — TrackRow component
|
||
|
||
**Goal:** A single reusable `<TrackRow>` that replaces `LibraryTrackRow`, uses semantic token classes, and exposes an optional `onDislike` callback (so callers handle invalidation).
|
||
|
||
**Files:** `frontend/src/components/TrackRow.tsx` (create)
|
||
|
||
**Steps:**
|
||
|
||
1. Create `frontend/src/components/TrackRow.tsx`:
|
||
```tsx
|
||
import { Play, Pause, Heart, ThumbsDown, Music } from 'lucide-react';
|
||
import type { Track } from '../types';
|
||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||
import { favoritesService } from '../services/favoritesService';
|
||
import { Artwork } from './Artwork';
|
||
|
||
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')}`;
|
||
}
|
||
|
||
interface TrackRowProps {
|
||
track: Track;
|
||
queue: Track[];
|
||
index: number;
|
||
showActions?: boolean;
|
||
trackNumber?: number;
|
||
onDislike?: (trackId: string) => void;
|
||
}
|
||
|
||
export function TrackRow({ track, queue, index, showActions = true, trackNumber, onDislike }: TrackRowProps) {
|
||
const { setQueue, playTrack, play, pause, currentTrack, isPlaying } = usePlaybackStore();
|
||
const isCurrent = currentTrack?.id === track.id;
|
||
|
||
const handlePlay = () => {
|
||
if (isCurrent) { isPlaying ? pause() : play(); return; }
|
||
setQueue(queue.slice(index));
|
||
playTrack(track);
|
||
};
|
||
|
||
const handleFavorite = (e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
void favoritesService.add(track.id).catch(() => undefined);
|
||
};
|
||
|
||
const handleDislike = (e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
void favoritesService.dislike(track.id).catch(() => undefined);
|
||
onDislike?.(track.id);
|
||
};
|
||
|
||
return (
|
||
<div
|
||
onClick={handlePlay}
|
||
className={`group flex w-full cursor-pointer items-center gap-3 rounded-lg border p-2.5 transition-colors ${
|
||
isCurrent
|
||
? 'border-accent/60 bg-accent/10'
|
||
: 'border-line bg-surface/50 hover:border-line hover:bg-surface-h'
|
||
}`}
|
||
>
|
||
<div className="relative flex h-10 w-10 flex-none items-center justify-center rounded overflow-hidden">
|
||
<Artwork seed={`${track.title} ${track.artist}`} className="absolute inset-0 w-full h-full" />
|
||
{trackNumber !== undefined ? (
|
||
<span className={`relative z-10 text-sm tabular-nums text-muted group-hover:opacity-0 ${isCurrent && isPlaying ? 'opacity-0' : ''}`}>
|
||
{trackNumber}
|
||
</span>
|
||
) : (
|
||
<Music size={18} className={`relative z-10 text-muted group-hover:opacity-0 ${isCurrent && isPlaying ? 'opacity-0' : ''}`} />
|
||
)}
|
||
{isCurrent && isPlaying ? (
|
||
<Pause size={18} className="absolute z-20 text-primary opacity-100" />
|
||
) : (
|
||
<Play size={18} className="absolute z-20 text-primary opacity-0 group-hover:opacity-100" />
|
||
)}
|
||
</div>
|
||
|
||
<div className="min-w-0 flex-1">
|
||
<div className={`truncate text-sm font-medium ${isCurrent ? 'text-accent' : 'text-primary'}`}>
|
||
{track.title || 'Untitled'}
|
||
</div>
|
||
<div className="truncate text-xs text-muted">{track.artist || 'Unknown artist'}</div>
|
||
</div>
|
||
|
||
{showActions && (
|
||
<div className="flex flex-none items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||
<button onClick={handleFavorite} title="Favorite" className="rounded p-1.5 text-muted hover:bg-surface-h hover:text-pink-400">
|
||
<Heart size={16} />
|
||
</button>
|
||
<button onClick={handleDislike} title="Dislike" className="rounded p-1.5 text-muted hover:bg-surface-h hover:text-red-400">
|
||
<ThumbsDown size={16} />
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex-none text-xs tabular-nums text-muted">{formatDuration(track.duration)}</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
**Acceptance criteria:** Renders with semantic classes; `isCurrent` highlights with accent; `trackNumber` or icon shown; actions hidden until hover.
|
||
|
||
**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0`
|
||
|
||
---
|
||
|
||
## Task 4 — PlaybackBar
|
||
|
||
**Goal:** Full-width bottom transport bar (replaces `NowPlayingBar`): artwork thumbnail + title/artist on left, controls + scrubber in center, volume + panel-toggle on right.
|
||
|
||
**Files:** `frontend/src/components/PlaybackBar.tsx` (create)
|
||
|
||
**Steps:**
|
||
|
||
1. Create `frontend/src/components/PlaybackBar.tsx`:
|
||
```tsx
|
||
import { Play, Pause, SkipBack, SkipForward, Volume2, ListMusic } from 'lucide-react';
|
||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||
import { Artwork } from './Artwork';
|
||
import { formatDuration } from './TrackRow';
|
||
|
||
interface PlaybackBarProps {
|
||
panelOpen: boolean;
|
||
onTogglePanel: () => void;
|
||
}
|
||
|
||
export function PlaybackBar({ panelOpen, onTogglePanel }: PlaybackBarProps) {
|
||
const { currentTrack, isPlaying, position, duration, volume, play, pause, next, prev, setPosition, setVolume } = usePlaybackStore();
|
||
|
||
return (
|
||
<div className="h-20 bg-elevated border-t border-line px-4 flex items-center gap-4 shrink-0">
|
||
{/* Track info */}
|
||
<div className="flex items-center gap-3 w-56 min-w-0 shrink-0">
|
||
{currentTrack ? (
|
||
<>
|
||
<div className="w-12 h-12 flex-none rounded overflow-hidden">
|
||
<Artwork seed={`${currentTrack.title} ${currentTrack.artist}`} className="w-full h-full" />
|
||
</div>
|
||
<div className="min-w-0">
|
||
<div className="text-sm font-semibold text-primary truncate">{currentTrack.title}</div>
|
||
<div className="text-xs text-muted truncate">{currentTrack.artist}</div>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<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-5">
|
||
<button onClick={prev} className="text-muted hover:text-primary" aria-label="Previous">
|
||
<SkipBack size={20} />
|
||
</button>
|
||
<button
|
||
onClick={() => isPlaying ? pause() : play()}
|
||
disabled={!currentTrack}
|
||
className="w-9 h-9 rounded-full bg-accent hover:bg-accent-h flex items-center justify-center text-on-accent disabled:opacity-40 transition-colors"
|
||
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-primary" aria-label="Next">
|
||
<SkipForward size={20} />
|
||
</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 accent-[var(--accent)]"
|
||
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 accent-[var(--accent)]"
|
||
aria-label="Volume"
|
||
/>
|
||
<button
|
||
onClick={onTogglePanel}
|
||
className={`p-2 rounded-md transition-colors ${panelOpen ? 'bg-accent/20 text-accent' : 'text-muted hover:text-primary'}`}
|
||
aria-label="Toggle queue panel"
|
||
title="Up Next"
|
||
>
|
||
<ListMusic size={18} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
**Acceptance criteria:** Play/pause button is a circle with accent fill; scrubber spans center; volume + panel toggle on right; disabled state when no track.
|
||
|
||
**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0`
|
||
|
||
---
|
||
|
||
## Task 5 — NowPlayingPanel
|
||
|
||
**Goal:** Collapsible right panel — large artwork, track info, scrubber, transport, Up Next queue list.
|
||
|
||
**Files:** `frontend/src/components/NowPlayingPanel.tsx` (create)
|
||
|
||
**Steps:**
|
||
|
||
1. Create `frontend/src/components/NowPlayingPanel.tsx`:
|
||
```tsx
|
||
import { X, Play, Pause, SkipBack, SkipForward, Music } from 'lucide-react';
|
||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||
import { Artwork } from './Artwork';
|
||
import { formatDuration } from './TrackRow';
|
||
|
||
interface NowPlayingPanelProps {
|
||
onClose: () => void;
|
||
}
|
||
|
||
export function NowPlayingPanel({ onClose }: NowPlayingPanelProps) {
|
||
const { currentTrack, queue, isPlaying, position, duration, play, pause, next, prev, setPosition, playTrack, setQueue } = usePlaybackStore();
|
||
|
||
const currentIdx = currentTrack ? queue.findIndex((t) => t.id === currentTrack.id) : -1;
|
||
const upNext = currentIdx >= 0 ? queue.slice(currentIdx + 1) : queue;
|
||
|
||
return (
|
||
<aside className="w-72 flex flex-col border-l border-line bg-elevated overflow-hidden shrink-0">
|
||
<div className="flex items-center justify-between px-4 py-3 border-b border-line">
|
||
<span className="text-sm font-semibold text-primary">Now Playing</span>
|
||
<button onClick={onClose} className="text-muted hover:text-primary p-1 rounded">
|
||
<X size={16} />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="p-4 space-y-4">
|
||
<div className="aspect-square rounded-xl overflow-hidden">
|
||
<Artwork
|
||
seed={currentTrack ? `${currentTrack.title} ${currentTrack.artist}` : 'empty'}
|
||
className="w-full h-full"
|
||
rounded="xl"
|
||
/>
|
||
</div>
|
||
|
||
{currentTrack ? (
|
||
<div className="text-center space-y-0.5">
|
||
<div className="font-bold text-primary truncate">{currentTrack.title}</div>
|
||
<div className="text-sm text-muted truncate">{currentTrack.artist}</div>
|
||
</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 h-1 cursor-pointer accent-[var(--accent)]"
|
||
/>
|
||
<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-primary"><SkipBack size={20} /></button>
|
||
<button
|
||
onClick={() => isPlaying ? pause() : play()}
|
||
disabled={!currentTrack}
|
||
className="w-10 h-10 rounded-full bg-accent hover:bg-accent-h flex items-center justify-center text-on-accent disabled:opacity-40"
|
||
>
|
||
{isPlaying ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />}
|
||
</button>
|
||
<button onClick={next} className="text-muted hover:text-primary"><SkipForward size={20} /></button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-y-auto border-t border-line">
|
||
<div className="px-4 py-2 text-xs font-semibold text-muted uppercase tracking-wide">
|
||
Up Next ({upNext.length})
|
||
</div>
|
||
{upNext.length === 0 ? (
|
||
<div className="px-4 pb-4 text-sm text-muted italic">Queue is empty.</div>
|
||
) : (
|
||
<ul>
|
||
{upNext.map((track, i) => (
|
||
<li key={`${track.id}-${i}`}>
|
||
<button
|
||
onClick={() => { setQueue(upNext.slice(i)); playTrack(track); }}
|
||
className="flex w-full items-center gap-2 px-4 py-2.5 text-left hover:bg-surface-h"
|
||
>
|
||
<div className="w-8 h-8 flex-none rounded overflow-hidden">
|
||
<Artwork seed={`${track.title} ${track.artist}`} className="w-full h-full" />
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="truncate text-sm text-primary">{track.title}</div>
|
||
<div className="truncate text-xs text-muted">{track.artist}</div>
|
||
</div>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
</aside>
|
||
);
|
||
}
|
||
```
|
||
|
||
**Acceptance criteria:** Panel shows artwork, scrubber, transport, and Up Next list; `onClose` hides it.
|
||
|
||
**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0`
|
||
|
||
---
|
||
|
||
## Task 6 — NavRail
|
||
|
||
**Goal:** Persistent left navigation column with two groups (Library, Personal), active state via accent, links to all routes.
|
||
|
||
**Files:** `frontend/src/components/NavRail.tsx` (create)
|
||
|
||
**Steps:**
|
||
|
||
1. Create `frontend/src/components/NavRail.tsx`:
|
||
```tsx
|
||
import { Link } from '@tanstack/react-router';
|
||
import {
|
||
Home, Music, Disc3, Users, Tags, Zap, Compass,
|
||
ShieldAlert, Settings,
|
||
} from 'lucide-react';
|
||
|
||
const NAV_GROUPS = [
|
||
{
|
||
label: 'Library',
|
||
items: [
|
||
{ to: '/', icon: Home, label: 'Home' },
|
||
{ to: '/tracks', icon: Music, label: 'Songs' },
|
||
{ to: '/albums', icon: Disc3, label: 'Albums' },
|
||
{ to: '/artists', icon: Users, label: 'Artists' },
|
||
{ to: '/genres', icon: Tags, label: 'Genres' },
|
||
{ to: '/vibe', icon: Zap, label: 'Vibe' },
|
||
{ to: '/discover', icon: Compass, label: 'Discover' },
|
||
],
|
||
},
|
||
{
|
||
label: 'Personal',
|
||
items: [
|
||
{ to: '/quarantine', icon: ShieldAlert, label: 'Quarantine' },
|
||
{ to: '/settings', icon: Settings, label: 'Settings' },
|
||
],
|
||
},
|
||
] as const;
|
||
|
||
const base = 'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors w-full';
|
||
const inactive = 'text-muted hover:bg-surface-h hover:text-primary';
|
||
const active = 'bg-accent/15 text-accent font-medium';
|
||
|
||
export function NavRail() {
|
||
return (
|
||
<aside className="w-56 flex flex-col bg-surface border-r border-line shrink-0 overflow-y-auto">
|
||
<div className="px-4 py-5">
|
||
<span className="text-xl font-bold text-accent tracking-tight">Muzick</span>
|
||
</div>
|
||
<nav className="flex-1 px-3 space-y-5 pb-4">
|
||
{NAV_GROUPS.map((group) => (
|
||
<div key={group.label}>
|
||
<div className="px-3 mb-1.5 text-xs font-semibold uppercase tracking-wider text-muted/60">
|
||
{group.label}
|
||
</div>
|
||
<ul className="space-y-0.5">
|
||
{group.items.map(({ to, icon: Icon, label }) => (
|
||
<li key={to}>
|
||
<Link
|
||
to={to}
|
||
activeOptions={{ exact: to === '/' }}
|
||
activeProps={{ className: `${base} ${active}` }}
|
||
inactiveProps={{ className: `${base} ${inactive}` }}
|
||
>
|
||
<Icon size={18} />
|
||
{label}
|
||
</Link>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
))}
|
||
</nav>
|
||
</aside>
|
||
);
|
||
}
|
||
```
|
||
|
||
**Acceptance criteria:** Active route link has accent background; two labelled groups; logo at top.
|
||
|
||
**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0`
|
||
|
||
---
|
||
|
||
## Task 7 — TopBar
|
||
|
||
**Goal:** Narrow top bar with logo gap (the NavRail handles branding) and a global search input that navigates to `/search?q=` on submit.
|
||
|
||
**Files:** `frontend/src/components/TopBar.tsx` (create)
|
||
|
||
**Steps:**
|
||
|
||
1. Create `frontend/src/components/TopBar.tsx`:
|
||
```tsx
|
||
import { useState } from 'react';
|
||
import { Search } from 'lucide-react';
|
||
import { useNavigate } from '@tanstack/react-router';
|
||
|
||
export function TopBar() {
|
||
const [q, setQ] = useState('');
|
||
const navigate = useNavigate();
|
||
|
||
const handleSubmit = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (q.trim()) void navigate({ to: '/search', search: { q: q.trim() } as any });
|
||
};
|
||
|
||
return (
|
||
<header className="h-14 bg-elevated border-b border-line flex items-center px-4 gap-4 shrink-0">
|
||
<div className="w-56 shrink-0" /> {/* aligns with NavRail width */}
|
||
<form onSubmit={handleSubmit} className="flex-1 max-w-xl">
|
||
<div className="relative">
|
||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" />
|
||
<input
|
||
type="search"
|
||
value={q}
|
||
onChange={(e) => setQ(e.target.value)}
|
||
placeholder="Search music… (Enter)"
|
||
className="w-full bg-surface border border-line rounded-lg pl-9 pr-4 py-1.5 text-sm text-primary placeholder:text-muted outline-none focus:border-accent transition-colors"
|
||
/>
|
||
</div>
|
||
</form>
|
||
</header>
|
||
);
|
||
}
|
||
```
|
||
|
||
**Acceptance criteria:** Submitting the form navigates to `/search` with a `q` param; input styled with surface/border tokens.
|
||
|
||
**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0`
|
||
|
||
---
|
||
|
||
## Task 8 — AppShell + router update
|
||
|
||
**Goal:** Replace `Layout` with `AppShell` (3-pane grid), wire `NowPlayingPanel` (starts collapsed), `PlaybackBar`, `NavRail`, `TopBar`, and `AudioEngine`. Update `router.tsx` to use `AppShell`.
|
||
|
||
**Files:** `frontend/src/components/AppShell.tsx` (create), `frontend/src/router.tsx` (modify)
|
||
|
||
**Steps:**
|
||
|
||
1. Create `frontend/src/components/AppShell.tsx`:
|
||
```tsx
|
||
import { useState } 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';
|
||
|
||
export default function AppShell() {
|
||
const [panelOpen, setPanelOpen] = useState(false);
|
||
|
||
return (
|
||
<div className="flex flex-col h-screen bg-background text-primary overflow-hidden">
|
||
<TopBar />
|
||
<div className="flex flex-1 overflow-hidden">
|
||
<NavRail />
|
||
<main className="flex-1 overflow-y-auto p-6">
|
||
<Outlet />
|
||
</main>
|
||
{panelOpen && <NowPlayingPanel onClose={() => setPanelOpen(false)} />}
|
||
</div>
|
||
<PlaybackBar panelOpen={panelOpen} onTogglePanel={() => setPanelOpen((o) => !o)} />
|
||
<AudioEngine />
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
2. In `frontend/src/router.tsx`, replace `import Layout` and its usage:
|
||
```tsx
|
||
// replace:
|
||
import Layout from './components/Layout';
|
||
// with:
|
||
import AppShell from './components/AppShell';
|
||
|
||
// replace in rootRoute component:
|
||
// <Layout><Outlet /></Layout>
|
||
// with:
|
||
// <AppShell />
|
||
// (AppShell renders <Outlet /> itself)
|
||
```
|
||
|
||
Full updated rootRoute component:
|
||
```tsx
|
||
export const rootRoute = createRootRoute({
|
||
component: AppShell,
|
||
});
|
||
```
|
||
|
||
**Acceptance criteria:** App renders the 3-pane layout; panel hidden by default; clicking the queue icon in PlaybackBar opens/closes the panel.
|
||
|
||
**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0`
|
||
|
||
---
|
||
|
||
## Task 9 — MediaCard + ShelfRow
|
||
|
||
**Goal:** `<MediaCard>` is a square artwork card with hover-play overlay used in grids and carousels. `<ShelfRow>` is a horizontal scroll container with a title + optional "View all" link.
|
||
|
||
**Files:** `frontend/src/components/MediaCard.tsx` (create), `frontend/src/components/ShelfRow.tsx` (create)
|
||
|
||
**Steps:**
|
||
|
||
1. Create `frontend/src/components/MediaCard.tsx`:
|
||
```tsx
|
||
import { Play } from 'lucide-react';
|
||
import { Artwork } from './Artwork';
|
||
|
||
interface MediaCardProps {
|
||
seed: string;
|
||
title: string;
|
||
subtitle?: string;
|
||
artSrc?: string | null;
|
||
onClick?: () => void;
|
||
href?: string;
|
||
}
|
||
|
||
export function MediaCard({ seed, title, subtitle, artSrc, onClick }: MediaCardProps) {
|
||
return (
|
||
<button
|
||
onClick={onClick}
|
||
className="group flex flex-col gap-2 text-left w-full bg-surface hover:bg-surface-h border border-line rounded-xl p-3 transition-colors"
|
||
>
|
||
<div className="relative aspect-square rounded-lg overflow-hidden w-full">
|
||
<Artwork seed={seed} src={artSrc} className="w-full h-full" rounded="lg" />
|
||
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity">
|
||
<div className="w-10 h-10 rounded-full bg-accent flex items-center justify-center shadow-lg">
|
||
<Play size={18} fill="white" className="text-on-accent ml-0.5" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="min-w-0">
|
||
<div className="truncate text-sm font-semibold text-primary">{title}</div>
|
||
{subtitle && <div className="truncate text-xs text-muted mt-0.5">{subtitle}</div>}
|
||
</div>
|
||
</button>
|
||
);
|
||
}
|
||
```
|
||
|
||
2. Create `frontend/src/components/ShelfRow.tsx`:
|
||
```tsx
|
||
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-primary">{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>
|
||
);
|
||
}
|
||
```
|
||
|
||
**Acceptance criteria:** MediaCard shows gradient artwork with play overlay on hover; ShelfRow scrolls horizontally and shows "View all" link when `viewAllTo` given.
|
||
|
||
**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0`
|
||
|
||
---
|
||
|
||
## Task 10 — Rework Home.tsx
|
||
|
||
**Goal:** Replace the current two-section Home with Quick Access cards row + three shelf rows (Continue Listening, Recently Added, Most Played).
|
||
|
||
**Files:** `frontend/src/pages/Home.tsx` (modify)
|
||
|
||
**Steps:**
|
||
|
||
1. Replace `frontend/src/pages/Home.tsx` entirely:
|
||
```tsx
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { Clock, Heart, Star, PlusCircle } from 'lucide-react';
|
||
import { ShelfRow } from '../components/ShelfRow';
|
||
import { MediaCard } from '../components/MediaCard';
|
||
import { historyService } from '../services/historyService';
|
||
import { trackService } from '../services/trackService';
|
||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||
import type { HistoryEntry, Track } from '../types';
|
||
|
||
interface QuickCard {
|
||
label: string;
|
||
icon: React.ReactNode;
|
||
to: string;
|
||
gradient: string;
|
||
}
|
||
|
||
const QUICK: QuickCard[] = [
|
||
{ label: 'Favorites', icon: <Heart size={20} />, to: '/tracks', gradient: 'from-pink-900/80 to-rose-950/80' },
|
||
{ label: 'Recently Added', icon: <PlusCircle size={20} />, to: '/tracks', gradient: 'from-blue-900/80 to-indigo-950/80' },
|
||
{ label: 'Most Played', icon: <Star size={20} />, to: '/tracks', gradient: 'from-amber-900/80 to-orange-950/80' },
|
||
{ label: 'History', icon: <Clock size={20} />, to: '/tracks', gradient: 'from-emerald-900/80 to-teal-950/80' },
|
||
];
|
||
|
||
export default function Home() {
|
||
const { setQueue, playTrack } = usePlaybackStore();
|
||
|
||
const history = useQuery<HistoryEntry[]>({
|
||
queryKey: ['history'],
|
||
queryFn: () => historyService.list(),
|
||
});
|
||
|
||
const recentlyAdded = useQuery<Track[]>({
|
||
queryKey: ['recently-added'],
|
||
queryFn: () => trackService.listTracks({ limit: 20 }),
|
||
select: (tracks) => [...tracks].sort((a, b) => (b.mtime ?? 0) - (a.mtime ?? 0)).slice(0, 12),
|
||
});
|
||
|
||
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 (
|
||
<div className="space-y-8 max-w-5xl">
|
||
<div>
|
||
<h1 className="text-3xl font-bold text-primary">Good listening</h1>
|
||
<p className="text-muted mt-1">Your music, your way.</p>
|
||
</div>
|
||
|
||
{/* Quick access */}
|
||
<section>
|
||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||
{QUICK.map((card) => (
|
||
<div
|
||
key={card.label}
|
||
className={`flex items-center gap-3 rounded-lg bg-gradient-to-br ${card.gradient} border border-line/40 px-4 py-3 cursor-pointer hover:opacity-90 transition-opacity`}
|
||
>
|
||
<span className="text-primary/70">{card.icon}</span>
|
||
<span className="text-sm font-semibold text-primary">{card.label}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<ShelfRow title="Continue Listening" viewAllTo="/tracks">
|
||
{history.isLoading ? (
|
||
<p className="text-sm text-muted py-4">Loading…</p>
|
||
) : 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}
|
||
onClick={() => playFrom(historyTracks, i)}
|
||
/>
|
||
</div>
|
||
))
|
||
)}
|
||
</ShelfRow>
|
||
|
||
<ShelfRow title="Recently Added" viewAllTo="/tracks">
|
||
{recentlyAdded.isLoading ? (
|
||
<p className="text-sm text-muted py-4">Loading…</p>
|
||
) : (recentlyAdded.data ?? []).length === 0 ? (
|
||
<p className="text-sm text-muted py-4">No tracks yet.</p>
|
||
) : (
|
||
(recentlyAdded.data ?? []).map((track, i) => (
|
||
<div key={track.id} className="w-36 shrink-0">
|
||
<MediaCard
|
||
seed={`${track.title} ${track.artist}`}
|
||
title={track.title}
|
||
subtitle={track.artist}
|
||
onClick={() => playFrom(recentlyAdded.data!, i)}
|
||
/>
|
||
</div>
|
||
))
|
||
)}
|
||
</ShelfRow>
|
||
|
||
<ShelfRow title="Most Played" viewAllTo="/tracks">
|
||
{mostPlayed.isLoading ? (
|
||
<p className="text-sm text-muted py-4">Loading…</p>
|
||
) : (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`}
|
||
onClick={() => playFrom(mostPlayed.data!, i)}
|
||
/>
|
||
</div>
|
||
))
|
||
)}
|
||
</ShelfRow>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
**Acceptance criteria:** Page shows 4 quick-access gradient cards + 3 horizontal shelves; clicking a media card plays from that position.
|
||
|
||
**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0`
|
||
|
||
---
|
||
|
||
## Task 11 — Restyle library pages (Tracks, Artists, ArtistDetail, Albums, AlbumDetail, Genres)
|
||
|
||
**Goal:** Replace all hard-coded `zinc-*` classes with semantic tokens; replace `LibraryTrackRow` with `TrackRow`; use `MediaCard` / `Artwork` in grid views.
|
||
|
||
**Files:** `Tracks.tsx`, `Artists.tsx`, `ArtistDetail.tsx`, `Albums.tsx`, `AlbumDetail.tsx`, `Genres.tsx` (all modify)
|
||
|
||
**Steps:**
|
||
|
||
1. **`Tracks.tsx`** — swap `LibraryTrackRow` for `TrackRow`; restyle pagination buttons:
|
||
```tsx
|
||
import { useState } from 'react';
|
||
import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
||
import { Music, ChevronLeft, ChevronRight } from 'lucide-react';
|
||
import { trackService } from '../services/trackService';
|
||
import { TrackRow } from '../components/TrackRow';
|
||
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 (
|
||
<div className="space-y-6 max-w-3xl">
|
||
<h1 className="flex items-center gap-3 text-3xl font-bold text-primary">
|
||
<Music size={28} className="text-accent" /> Songs
|
||
</h1>
|
||
{isLoading ? <p className="text-sm text-muted">Loading…</p>
|
||
: isError ? <p className="text-sm text-muted">Couldn't load tracks.</p>
|
||
: tracks.length === 0 ? <p className="text-sm text-muted">{page === 0 ? 'No tracks yet.' : 'No more tracks.'}</p>
|
||
: <div className="space-y-1">{tracks.map((t, i) => <TrackRow key={t.id} track={t} queue={tracks} index={i} />)}</div>}
|
||
<div className="flex items-center justify-between pt-2">
|
||
<button onClick={() => setPage((p) => Math.max(0, p - 1))} disabled={page === 0 || isPlaceholderData}
|
||
className="inline-flex items-center gap-1 rounded-lg border border-line px-3 py-1.5 text-sm text-primary hover:bg-surface-h disabled:opacity-40">
|
||
<ChevronLeft size={16} /> Prev
|
||
</button>
|
||
<span className="text-sm text-muted">Page {page + 1}</span>
|
||
<button onClick={() => setPage((p) => p + 1)} disabled={!hasNext || isPlaceholderData}
|
||
className="inline-flex items-center gap-1 rounded-lg border border-line px-3 py-1.5 text-sm text-primary hover:bg-surface-h disabled:opacity-40">
|
||
Next <ChevronRight size={16} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
2. **`Artists.tsx`** — replace `zinc-*` with tokens; use `Artwork` for avatar:
|
||
```tsx
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { Link } from '@tanstack/react-router';
|
||
import { Users } from 'lucide-react';
|
||
import { artistService } from '../services/artistService';
|
||
import { Artwork } from '../components/Artwork';
|
||
import type { Artist } from '../types';
|
||
|
||
export default function Artists() {
|
||
const { data, isLoading, isError } = useQuery<Artist[]>({
|
||
queryKey: ['artists'],
|
||
queryFn: () => artistService.listArtists(),
|
||
});
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<h1 className="flex items-center gap-3 text-3xl font-bold text-primary">
|
||
<Users size={28} className="text-accent" /> Artists
|
||
</h1>
|
||
{isLoading ? <p className="text-sm text-muted">Loading…</p>
|
||
: isError ? <p className="text-sm text-muted">Couldn't load artists.</p>
|
||
: !data?.length ? <p className="text-sm text-muted">No artists yet.</p>
|
||
: (
|
||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||
{data.map((artist) => (
|
||
<Link key={artist.id} to="/artists/$artistId" params={{ artistId: artist.id }}
|
||
className="group flex flex-col items-center gap-3 rounded-xl border border-line bg-surface p-4 hover:bg-surface-h transition-colors">
|
||
<div className="w-24 h-24 rounded-full overflow-hidden">
|
||
<Artwork seed={artist.name} src={artist.image_path} className="w-full h-full" rounded="full" />
|
||
</div>
|
||
<div className="text-sm font-medium text-primary truncate w-full text-center">{artist.name}</div>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
3. **`ArtistDetail.tsx`** — tokens; use `Artwork` for artist and album cards:
|
||
```tsx
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { Link } from '@tanstack/react-router';
|
||
import { ArrowLeft } from 'lucide-react';
|
||
import { artistDetailRoute } from '../router';
|
||
import { artistService } from '../services/artistService';
|
||
import { Artwork } from '../components/Artwork';
|
||
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 <p className="text-sm text-muted">Loading…</p>;
|
||
if (isError || !data) return <p className="text-sm text-muted">Couldn't load artist.</p>;
|
||
const albums = data.albums ?? [];
|
||
return (
|
||
<div className="space-y-8 max-w-4xl">
|
||
<Link to="/artists" className="inline-flex items-center gap-1 text-sm text-muted hover:text-primary">
|
||
<ArrowLeft size={16} /> Artists
|
||
</Link>
|
||
<div className="flex items-center gap-5">
|
||
<div className="w-28 h-28 flex-none rounded-full overflow-hidden">
|
||
<Artwork seed={data.name} src={data.image_path} className="w-full h-full" rounded="full" />
|
||
</div>
|
||
<div>
|
||
<h1 className="text-4xl font-bold text-primary">{data.name}</h1>
|
||
<p className="text-sm text-muted mt-1">{albums.length} {albums.length === 1 ? 'album' : 'albums'}</p>
|
||
</div>
|
||
</div>
|
||
<section className="space-y-4">
|
||
<h2 className="text-xl font-semibold text-primary">Albums</h2>
|
||
{albums.length === 0 ? <p className="text-sm text-muted">No albums.</p> : (
|
||
<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="group flex flex-col gap-2 rounded-xl border border-line bg-surface p-3 hover:bg-surface-h transition-colors">
|
||
<div className="aspect-square rounded-lg overflow-hidden">
|
||
<Artwork seed={`${album.title} ${data.name}`} className="w-full h-full" rounded="lg" />
|
||
</div>
|
||
<div>
|
||
<div className="truncate text-sm font-medium text-primary">{album.title}</div>
|
||
{album.year && <div className="text-xs text-muted">{album.year}</div>}
|
||
</div>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
4. **`Albums.tsx`** — tokens + `Artwork`:
|
||
```tsx
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { Link } from '@tanstack/react-router';
|
||
import { Disc3 } from 'lucide-react';
|
||
import { albumService } from '../services/albumService';
|
||
import { Artwork } from '../components/Artwork';
|
||
import type { Album } from '../types';
|
||
|
||
export default function Albums() {
|
||
const { data, isLoading, isError } = useQuery<Album[]>({
|
||
queryKey: ['albums'],
|
||
queryFn: () => albumService.listAlbums(),
|
||
});
|
||
return (
|
||
<div className="space-y-6">
|
||
<h1 className="flex items-center gap-3 text-3xl font-bold text-primary">
|
||
<Disc3 size={28} className="text-accent" /> Albums
|
||
</h1>
|
||
{isLoading ? <p className="text-sm text-muted">Loading…</p>
|
||
: isError ? <p className="text-sm text-muted">Couldn't load albums.</p>
|
||
: !data?.length ? <p className="text-sm text-muted">No albums yet.</p>
|
||
: (
|
||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||
{data.map((album) => (
|
||
<Link key={album.id} to="/albums/$albumId" params={{ albumId: album.id }}
|
||
className="group flex flex-col gap-2 rounded-xl border border-line bg-surface p-3 hover:bg-surface-h transition-colors">
|
||
<div className="aspect-square rounded-lg overflow-hidden">
|
||
<Artwork seed={album.title} className="w-full h-full" rounded="lg" />
|
||
</div>
|
||
<div>
|
||
<div className="truncate text-sm font-medium text-primary">{album.title}</div>
|
||
{album.year && <div className="text-xs text-muted">{album.year}</div>}
|
||
</div>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
5. **`AlbumDetail.tsx`** — tokens + `Artwork` + `TrackRow`:
|
||
```tsx
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { Link } from '@tanstack/react-router';
|
||
import { Play, ArrowLeft } 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 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 <p className="text-sm text-muted">Loading…</p>;
|
||
if (isError || !data) return <p className="text-sm text-muted">Couldn't load album.</p>;
|
||
const tracks = data.tracks ?? [];
|
||
return (
|
||
<div className="space-y-8 max-w-3xl">
|
||
<Link to="/albums" className="inline-flex items-center gap-1 text-sm text-muted hover:text-primary">
|
||
<ArrowLeft size={16} /> Albums
|
||
</Link>
|
||
<div className="flex items-end gap-5">
|
||
<div className="w-40 h-40 flex-none rounded-xl overflow-hidden">
|
||
<Artwork seed={data.title} className="w-full h-full" rounded="xl" />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<h1 className="text-4xl font-bold text-primary">{data.title}</h1>
|
||
<p className="text-sm text-muted">{data.year ? `${data.year} · ` : ''}{tracks.length} {tracks.length === 1 ? 'track' : 'tracks'}</p>
|
||
<button onClick={() => { if (tracks.length) { setQueue(tracks); playTrack(tracks[0]); } }}
|
||
disabled={!tracks.length}
|
||
className="inline-flex items-center gap-2 rounded-full bg-accent hover:bg-accent-h px-5 py-2 text-sm font-semibold text-on-accent disabled:opacity-50 transition-colors">
|
||
<Play size={16} fill="currentColor" /> Play album
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<section className="space-y-1">
|
||
{tracks.length === 0 ? <p className="text-sm text-muted">No tracks.</p>
|
||
: tracks.map((t, i) => <TrackRow key={t.id} track={t} queue={tracks} index={i} trackNumber={i + 1} />)}
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
6. **`Genres.tsx`** — tokens; genre cards use gradient derived from genre name:
|
||
```tsx
|
||
import { useState } from 'react';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { Tag, Play, ArrowLeft } from 'lucide-react';
|
||
import { genreService } from '../services/genreService';
|
||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||
import { TrackRow } from '../components/TrackRow';
|
||
import type { Genre, Track } from '../types';
|
||
|
||
function hueFrom(s: string) {
|
||
let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
|
||
return Math.abs(h) % 360;
|
||
}
|
||
|
||
export default function Genres() {
|
||
const [selected, setSelected] = useState<Genre | null>(null);
|
||
const { setQueue, playTrack } = usePlaybackStore();
|
||
|
||
const genresQ = useQuery<Genre[]>({ queryKey: ['genres'], queryFn: () => genreService.listGenres() });
|
||
const tracksQ = useQuery<Track[]>({
|
||
queryKey: ['genre-tracks', selected?.id],
|
||
queryFn: () => genreService.getGenreTracks(selected!.id),
|
||
enabled: !!selected,
|
||
});
|
||
|
||
if (selected) {
|
||
const tracks = tracksQ.data ?? [];
|
||
return (
|
||
<div className="space-y-6 max-w-3xl">
|
||
<button onClick={() => setSelected(null)} className="inline-flex items-center gap-1 text-sm text-muted hover:text-primary">
|
||
<ArrowLeft size={16} /> Genres
|
||
</button>
|
||
<div className="flex items-center justify-between">
|
||
<h1 className="flex items-center gap-3 text-3xl font-bold text-primary"><Tag size={28} className="text-accent" />{selected.name}</h1>
|
||
<button onClick={() => { if (tracks.length) { setQueue(tracks); playTrack(tracks[0]); } }} disabled={!tracks.length}
|
||
className="inline-flex items-center gap-2 rounded-full bg-accent hover:bg-accent-h px-4 py-2 text-sm font-semibold text-on-accent disabled:opacity-50">
|
||
<Play size={16} fill="currentColor" /> Play all
|
||
</button>
|
||
</div>
|
||
{tracksQ.isLoading ? <p className="text-sm text-muted">Loading…</p>
|
||
: tracks.length === 0 ? <p className="text-sm text-muted">No tracks.</p>
|
||
: <div className="space-y-1">{tracks.map((t, i) => <TrackRow key={t.id} track={t} queue={tracks} index={i} />)}</div>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<h1 className="flex items-center gap-3 text-3xl font-bold text-primary"><Tag size={28} className="text-accent" />Genres</h1>
|
||
{genresQ.isLoading ? <p className="text-sm text-muted">Loading…</p>
|
||
: genresQ.isError ? <p className="text-sm text-muted">Couldn't load genres.</p>
|
||
: !genresQ.data?.length ? <p className="text-sm text-muted">No genres yet.</p>
|
||
: (
|
||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
|
||
{genresQ.data.map((genre) => {
|
||
const hue = hueFrom(genre.name);
|
||
return (
|
||
<button key={genre.id} onClick={() => setSelected(genre)}
|
||
className="group flex flex-col items-start gap-2 rounded-xl border border-line 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-primary">{genre.name}</div>
|
||
<div className="text-xs text-muted">{genre.track_count ?? 0} tracks</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
**Acceptance criteria:** All 6 pages compile; no `zinc-*` or `gray-*` hard-coded color references remain; `LibraryTrackRow` no longer imported.
|
||
|
||
**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0`
|
||
|
||
---
|
||
|
||
## Task 12 — Restyle Discover, Vibe, Search
|
||
|
||
**Goal:** Apply semantic token classes; use `TrackRow` in Search; keep all logic intact.
|
||
|
||
**Files:** `Discover.tsx`, `Vibe.tsx`, `Search.tsx` (all modify)
|
||
|
||
**Steps:**
|
||
|
||
1. **`Discover.tsx`** — swap `zinc-*` for tokens; use `TrackRow` for the track list:
|
||
- Replace `border-zinc-800 bg-zinc-900/50 hover:border-zinc-700 hover:bg-zinc-800/70` → `border-line bg-surface hover:bg-surface-h`
|
||
- Replace `text-zinc-400` → `text-muted`, `text-zinc-200` → `text-primary`, `text-white` → `text-primary`
|
||
- The genre card active state `border-blue-500/70 bg-blue-500/10` → `border-accent/60 bg-accent/10`
|
||
- Replace the inline `<button>` track list rows with `<TrackRow>` (pass `showActions={false}`)
|
||
- The "Start a vibe" button: `border-blue-500/60 bg-blue-500/10 text-blue-300 hover:bg-blue-500/20` → `border-accent/60 bg-accent/10 text-accent hover:bg-accent/20`
|
||
- Keep all logic, hooks, imports unchanged except adding `TrackRow` import and removing the inline button track row
|
||
|
||
2. **`Vibe.tsx`** — same token swap; keep all logic:
|
||
- All `bg-zinc-900/50 border-zinc-800` → `bg-surface border-line`
|
||
- `hover:bg-zinc-800/70` → `hover:bg-surface-h`
|
||
- `text-zinc-400/500` → `text-muted`
|
||
- `text-zinc-200/300` → `text-primary`
|
||
- The seed-picker list buttons swap to `<TrackRow showActions={false}>` for the seed picker list
|
||
- The session buttons ("Keep", "Dislike & skip", "End Vibe"): token border/text classes
|
||
- The `bg-blue-500/10 border-blue-500/60 text-blue-300/400` accents → `bg-accent/10 border-accent/60 text-accent`
|
||
- `VibeTimeline` is used as-is (it will be restyled in its own file in a follow-up, but for now leave it)
|
||
|
||
3. **`Search.tsx`** — swap `LibraryTrackRow` for `TrackRow`; token classes on the input:
|
||
```tsx
|
||
import { useEffect, useState } from 'react';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { Search as SearchIcon } from 'lucide-react';
|
||
import { searchService } from '../services/searchService';
|
||
import { TrackRow } from '../components/TrackRow';
|
||
import type { SearchResponse, Track } from '../types';
|
||
|
||
export default function Search() {
|
||
const [input, setInput] = useState('');
|
||
const [query, setQuery] = useState('');
|
||
useEffect(() => { const id = setTimeout(() => setQuery(input.trim()), 300); return () => clearTimeout(id); }, [input]);
|
||
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));
|
||
return (
|
||
<div className="space-y-6 max-w-3xl">
|
||
<h1 className="text-3xl font-bold text-primary">Search</h1>
|
||
<div className="relative max-w-xl">
|
||
<SearchIcon size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" />
|
||
<input type="search" value={input} onChange={(e) => setInput(e.target.value)}
|
||
placeholder="Search tracks, artists…" autoFocus
|
||
className="w-full rounded-lg border border-line bg-surface/70 py-2.5 pl-10 pr-4 text-sm text-primary placeholder:text-muted outline-none focus:border-accent transition-colors" />
|
||
</div>
|
||
{query.length === 0 ? <p className="text-sm text-muted">Type to search.</p>
|
||
: isLoading || isFetching ? <p className="text-sm text-muted">Searching…</p>
|
||
: isError ? <p className="text-sm text-muted">Search failed.</p>
|
||
: tracks.length === 0 ? <p className="text-sm text-muted">No results for "{query}".</p>
|
||
: <div className="space-y-1">{tracks.map((t, i) => <TrackRow key={t.id} track={t} queue={tracks} index={i} />)}</div>}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
**Acceptance criteria:** No `LibraryTrackRow` import in any of the three files; no hard-coded `zinc-*`/`gray-*` colors.
|
||
|
||
**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0`
|
||
|
||
---
|
||
|
||
## Task 13 — Real Quarantine page + quarantineService
|
||
|
||
**Goal:** Add `DislikeEntry` to `types.ts`; create `quarantineService.ts`; rewrite `Quarantine.tsx` with a real list, countdowns, Restore, and hard-Delete actions.
|
||
|
||
**Files:** `frontend/src/types.ts` (modify), `frontend/src/services/quarantineService.ts` (create), `frontend/src/pages/Quarantine.tsx` (rewrite)
|
||
|
||
**Steps:**
|
||
|
||
1. Add to `frontend/src/types.ts`:
|
||
```ts
|
||
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;
|
||
}
|
||
```
|
||
|
||
2. Create `frontend/src/services/quarantineService.ts`:
|
||
```ts
|
||
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}`);
|
||
},
|
||
};
|
||
```
|
||
|
||
3. Rewrite `frontend/src/pages/Quarantine.tsx`:
|
||
```tsx
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||
import { ShieldAlert, RotateCcw, Trash2, Clock } from 'lucide-react';
|
||
import { quarantineService } from '../services/quarantineService';
|
||
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 <span className="text-xs px-2 py-0.5 rounded-full bg-amber-500/20 text-amber-300 font-medium">Warning sent</span>;
|
||
return <span className="text-xs px-2 py-0.5 rounded-full bg-surface-h text-muted font-medium">Grace period</span>;
|
||
}
|
||
|
||
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: () => qc.invalidateQueries({ queryKey: ['dislikes'] }),
|
||
});
|
||
|
||
const hardDelete = useMutation({
|
||
mutationFn: (trackId: string) => quarantineService.hardDelete(trackId),
|
||
onSuccess: () => qc.invalidateQueries({ queryKey: ['dislikes'] }),
|
||
});
|
||
|
||
const entries = data ?? [];
|
||
|
||
return (
|
||
<div className="space-y-6 max-w-3xl">
|
||
<div>
|
||
<h1 className="flex items-center gap-3 text-3xl font-bold text-primary">
|
||
<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 ? <p className="text-sm text-muted">Loading…</p>
|
||
: isError ? <p className="text-sm text-muted">Couldn't load quarantine list.</p>
|
||
: entries.length === 0 ? (
|
||
<div className="flex flex-col items-center gap-3 rounded-xl border border-line border-dashed py-16 text-center">
|
||
<ShieldAlert size={32} className="text-muted/40" />
|
||
<p className="text-muted">No tracks in quarantine.</p>
|
||
<p className="text-sm text-muted/60">Disliked tracks will appear here during the grace period.</p>
|
||
</div>
|
||
) : (
|
||
<ul className="space-y-2">
|
||
{entries.map((entry) => (
|
||
<li key={entry.track_id} className="flex items-center gap-3 rounded-lg border border-line bg-surface 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-primary 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-line px-3 py-1.5 text-sm text-primary hover:bg-surface-h 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>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
**Acceptance criteria:** Page lists disliked tracks from the real backend; Restore clears the row and returns track to library; Delete prompts confirmation; countdown shows time remaining.
|
||
|
||
**Verify:** `cd frontend && npm run typecheck 2>&1 | grep -c error` → `0`
|
||
|
||
---
|
||
|
||
## Task 14 — Settings restyle + final typecheck
|
||
|
||
**Goal:** Restyle Settings with semantic tokens; remove hard-coded `gray-800`/`gray-700` classes; keep theme + volume logic intact. Then run a final typecheck.
|
||
|
||
**Files:** `frontend/src/pages/Settings.tsx` (modify)
|
||
|
||
**Steps:**
|
||
|
||
1. Rewrite `frontend/src/pages/Settings.tsx` (logic unchanged, colors replaced):
|
||
```tsx
|
||
import { useEffect, useState } from 'react';
|
||
import { Palette, Volume2, Info, Check } from 'lucide-react';
|
||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||
import api from '../services/api';
|
||
import { THEMES, DEFAULT_THEME_ID, STORAGE_KEYS, applyTheme, readStoredThemeId, readStoredVolume, type ThemePreset } from '../lib/theme';
|
||
|
||
export default function Settings() {
|
||
const volume = usePlaybackStore((s) => s.volume);
|
||
const setVolume = usePlaybackStore((s) => s.setVolume);
|
||
const [themeId, setThemeId] = useState<string>(DEFAULT_THEME_ID);
|
||
|
||
useEffect(() => {
|
||
const id = readStoredThemeId();
|
||
setThemeId(id);
|
||
const t = THEMES.find((x) => x.id === id);
|
||
if (t) applyTheme(t);
|
||
setVolume(readStoredVolume(volume));
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
const selectTheme = (theme: ThemePreset) => {
|
||
setThemeId(theme.id);
|
||
applyTheme(theme);
|
||
try { localStorage.setItem(STORAGE_KEYS.theme, theme.id); } catch { /**/ }
|
||
};
|
||
|
||
const handleVolume = (v: number) => {
|
||
setVolume(v);
|
||
try { localStorage.setItem(STORAGE_KEYS.volume, String(v)); } catch { /**/ }
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-8 max-w-2xl">
|
||
<div>
|
||
<h1 className="text-3xl font-bold text-primary">Settings</h1>
|
||
<p className="text-muted mt-1">Preferences are stored locally in this browser.</p>
|
||
</div>
|
||
|
||
<section className="rounded-xl border border-line bg-surface p-5 space-y-4">
|
||
<h2 className="text-lg font-semibold text-primary flex items-center gap-2"><Palette size={20} className="text-accent" />Theme</h2>
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||
{THEMES.map((theme) => {
|
||
const active = theme.id === themeId;
|
||
return (
|
||
<button key={theme.id} onClick={() => selectTheme(theme)}
|
||
className={`relative flex flex-col items-start gap-2 rounded-lg border p-3 text-left transition-colors ${active ? 'border-accent ring-2 ring-accent/30' : 'border-line hover:border-accent/40'}`}>
|
||
<span className="h-10 w-full rounded-md border border-black/20" style={{ backgroundColor: theme.swatch }} />
|
||
<span className="text-sm font-medium text-primary">{theme.name}</span>
|
||
{active && <Check size={14} className="absolute right-2 top-2 text-accent" />}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="rounded-xl border border-line bg-surface p-5 space-y-4">
|
||
<h2 className="text-lg font-semibold text-primary 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 accent-[var(--accent)]" aria-label="Volume" />
|
||
<span className="w-12 text-right text-sm tabular-nums text-primary">{Math.round(volume * 100)}%</span>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="rounded-xl border border-line bg-surface p-5 space-y-3">
|
||
<h2 className="text-lg font-semibold text-primary 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-primary font-medium">muzick</dd></div>
|
||
<div className="flex justify-between"><dt className="text-muted">Version</dt><dd className="text-primary 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-primary break-all">{api.defaults.baseURL ?? '/api'}</dd></div>
|
||
</dl>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
2. Also update `VibeTimeline.tsx` to use tokens (it's used by Vibe):
|
||
```tsx
|
||
// Replace zinc-* with token classes throughout VibeTimeline.tsx:
|
||
// bg-zinc-900/50 border-zinc-800 → bg-surface border-line
|
||
// hover:border-zinc-700 hover:bg-zinc-800/70 → hover:bg-surface-h
|
||
// text-zinc-200 → text-primary
|
||
// text-zinc-400/500 → text-muted
|
||
// text-zinc-600 → text-muted/60
|
||
// bg-blue-500/20 text-blue-300 → bg-accent/20 text-accent
|
||
// bg-blue-500 text-white → bg-accent text-on-accent
|
||
// bg-zinc-800 text-zinc-500 → bg-elevated text-muted
|
||
// The 'Now playing' span: bg-blue-500 → bg-accent
|
||
```
|
||
|
||
3. Delete `frontend/src/components/Layout.tsx`, `frontend/src/components/NowPlayingBar.tsx`, `frontend/src/pages/LibraryTrackRow.tsx`.
|
||
|
||
4. Run final typecheck:
|
||
```bash
|
||
cd frontend && npm run typecheck
|
||
```
|
||
|
||
**Acceptance criteria:** Zero TypeScript errors. No remaining imports of `Layout`, `NowPlayingBar`, or `LibraryTrackRow`.
|
||
|
||
**Verify:**
|
||
```bash
|
||
cd /mnt/server/home/kami/apps/muzick/frontend && npm run typecheck 2>&1 | tail -5
|
||
# Expected: no output or "Found 0 errors."
|
||
grep -r "LibraryTrackRow\|NowPlayingBar\|from.*Layout" src/ | grep -v "\.md"
|
||
# Expected: no output
|
||
```
|
||
|
||
---
|
||
|
||
## Execution order
|
||
|
||
Tasks are ordered by dependency:
|
||
|
||
```
|
||
1 (tokens) → 2 (Artwork) → 3 (TrackRow) → 4 (PlaybackBar) → 5 (NowPlayingPanel)
|
||
→ 6 (NavRail) → 7 (TopBar) → 8 (AppShell+router)
|
||
→ 9 (MediaCard+ShelfRow) → 10 (Home)
|
||
→ 11 (library pages) → 12 (Discover/Vibe/Search)
|
||
→ 13 (Quarantine) → 14 (Settings + typecheck)
|
||
```
|
||
|
||
Tasks 2–7 have no inter-dependencies and can be written in parallel; they all depend only on task 1.
|
||
Tasks 11–13 depend on tasks 1–3.
|
||
|
||
---
|
||
|
||
## Execute now with `/implement`?
|