import { useEffect, useRef, useState } from "react"; import { Plus, Trash2, Upload, X } from "lucide-react"; import { useBoard } from "@/board/store"; import { api } from "@/api"; import type { Item, UrlInput } from "@/types"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; // dashboard-icons CDN serves png/.png — slugify the service name for // the one-click suggestion under the icon field. const DI_BASE = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png"; const diSlug = (name: string) => name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); // One dialog for both add and edit: item present = edit, absent = add. export function ServiceDialog({ open, onOpenChange, item, }: { open: boolean; onOpenChange: (o: boolean) => void; item?: Item | undefined; }) { const board = useBoard(); const editing = Boolean(item); const [name, setName] = useState(""); const [icon, setIcon] = useState(""); const [urls, setUrls] = useState([{ url: "", label: "local" }]); const [sectionId, setSectionId] = useState(""); const [alsoMonitor, setAlsoMonitor] = useState(false); const [busy, setBusy] = useState(false); const [err, setErr] = useState(""); const fileRef = useRef(null); useEffect(() => { if (!open) return; setErr(""); setName(item?.name ?? ""); setIcon(item?.icon ?? ""); setUrls( item && item.urls.length > 0 ? item.urls.map((u) => ({ url: u.url, label: u.label })) : [{ url: "", label: "local" }], ); setSectionId(item?.sectionId ?? board.sections[0]?.id ?? ""); setAlsoMonitor(false); }, [open, item, board.sections]); const setUrl = (i: number, patch: Partial) => setUrls((us) => us.map((u, j) => (j === i ? { ...u, ...patch } : u))); async function submit() { const cleanUrls = urls.filter((u) => u.url.trim() !== ""); if (!name.trim()) return setErr("Name is required"), undefined; if (cleanUrls.some((u) => !/^https?:\/\/.+/.test(u.url))) { return setErr("URLs must start with http:// or https://"), undefined; } setBusy(true); try { const file = fileRef.current?.files?.[0]; if (editing && item) { await board.patchItem(item.id, { name: name.trim(), icon, urls: cleanUrls }); if (file) await board.uploadIcon(item.id, file); } else { let sid = sectionId; if (!sid) { // First service on an empty board — create the home section. const sec = await board.addSection("Services"); if (!sec) throw new Error("could not create section"); sid = sec.id; } const created = await board.addItem({ sectionId: sid, kind: "service", name: name.trim(), icon, urls: cleanUrls, }); if (created && file) await board.uploadIcon(created.id, file); if (alsoMonitor && cleanUrls[0]) { // Uptime check on the primary URL — appears under /monitors. await api.POST("/api/monitors", { body: { name: name.trim(), type: "http", url: cleanUrls[0].url }, }); } } onOpenChange(false); } catch (e) { setErr(e instanceof Error ? e.message : "save failed"); } finally { setBusy(false); } } async function remove() { if (!item) return; setBusy(true); await board.deleteItem(item.id); setBusy(false); onOpenChange(false); } return ( {editing ? "Edit service" : "Add service"} {editing ? "Update name, icon, or launch URLs." : "Name it, give it an icon, add one or more URLs."}
setName(e.target.value)} placeholder="Pi-hole" autoFocus />
{!editing && board.sections.length > 0 && (
)}
{urls.map((u, i) => (
setUrl(i, { url: e.target.value })} placeholder="https://…" className="font-mono text-[12px]" /> setUrl(i, { label: e.target.value })} placeholder="label" className="w-[110px] shrink-0" list="url-labels" />
))}
setIcon(e.target.value)} placeholder="https://…/icon.png" className="font-mono text-[12px]" /> setErr("")} />
{!icon && diSlug(name) ? ( ) : null}

URL or uploaded file; blank shows a letter tile.

{!editing && ( )} {err ?

{err}

: null}
{editing ? ( ) : null}
); } // Small live preview for the icon URL — hides itself on load failure so a // dead URL reads as "no preview", not a broken image. function IconPreview({ url }: { url: string }) { const [failed, setFailed] = useState(false); useEffect(() => setFailed(false), [url]); if (!url || failed) { return ; } return ( setFailed(true)} className="size-[30px] shrink-0 rounded-[7px] border border-border bg-icon-bg object-contain p-1" /> ); }