Initial commit, prod ready
This commit is contained in:
199
frontend/src/components/Library.tsx
Normal file
199
frontend/src/components/Library.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user