"use client"; import type { AppActionTarget } from "@/modules/apps/components/app-grid"; import { useContainerCustomization } from "@/modules/apps/hooks/useContainerCustomization"; import { useUnmanagedContainers } from "@/modules/apps/hooks/useUnmanagedContainers"; import { Container, Loader2 } from "@/components/icons/platform-icons"; import { useEffect, useState, type ReactNode } from "react"; import { toast } from "sonner"; type FormState = { displayName: string; iconUrl: string; dashboardUrl: string; }; const EMPTY_FORM: FormState = { displayName: "", iconUrl: "", dashboardUrl: "" }; function isHttpUrl(value: string) { if (value.length === 0) return true; if (!/^https?:\/\//i.test(value)) return false; try { new URL(value); return true; } catch { return false; } } /** * Settings for a container Homeio did not deploy (#55). Its compose file is * not Homeio's to edit, so this only changes how the desktop shows it: the * name, the icon and where "Open Dashboard" goes. */ export function ContainerSettingsPanel({ target, onClose, }: { target: AppActionTarget; onClose?: () => void; }) { const containersQuery = useUnmanagedContainers(); const container = containersQuery.data?.find( (entry) => entry.name === target.containerName, ); const saveMutation = useContainerCustomization(target.containerName); const [form, setForm] = useState(EMPTY_FORM); const [iconFailed, setIconFailed] = useState(false); const [loadedFor, setLoadedFor] = useState(null); // Fill the form once the container is known; later refetches must not // overwrite what the operator is typing. useEffect(() => { if (!container || loadedFor === container.name) return; setForm({ displayName: container.displayName ?? "", iconUrl: container.iconUrl ?? "", dashboardUrl: container.dashboardUrl ?? "", }); setLoadedFor(container.name); }, [container, loadedFor]); const iconUrl = form.iconUrl.trim(); const dashboardUrl = form.dashboardUrl.trim(); const iconUrlValid = isHttpUrl(iconUrl); const dashboardUrlValid = isHttpUrl(dashboardUrl); const canSave = iconUrlValid && dashboardUrlValid && !saveMutation.isPending; function update(patch: Partial) { if (patch.iconUrl !== undefined) setIconFailed(false); setForm((previous) => ({ ...previous, ...patch })); } async function save(next: FormState) { try { await saveMutation.mutateAsync({ displayName: next.displayName.trim() || null, iconUrl: next.iconUrl.trim() || null, dashboardUrl: next.dashboardUrl.trim() || null, }); toast.success(`${next.displayName.trim() || target.containerName} saved`); onClose?.(); } catch { // The error is shown in the footer. } } if (containersQuery.isLoading) { return (
Loading container...
); } return (
{iconUrl && iconUrlValid && !iconFailed ? ( // eslint-disable-next-line @next/next/no-img-element setIconFailed(true)} /> ) : ( )}

{form.displayName.trim() || target.containerName}

{target.containerName} {container?.image ? ` ยท ${container.image}` : ""}

Not managed by Homeio: only how it shows on the desktop changes.

update({ displayName: value })} placeholder={target.containerName} maxLength={80} /> update({ iconUrl: value })} placeholder="https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/jellyfin.png" invalid={!iconUrlValid} /> update({ dashboardUrl: value })} placeholder="http://192.168.1.10:8096" invalid={!dashboardUrlValid} />
{saveMutation.error instanceof Error ? saveMutation.error.message : ""}
); } function Field({ label, hint, error, children, }: { label: string; hint?: string; error?: string; children: ReactNode; }) { return (
{children} {error ? (

{error}

) : hint ? (

{hint}

) : null}
); } function TextInput({ ariaLabel, value, onChange, placeholder, maxLength, invalid = false, }: { ariaLabel: string; value: string; onChange: (value: string) => void; placeholder?: string; maxLength?: number; invalid?: boolean; }) { return ( onChange(event.target.value)} placeholder={placeholder} maxLength={maxLength} className={`h-9 w-full rounded-lg border bg-secondary/35 px-2.5 text-xs text-foreground placeholder:text-muted-foreground/70 focus:outline-none ${ invalid ? "border-status-red/70 focus:border-status-red" : "border-glass-border focus:border-primary/50" }`} /> ); }