Initial commit, prod ready

This commit is contained in:
2026-02-22 23:18:57 -05:00
commit 0d03be47a0
41 changed files with 5623 additions and 0 deletions

View 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>
);
}