"use client"; import * as React from "react"; import { motion } from "framer-motion"; import { ArrowLeft, SearchX } from "lucide-react"; import { api, type Me, type Track } from "@/lib/api"; import { tint } from "@/lib/colour"; import { useNav, type Tab } from "@/lib/nav"; import { panel, useMotionOK } from "@/lib/motion"; import { SearchBar } from "@/components/search-bar"; import { TrackRow } from "@/components/track-row"; import { AlbumView } from "@/components/album-view"; import { ArtistView } from "@/components/artist-view"; import { BulkImport } from "@/components/bulk-import"; import { PlaylistImport } from "@/components/playlist-import"; import { Requests, type JobsSnapshot } from "@/components/requests"; import { Review } from "@/components/review"; import { DiscoverPanel } from "@/components/discover"; import { Settings } from "@/components/settings"; import { Welcome } from "@/components/welcome"; import { PlayerProvider } from "@/components/player"; import { ToastProvider } from "@/components/toast"; import { AdminPanel } from "@/components/admin/admin-panel"; import { Sidebar } from "@/components/shell/sidebar"; import { TabBar } from "@/components/shell/tabbar"; import { Toolbar } from "@/components/shell/toolbar"; import { StatusBanner } from "@/components/shell/status-banner"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; import { SkeletonRows } from "@/components/ui/skeleton"; export default function Page() { return ( ); } /** Tailwind's `md`. Decides sidebar-vs-rail, and rail-vs-bottom-bar. */ function useBreakpoint() { const [w, setW] = React.useState(1280); React.useEffect(() => { const on = () => setW(window.innerWidth); on(); window.addEventListener("resize", on); return () => window.removeEventListener("resize", on); }, []); return { rail: w >= 768 && w < 1024 }; } function Muse() { const [nav, go] = useNav(); const { rail } = useBreakpoint(); const ok = useMotionOK(); const [results, setResults] = React.useState(null); const [busy, setBusy] = React.useState(false); const [me, setMe] = React.useState(null); const [snap, setSnap] = React.useState(null); const doSearch = React.useCallback( async (q: string) => { if (!q.trim()) return; go({ tab: "search", q }); setResults(null); setBusy(true); try { const r = await api.search(q); setResults(r.results); if (r.results[0]) void tint(r.results[0].cover); } catch { setResults([]); } setBusy(false); }, [go], ); // Re-runs the query when the URL carries one, so a shared link and a browser // Back both restore an actual result set rather than an empty screen. const lastQ = React.useRef(null); React.useEffect(() => { if (nav.tab !== "search" || nav.view || !nav.q) return; if (lastQ.current === nav.q) return; lastQ.current = nav.q; void doSearch(nav.q); }, [nav.tab, nav.view, nav.q, doSearch]); const openAlbum = React.useCallback( (id: number) => go({ tab: "search", view: "album", id: String(id), q: nav.q }), [go, nav.q], ); const openArtist = React.useCallback( (id: number) => go({ tab: "search", view: "artist", id: String(id), q: nav.q }), [go, nav.q], ); const refresh = React.useCallback(async () => { try { setSnap(await api.jobs()); } catch { /* a 401 here just means the poll outlived the session */ } // Refetched alongside jobs rather than once at mount so the quota chip // counts down as you queue, and so a role change or a disable lands within // one poll instead of at next sign-in. try { setMe(await api.me()); } catch { /* ignore */ } }, []); React.useEffect(() => { void refresh(); const t = setInterval(refresh, 4000); return () => clearInterval(t); }, [refresh]); const active = (snap?.counts.queued ?? 0) + (snap?.counts.searching ?? 0) + (snap?.counts.downloading ?? 0); const held = snap?.counts.review ?? 0; const isAdmin = me?.role === "admin"; // Only worth showing when a limit actually exists — a permanent "0 / 0" chip // is noise on a single-user install, which is most of them. const quotaLeft = me?.quota && me.usage ? Math.max(0, me.quota - me.usage.requests) : null; const badges: Partial> = { jobs: active, review: held }; // Review appears exactly when something is waiting on you; an always-present // tab that is empty nine days in ten is chrome. const visible = (t: Tab) => t === "admin" ? !!isAdmin : t === "review" ? held > 0 || nav.tab === "review" : true; const onBack = nav.view ? () => go({ tab: "search", q: nav.q }) : null; return (
{nav.tab === "search" && ( <> {onBack && ( )} {nav.view === "album" && nav.id && ( )} {nav.view === "artist" && nav.id && ( )} {!nav.view && !nav.q && } {!nav.view && nav.q && (results === null ? ( ) : results.length === 0 ? ( ) : (
{results.map((t, i) => ( ))}
))} )} {nav.tab === "discover" && } {nav.tab === "jobs" && } {nav.tab === "review" && } {nav.tab === "bulk" && (
)} {nav.tab === "settings" && me && } {nav.tab === "admin" && isAdmin && }
); }