170 lines
6.0 KiB
TypeScript
170 lines
6.0 KiB
TypeScript
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>
|
|
);
|
|
}
|