Homelabtoolkit v1

This commit is contained in:
2026-06-08 00:01:55 +12:00
parent c8838a485d
commit 040fbacc70
56 changed files with 12477 additions and 151 deletions
+224
View File
@@ -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: "AZ" },
{ 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>
)}
</>
);
}