import { useCallback, useEffect, useState } from "preact/hooks"; import { createClient } from "@connectrpc/connect"; import { createConnectTransport } from "@connectrpc/connect-web"; import { BookOpen, Gauge, Link as LinkIcon, Play, Server } from "lucide-preact"; import type { Item, Link, Service, Monitor } from "./gen/homelab/v1/homelab_pb"; import { HomelabService } from "./gen/homelab/v1/homelab_pb"; import { MonitorCard } from "./components/MonitorCard"; import { ScriptCard } from "./components/ScriptCard"; import { ServiceCard } from "./components/ServiceCard"; import { DocsView, DocView, localDocs } from "./components/Docs"; import { StoredDoc, syncDocs } from "./docsStore"; const client = createClient( HomelabService, createConnectTransport({ baseUrl: "" }), ); const CATEGORIES = [ { id: "links", label: "Links", icon: LinkIcon }, { id: "monitor", label: "Monitor", icon: Gauge }, { id: "services", label: "Services", icon: Server }, { id: "exec", label: "Exec", icon: Play }, { id: "docs", label: "Docs", icon: BookOpen }, ] as const; type Category = (typeof CATEGORIES)[number]["id"]; function LinkList({ links }: { links: Link[] }) { if (links.length === 0) return
No links in config
; return (
{links.map((l) => (
{l.name} {l.url}
))}
); } function ServiceList({ services, refresh, }: { services: Service[]; refresh: () => void; }) { const [searchQuery, setSearchQuery] = useState(""); const filteredServices = services.filter((s) => s.name.toLowerCase().includes(searchQuery.toLowerCase()), ); return (
{filteredServices.map((s) => ( ))}
); } function Nav({ category, setCategory, setOpenDoc, }: { category: Category; setCategory: (c: Category) => void; setOpenDoc: (d: string | null) => void; }) { return ( ); } function MobileTopBar({ category }: { category: Category }) { const label = CATEGORIES.find((c) => c.id === category)?.label ?? ""; return (
{label}
); } function Footer({ category, setCategory, setOpenDoc, }: { category: Category; setCategory: (c: Category) => void; setOpenDoc: (d: string | null) => void; }) { return (
{CATEGORIES.map((c) => ( ))}
); } function MainScreen({ category, setOpenDoc, content, refresh, open, docs, }: { category: Category; setOpenDoc: (d: string | null) => void; content: Content; refresh: () => void; open: StoredDoc | undefined; docs: StoredDoc[]; }) { return (
{category === "links" && } {category === "monitor" && (
{content.monitors.map((it) => ( ))}
)} {category === "exec" && (
{content.exec.map((it) => ( ))}
)} {category === "services" && ( )} {category === "docs" && (open ? ( setOpenDoc(null)} /> ) : ( setOpenDoc(d.name)} /> ))}
); } type Content = { links: Link[]; monitors: Monitor[]; exec: Item[]; services: Service[]; }; export function App() { const [content, setContent] = useState({ links: [], monitors: [], exec: [], services: [], }); const [category, setCategory] = useState("monitor"); const [docs, setDocs] = useState(localDocs()); const [openDoc, setOpenDoc] = useState(null); const refresh = useCallback( () => client .listContent({}) .then((r) => setContent({ links: r.links, monitors: r.monitors, exec: r.exec, services: r.services, }), ) .catch(() => {}), // offline: keep last state [], ); useEffect(() => { refresh(); const id = setInterval(refresh, 3000); return () => clearInterval(id); }, [refresh]); // Docs sync + offline dirty flush, every 5s. useEffect(() => { const tick = () => syncDocs() .then(() => setDocs(localDocs())) .catch(() => {}); tick(); const id = setInterval(tick, 5000); return () => clearInterval(id); }, []); const open = docs.find((d) => d.name === openDoc); return (
); }