"use client" import { useEffect, useState } from "react" import { Trash2 } from "lucide-react" import { fetchApi } from "../lib/api-config" import { useT } from "../lib/i18n/provider" import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog" import { Button } from "./ui/button" import { Input } from "./ui/input" import { Label } from "./ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" // Minimal shape we need from the /api/vms poll. Kept narrow so this // component stays independent of the fuller VMData type used in // virtual-machines.tsx. export interface GuestOption { vmid: number name: string type: "lxc" | "qemu" } export interface CustomLink { id: string name: string url: string logo_url: string category: string binding: { vmid: number; guest_type: "lxc" | "qemu" } | null created_at?: number updated_at?: number } export interface DraftCustomLink { name: string url: string logo_url: string category: string bindingKey: string } const UNBOUND_KEY = "__none__" function buildKey(binding: CustomLink["binding"]): string { if (!binding) return UNBOUND_KEY return `${binding.guest_type}:${binding.vmid}` } function parseKey(key: string): CustomLink["binding"] { if (!key || key === UNBOUND_KEY) return null const [type, vmid] = key.split(":") if (type !== "lxc" && type !== "qemu") return null const n = Number(vmid) if (!Number.isFinite(n)) return null return { guest_type: type, vmid: n } } export function CustomLinkEditor({ open, onOpenChange, editing, guests, categoryPresets, onSaved, }: { open: boolean onOpenChange: (v: boolean) => void /** null = create; existing link = edit */ editing: CustomLink | null /** VMs + LXCs from /api/vms so the user can bind a link to a guest */ guests: GuestOption[] /** Populated from /api/apps/categories */ categoryPresets: string[] /** Called on successful save/delete so the parent can refresh */ onSaved: () => void }) { const t = useT() const [draft, setDraft] = useState({ name: "", url: "", logo_url: "", category: "", bindingKey: UNBOUND_KEY, }) const [customCategoryMode, setCustomCategoryMode] = useState(false) const [saving, setSaving] = useState(false) const [deleting, setDeleting] = useState(false) const [error, setError] = useState(null) // Reset the draft whenever the modal opens with a new target. useEffect(() => { if (!open) return setError(null) if (editing) { setDraft({ name: editing.name, url: editing.url, logo_url: editing.logo_url || "", category: editing.category || "", bindingKey: buildKey(editing.binding), }) setCustomCategoryMode( !!editing.category && !categoryPresets.includes(editing.category), ) } else { setDraft({ name: "", url: "", logo_url: "", category: "", bindingKey: UNBOUND_KEY }) setCustomCategoryMode(false) } }, [open, editing, categoryPresets]) const canSave = draft.name.trim() && draft.url.trim() && !saving const handleSave = async () => { setError(null) setSaving(true) try { const payload = { name: draft.name.trim(), url: draft.url.trim(), logo_url: draft.logo_url.trim(), category: draft.category.trim(), binding: parseKey(draft.bindingKey), } if (editing) { await fetchApi(`/api/apps/custom-links/${editing.id}`, { method: "PUT", body: JSON.stringify(payload), headers: { "Content-Type": "application/json" }, }) } else { await fetchApi("/api/apps/custom-links", { method: "POST", body: JSON.stringify(payload), headers: { "Content-Type": "application/json" }, }) } onSaved() onOpenChange(false) } catch (e: any) { setError((e && e.message) || t("apps.customLinkSaveError")) } finally { setSaving(false) } } const handleDelete = async () => { if (!editing) return setError(null) setDeleting(true) try { await fetchApi(`/api/apps/custom-links/${editing.id}`, { method: "DELETE" }) onSaved() onOpenChange(false) } catch (e: any) { setError((e && e.message) || t("apps.customLinkDeleteError")) } finally { setDeleting(false) } } // Sort guests by vmid so the dropdown is easy to scan const sortedGuests = [...guests].sort((a, b) => a.vmid - b.vmid) return ( {editing ? t("apps.customLinkEditTitle") : t("apps.customLinkNewTitle")}
setDraft((d) => ({ ...d, name: e.target.value }))} placeholder={t("apps.customLinkNamePlaceholder")} maxLength={80} className="text-sm" />
setDraft((d) => ({ ...d, url: e.target.value }))} placeholder="https://example.com" maxLength={512} className="text-sm font-mono" />
setDraft((d) => ({ ...d, logo_url: e.target.value }))} placeholder={t("apps.customLinkLogoPlaceholder")} maxLength={512} className="text-sm font-mono" />
{customCategoryMode ? ( setDraft((d) => ({ ...d, category: e.target.value }))} placeholder={t("vmLxc.appEditor.portCategoryCustomPlaceholder")} maxLength={60} className="text-sm" onBlur={() => { if (!draft.category.trim()) setCustomCategoryMode(false) }} /> ) : ( )}

{t("apps.customLinkBindingHelp")}

{error && (
{error}
)}
{editing ? ( ) :
}
) }