200 lines
6.8 KiB
TypeScript
200 lines
6.8 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import {
|
|
RefreshCw, Trash2, Download, ChevronRight,
|
|
Music, Disc, FolderOpen, Loader2,
|
|
} from 'lucide-react';
|
|
import { getLibrary, deleteLibraryItem, getDownloadUrl } from '../api/client';
|
|
import type { Artist, Album, Track } from '../api/types';
|
|
|
|
function formatSize(bytes: number): string {
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|
|
|
|
export default function Library() {
|
|
const [artists, setArtists] = useState<Artist[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [selectedArtist, setSelectedArtist] = useState<Artist | null>(null);
|
|
const [selectedAlbum, setSelectedAlbum] = useState<Album | null>(null);
|
|
const [deleting, setDeleting] = useState<string | null>(null);
|
|
|
|
async function load() {
|
|
setLoading(true);
|
|
try {
|
|
const data = await getLibrary();
|
|
setArtists(data);
|
|
// Re-sync selections in case they were deleted
|
|
setSelectedArtist(a => data.find(x => x.artist === a?.artist) ?? null);
|
|
setSelectedAlbum(al => {
|
|
const parent = data.find(x => x.artist === selectedArtist?.artist);
|
|
return parent?.albums.find(x => x.album === al?.album) ?? null;
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => { load(); }, []);
|
|
|
|
async function handleDelete(path: string, label: string) {
|
|
if (!confirm(`Delete "${label}" from your library?`)) return;
|
|
setDeleting(path);
|
|
try {
|
|
await deleteLibraryItem(path);
|
|
await load();
|
|
} catch {
|
|
alert('Delete failed.');
|
|
} finally {
|
|
setDeleting(null);
|
|
}
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="panel-loading">
|
|
<Loader2 size={24} className="spin" />
|
|
<span>Loading library…</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (artists.length === 0) {
|
|
return (
|
|
<div className="library-empty">
|
|
<Music size={40} className="muted-icon" />
|
|
<p>Your library is empty.</p>
|
|
<p className="muted-text">Search for songs and download them — they'll appear here.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="library-layout">
|
|
{/* Artist column */}
|
|
<div className="lib-col">
|
|
<div className="lib-col-header">
|
|
<span>Artists</span>
|
|
<button className="icon-btn" onClick={load} title="Refresh">
|
|
<RefreshCw size={14} />
|
|
</button>
|
|
</div>
|
|
<ul className="lib-list">
|
|
{artists.map(artist => (
|
|
<li
|
|
key={artist.artist}
|
|
className={`lib-item ${selectedArtist?.artist === artist.artist ? 'active' : ''}`}
|
|
onClick={() => {
|
|
setSelectedArtist(artist);
|
|
setSelectedAlbum(null);
|
|
}}
|
|
>
|
|
<Music size={14} className="lib-item-icon" />
|
|
<span className="lib-item-label">{artist.artist}</span>
|
|
<div className="lib-item-actions" onClick={e => e.stopPropagation()}>
|
|
<button
|
|
className="icon-btn danger"
|
|
title="Delete artist"
|
|
disabled={deleting === artist.artist}
|
|
onClick={() => handleDelete(artist.artist, artist.artist)}
|
|
>
|
|
{deleting === artist.artist
|
|
? <Loader2 size={13} className="spin" />
|
|
: <Trash2 size={13} />}
|
|
</button>
|
|
</div>
|
|
<ChevronRight size={13} className="lib-chevron" />
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
|
|
{/* Album column */}
|
|
<div className="lib-col">
|
|
<div className="lib-col-header">
|
|
<span>Albums</span>
|
|
</div>
|
|
{selectedArtist ? (
|
|
<ul className="lib-list">
|
|
{selectedArtist.albums.map(album => (
|
|
<li
|
|
key={album.album}
|
|
className={`lib-item ${selectedAlbum?.album === album.album ? 'active' : ''}`}
|
|
onClick={() => setSelectedAlbum(album)}
|
|
>
|
|
<Disc size={14} className="lib-item-icon" />
|
|
<span className="lib-item-label">{album.album}</span>
|
|
<div className="lib-item-actions" onClick={e => e.stopPropagation()}>
|
|
<button
|
|
className="icon-btn danger"
|
|
title="Delete album"
|
|
disabled={deleting === `${selectedArtist.artist}/${album.album}`}
|
|
onClick={() => handleDelete(
|
|
`${selectedArtist.artist}/${album.album}`,
|
|
album.album
|
|
)}
|
|
>
|
|
{deleting === `${selectedArtist.artist}/${album.album}`
|
|
? <Loader2 size={13} className="spin" />
|
|
: <Trash2 size={13} />}
|
|
</button>
|
|
</div>
|
|
<ChevronRight size={13} className="lib-chevron" />
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : (
|
|
<div className="lib-empty-col">
|
|
<FolderOpen size={24} className="muted-icon" />
|
|
<span>Select an artist</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Tracks column */}
|
|
<div className="lib-col">
|
|
<div className="lib-col-header">
|
|
<span>Tracks</span>
|
|
</div>
|
|
{selectedAlbum ? (
|
|
<ul className="lib-list">
|
|
{selectedAlbum.tracks.map((track: Track) => (
|
|
<li key={track.path} className="lib-item lib-track">
|
|
<Music size={13} className="lib-item-icon" />
|
|
<div className="lib-track-info">
|
|
<span className="lib-item-label">{track.filename}</span>
|
|
<span className="lib-track-size">{formatSize(track.size)}</span>
|
|
</div>
|
|
<div className="lib-item-actions" onClick={e => e.stopPropagation()}>
|
|
<a
|
|
className="icon-btn"
|
|
href={getDownloadUrl(track.path)}
|
|
title="Download to device"
|
|
download
|
|
>
|
|
<Download size={13} />
|
|
</a>
|
|
<button
|
|
className="icon-btn danger"
|
|
title="Delete track"
|
|
disabled={deleting === track.path}
|
|
onClick={() => handleDelete(track.path, track.filename)}
|
|
>
|
|
{deleting === track.path
|
|
? <Loader2 size={13} className="spin" />
|
|
: <Trash2 size={13} />}
|
|
</button>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : (
|
|
<div className="lib-empty-col">
|
|
<FolderOpen size={24} className="muted-icon" />
|
|
<span>Select an album</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|