Homelabtoolkit v1
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { apiGet } from "../../api";
|
||||
import { PageHead, Empty, Loading, fmtDuration } from "../../components/ui";
|
||||
import { IconDisc, IconMusic, IconSearch, IconPlay } from "../../components/icons";
|
||||
import { useToast } from "../../lib/toast";
|
||||
|
||||
interface Album {
|
||||
id: string;
|
||||
name: string;
|
||||
artist: string;
|
||||
year?: number;
|
||||
song_count: number;
|
||||
duration: number;
|
||||
cover_url: string | null;
|
||||
}
|
||||
interface Song {
|
||||
id: string;
|
||||
title: string;
|
||||
track?: number;
|
||||
duration: number;
|
||||
artist: string;
|
||||
}
|
||||
interface AlbumDetail extends Album {
|
||||
songs: Song[];
|
||||
}
|
||||
|
||||
const SORTS = [
|
||||
{ key: "alphabeticalByName", label: "A–Z" },
|
||||
{ key: "newest", label: "Newest" },
|
||||
{ key: "recent", label: "Recently Played" },
|
||||
{ key: "frequent", label: "Most Played" },
|
||||
{ key: "random", label: "Random" },
|
||||
];
|
||||
|
||||
export default function Library() {
|
||||
const toast = useToast();
|
||||
const [status, setStatus] = useState<{ connected: boolean; configured: boolean; error?: string } | null>(null);
|
||||
const [albums, setAlbums] = useState<Album[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sort, setSort] = useState("alphabeticalByName");
|
||||
const [query, setQuery] = useState("");
|
||||
const [selected, setSelected] = useState<AlbumDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
apiGet("/api/navidrome/status").then(setStatus).catch(() => setStatus({ connected: false, configured: false }));
|
||||
}, []);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
const q = query.trim();
|
||||
const path = q
|
||||
? `/api/navidrome/albums?q=${encodeURIComponent(q)}&size=120`
|
||||
: `/api/navidrome/albums?type=${sort}&size=120`;
|
||||
apiGet<{ items: Album[] }>(path)
|
||||
.then((d) => setAlbums(d.items))
|
||||
.catch((e) => toast(e.message, "err"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [sort, query, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status?.connected) load();
|
||||
}, [status?.connected, sort]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
function openAlbum(id: string) {
|
||||
setDetailLoading(true);
|
||||
setSelected(null);
|
||||
apiGet<AlbumDetail>(`/api/navidrome/album/${id}`)
|
||||
.then(setSelected)
|
||||
.catch((e) => toast(e.message, "err"))
|
||||
.finally(() => setDetailLoading(false));
|
||||
}
|
||||
|
||||
if (status && !status.configured) {
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Music Library">Browse your Navidrome library.</PageHead>
|
||||
<div className="panel">
|
||||
<Empty icon={<IconMusic />}>
|
||||
Navidrome is not configured. Set <code>NAVIDROME_URL</code>, <code>NAVIDROME_USER</code> and{" "}
|
||||
<code>NAVIDROME_PASSWORD</code> and restart the app.
|
||||
</Empty>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (status && status.configured && !status.connected) {
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Music Library">Browse your Navidrome library.</PageHead>
|
||||
<div className="panel">
|
||||
<Empty icon={<IconMusic />}>Could not connect to Navidrome. {status.error}</Empty>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Music Library">Browse artists and albums served by your Navidrome instance.</PageHead>
|
||||
|
||||
<div className="row between wrap" style={{ marginBottom: 18, gap: 12 }}>
|
||||
<div className="seg">
|
||||
{SORTS.map((s) => (
|
||||
<button
|
||||
key={s.key}
|
||||
className={`seg-btn ${sort === s.key && !query ? "active" : ""}`}
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
setSort(s.key);
|
||||
}}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<form
|
||||
className="search-inner"
|
||||
style={{ width: 280 }}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
load();
|
||||
}}
|
||||
>
|
||||
<IconSearch />
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Search albums…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Loading label="Loading albums…" />
|
||||
) : albums.length === 0 ? (
|
||||
<div className="panel">
|
||||
<Empty icon={<IconDisc />}>No albums found.</Empty>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card-grid">
|
||||
{albums.map((a) => (
|
||||
<div key={a.id} className="media-card" onClick={() => openAlbum(a.id)}>
|
||||
{a.cover_url ? (
|
||||
<img className="media-cover" src={`${a.cover_url}?size=300`} loading="lazy" alt={a.name} />
|
||||
) : (
|
||||
<div className="media-cover" style={{ display: "grid", placeItems: "center" }}>
|
||||
<IconDisc className="dim" />
|
||||
</div>
|
||||
)}
|
||||
<div className="media-body">
|
||||
<div className="media-title">{a.name}</div>
|
||||
<div className="media-sub">
|
||||
{a.artist}
|
||||
{a.year ? ` · ${a.year}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(selected || detailLoading) && (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(4,7,11,0.7)",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
zIndex: 50,
|
||||
padding: 24,
|
||||
}}
|
||||
onClick={() => setSelected(null)}
|
||||
>
|
||||
<div className="panel" style={{ width: 640, maxWidth: "100%", maxHeight: "86vh", overflow: "auto" }} onClick={(e) => e.stopPropagation()}>
|
||||
{detailLoading || !selected ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<>
|
||||
<div className="panel-head">
|
||||
{selected.cover_url && (
|
||||
<img
|
||||
src={`${selected.cover_url}?size=120`}
|
||||
style={{ width: 64, height: 64, borderRadius: 10, objectFit: "cover" }}
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
<div className="grow">
|
||||
<h3>{selected.name}</h3>
|
||||
<div className="sub">
|
||||
{selected.artist}
|
||||
{selected.year ? ` · ${selected.year}` : ""} · {selected.song_count} tracks ·{" "}
|
||||
{fmtDuration(selected.duration)}
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-sm" onClick={() => setSelected(null)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<table className="data-table">
|
||||
<tbody>
|
||||
{selected.songs.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td style={{ width: 36 }} className="cell-sub mono">
|
||||
{s.track ?? <IconPlay />}
|
||||
</td>
|
||||
<td className="cell-strong">{s.title}</td>
|
||||
<td style={{ textAlign: "right", width: 60 }} className="cell-sub mono">
|
||||
{fmtDuration(s.duration)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user