import React, { useState, useEffect, useRef, type MutableRefObject, } from "react"; import { useTranslation } from "react-i18next"; import { TERMINAL_THEMES, TERMINAL_FONTS, BELL_STYLES, FAST_SCROLL_MODIFIERS, CURSOR_STYLES, } from "@/lib/terminal-themes"; import { Button } from "@/components/button"; import { Input } from "@/components/input"; import { Activity, ArrowLeft, Box, Check, ChevronDown, ChevronLeft, ChevronRight, Copy, Download, Folder, FolderOpen, FolderSearch, Globe, Info, KeyRound, LayoutDashboard, ListChecks, Lock, MoreHorizontal, Monitor, Network, Palette, Pencil, Pin, Plus, RefreshCw, Search, Server, Settings, Share2, Shield, Tag, Terminal, Trash2, Upload, User, Users, X, Zap, } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/dropdown-menu"; import { toast } from "sonner"; import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card"; import { getSSHHosts, getCredentials, createSSHHost, updateSSHHost, deleteSSHHost, createCredential, updateCredential, deleteCredential, getAllServerStatuses, getServerMetricsById, bulkImportSSHHosts, bulkUpdateSSHHosts, generateKeyPair, generatePublicKeyFromPrivate, deployCredentialToHost, getSnippets, getUserList, getRoles, shareHost, getHostAccess, revokeHostAccess, renameFolder, renameCredentialFolder, refreshServerPolling, deleteAllHostsInFolder, subscribeTunnelStatuses, connectTunnel, disconnectTunnel, } from "@/main-axios"; import type { SSHHostWithStatus } from "@/main-axios"; import type { Host, Credential } from "@/types/ui-types"; function sshHostToHost(h: SSHHostWithStatus): Host { const parseJson = (v: any) => { if (!v) return undefined; if (typeof v === "string") { try { return JSON.parse(v); } catch { return undefined; } } return v; }; return { id: String(h.id), name: h.name, username: h.username, ip: h.ip, port: h.port, folder: h.folder ?? "", online: h.status === "online", cpu: null, ram: null, lastAccess: "", tags: h.tags ?? [], authType: h.authType, password: h.password, key: typeof h.key === "string" ? h.key : undefined, keyPassword: h.keyPassword, keyType: h.keyType, credentialId: h.credentialId != null ? String(h.credentialId) : undefined, notes: h.notes, pin: h.pin ?? false, macAddress: h.macAddress, enableSsh: h.enableSsh != null ? h.enableSsh : h.connectionType === "ssh" || !h.connectionType, enableTerminal: h.enableTerminal ?? (h.enableSsh != null ? h.enableSsh : h.connectionType === "ssh" || !h.connectionType), enableTunnel: h.enableTunnel ?? false, enableFileManager: h.enableFileManager ?? false, enableDocker: h.enableDocker ?? false, enableRdp: h.enableRdp != null ? h.enableRdp : h.connectionType === "rdp", enableVnc: h.enableVnc != null ? h.enableVnc : h.connectionType === "vnc", enableTelnet: h.enableTelnet != null ? h.enableTelnet : h.connectionType === "telnet", sshPort: h.sshPort ?? h.port, rdpPort: h.rdpPort ?? 3389, vncPort: h.vncPort ?? 5900, telnetPort: h.telnetPort ?? 23, rdpUser: h.rdpUser, rdpPassword: h.rdpPassword, domain: h.rdpDomain, security: h.rdpSecurity, ignoreCert: h.rdpIgnoreCert ?? false, vncPassword: h.vncPassword, vncUser: h.vncUser, telnetUser: h.telnetUser, telnetPassword: h.telnetPassword, quickActions: (h.quickActions ?? []).map((a: any) => ({ name: a.name, snippetId: String(a.snippetId), })), serverTunnels: parseJson(h.tunnelConnections) ?? [], jumpHosts: (parseJson(h.jumpHosts) ?? []).map((j: any) => ({ hostId: String(j.hostId ?? j.hostid ?? j), })), portKnockSequence: parseJson(h.portKnockSequence) ?? [], defaultPath: h.defaultPath, terminalConfig: parseJson(h.terminalConfig) as Host["terminalConfig"], statsConfig: parseJson(h.statsConfig) as Host["statsConfig"], guacamoleConfig: parseJson(h.guacamoleConfig), forceKeyboardInteractive: h.forceKeyboardInteractive ?? false, useSocks5: h.useSocks5, socks5Host: h.socks5Host, socks5Port: h.socks5Port, socks5Username: h.socks5Username, socks5Password: h.socks5Password, }; } const HOST_TAB_IDS = [ "general", "ssh", "tunnels", "docker", "files", "stats", "rdp", "vnc", "telnet", "sharing", ] as const; const CREDENTIAL_TAB_IDS = ["general", "auth"] as const; type HostTab = { id: (typeof HOST_TAB_IDS)[number]; label: string; icon: React.ReactNode; }; type CredentialTab = { id: (typeof CREDENTIAL_TAB_IDS)[number]; label: string; icon: React.ReactNode; }; function makeHostTabs(t: (key: string) => string): HostTab[] { return [ { id: "general", label: t("hosts.tabGeneral"), icon: , }, { id: "ssh", label: "SSH", icon: }, { id: "tunnels", label: t("hosts.tabTunnels"), icon: , }, { id: "docker", label: t("hosts.tabDocker"), icon: , }, { id: "files", label: t("hosts.tabFiles"), icon: , }, { id: "stats", label: t("hosts.tabStats"), icon: , }, { id: "rdp", label: "RDP", icon: }, { id: "vnc", label: "VNC", icon: }, { id: "telnet", label: t("hosts.tabTelnet"), icon: , }, { id: "sharing", label: t("hosts.tabSharing"), icon: , }, ]; } function makeCredentialTabs(t: (key: string) => string): CredentialTab[] { return [ { id: "general", label: t("hosts.tabGeneral"), icon: , }, { id: "auth", label: t("hosts.tabAuthentication"), icon: , }, ]; } const SSH_DEP_TABS = new Set(["tunnels", "docker", "files", "stats"]); function TabStrip({ tabs, activeTab, onTabChange, }: { tabs: { id: string; label: string; icon: React.ReactNode }[]; activeTab: string; onTabChange: (id: string) => void; }) { const hasSshGroup = tabs.some((t) => SSH_DEP_TABS.has(t.id)); const ref = useRef(null); useEffect(() => { const el = ref.current; if (!el) return; const onWheel = (e: WheelEvent) => { if (e.deltaY === 0) return; e.preventDefault(); el.scrollLeft += e.deltaY; }; el.addEventListener("wheel", onWheel, { passive: false }); return () => el.removeEventListener("wheel", onWheel); }, []); // Split tabs into groups for rendering a labeled SSH section const nonSshTabs = tabs.filter((t) => !SSH_DEP_TABS.has(t.id)); const sshDepTabs = tabs.filter((t) => SSH_DEP_TABS.has(t.id)); const renderTab = (tab: (typeof tabs)[0]) => ( ); return (
{nonSshTabs.map(renderTab)} {hasSshGroup && sshDepTabs.length > 0 && (
SSH
{sshDepTabs.map(renderTab)}
)}
); } function HostRow({ host, selectionMode, selected, onToggleSelect, onEdit, onDelete, onDragStart, onDragEnd, depth = 0, stripeIndex = 0, statusesLoading = false, initialLoadComplete = true, }: { host: Host; selectionMode: boolean; selected: boolean; onToggleSelect: () => void; onEdit: () => void; onDelete: () => void; onDragStart?: () => void; onDragEnd?: () => void; depth?: number; stripeIndex?: number; statusesLoading?: boolean; initialLoadComplete?: boolean; }) { const { t } = useTranslation(); const [hovered, setHovered] = useState(false); const connTypeColor = "border-border/60 text-muted-foreground/60"; const metricsEnabled = host.statsConfig?.metricsEnabled !== false; const fireOpen = (type: string) => { window.dispatchEvent( new CustomEvent("termix:open-tab", { detail: { hostId: host.id, type } }), ); }; return (
setHovered(true)} onMouseLeave={() => setHovered(false)} onClick={selectionMode ? onToggleSelect : undefined} style={{ paddingLeft: depth > 0 ? `${depth * 12 + 8}px` : undefined }} className={`relative flex flex-col border-b border-border/50 last:border-0 transition-colors select-none ${selectionMode ? "cursor-pointer" : ""} ${selected ? "bg-accent-brand/5" : hovered ? "bg-muted/40" : stripeIndex % 2 === 1 ? "bg-muted/20" : ""} ${onDragStart && !selectionMode ? "cursor-grab active:cursor-grabbing" : ""}`} > {/* Main row */}
{selectionMode && (
{selected && }
)} {/* Status dot */}
{/* Name + badges */}
{host.name} {host.pin && ( )}
{host.enableSsh && ( SSH )} {host.enableRdp && ( RDP )} {host.enableVnc && ( VNC )} {host.enableTelnet && ( TELNET )}
{/* Right: last access always visible, CPU/RAM on hover */}
{host.online && hovered && metricsEnabled && host.cpu != null && host.ram != null && (
CPU
80 ? "bg-red-400" : host.cpu > 50 ? "bg-yellow-400" : "bg-accent-brand"}`} style={{ width: `${host.cpu}%` }} />
{host.cpu}%
RAM
80 ? "bg-red-400" : host.ram > 60 ? "bg-yellow-400" : "bg-accent-brand/60"}`} style={{ width: `${host.ram}%` }} />
{host.ram}%
)} {host.lastAccess}
{/* Sub-row: address + tags */}
0 ? `${depth * 12 + 8 + 12 + 8 + 6}px` : undefined, }} > {host.username}@{host.ip}:{host.sshPort || host.port} {host.tags && host.tags.length > 0 && (
{host.tags.slice(0, 4).map((tag) => ( {tag} ))} {host.tags.length > 4 && ( +{host.tags.length - 4} )}
)}
{/* Hover action tray */} {hovered && !selectionMode && (
0 ? `-${depth * 12 + 8}px` : undefined }} >
0 ? `${depth * 12 + 8}px` : "8px", paddingRight: "8px", }} > {host.enableTerminal && ( )} {host.enableFileManager && ( )} {host.enableDocker && ( )} {host.enableTunnel && ( )} {metricsEnabled && ( )} {host.enableRdp && ( )} {host.enableVnc && ( )} {host.enableTelnet && ( )}
toast.success(t("hosts.hostCloned"))} > {t("hosts.cloneHostAction")} { navigator.clipboard.writeText( `${host.username}@${host.ip}`, ); toast.success(t("hosts.copiedToClipboard")); }} > {t("hosts.copyAddress")} {host.enableTerminal && ( { navigator.clipboard.writeText( `${window.location.origin}?view=terminal&hostId=${host.id}`, ); toast.success(t("hosts.terminalUrlCopied")); }} > {t("hosts.copyTerminalUrlAction")} )} {host.enableFileManager && ( { navigator.clipboard.writeText( `${window.location.origin}?view=file_manager&hostId=${host.id}`, ); toast.success(t("hosts.fileManagerUrlCopied")); }} > {t("hosts.copyFileManagerUrlAction")} )} {(host.enableRdp || host.enableVnc || host.enableTelnet) && ( { const proto = host.enableRdp ? "rdp" : host.enableVnc ? "vnc" : "telnet"; navigator.clipboard.writeText( `${window.location.origin}?view=${proto}&hostId=${host.id}`, ); toast.success(t("hosts.remoteDesktopUrlCopied")); }} > {t("hosts.copyRemoteDesktopUrlAction")} )} {t("common.delete")}
)}
); } function HostEditor({ host, activeTab, onBack, onSave, protocols, onProtocolChange, onTabChange, hosts, credentials, }: { host: Host | null; activeTab: string; onBack: () => void; onSave: (saved: any) => void; protocols: { enableSsh: boolean; enableRdp: boolean; enableVnc: boolean; enableTelnet: boolean; }; onProtocolChange: (p: Partial) => void; onTabChange: (tab: string) => void; hosts: Host[]; credentials: { id: string; name: string; username: string }[]; }) { const { t } = useTranslation(); const [form, setForm] = useState(() => { const rawTheme = host?.terminalConfig?.theme; const normalizedTheme = !rawTheme || ["Termix Dark", "Termix Light", "termixDark", "termixLight"].includes( rawTheme, ) ? "termix" : TERMINAL_THEMES[rawTheme] ? rawTheme : "termix"; return { name: host?.name ?? "", ip: host?.ip ?? "", username: host?.username ?? "", sshPort: host?.sshPort ?? 22, rdpPort: host?.rdpPort ?? 3389, vncPort: host?.vncPort ?? 5900, telnetPort: host?.telnetPort ?? 23, authType: host?.authType ?? "password", password: host?.password ?? "", key: host?.key ?? "", keyPassword: host?.keyPassword ?? "", credentialId: host?.credentialId ?? "", folder: host?.folder ?? "", tags: host?.tags ?? ([] as string[]), tagInput: "", notes: host?.notes ?? "", pin: host?.pin ?? false, macAddress: host?.macAddress ?? "", useSocks5: host?.useSocks5 ?? false, socks5Host: host?.socks5Host ?? "", socks5Port: host?.socks5Port ?? 1080, socks5Username: host?.socks5Username ?? "", socks5Password: host?.socks5Password ?? "", enableTerminal: host?.enableTerminal ?? true, enableFileManager: host?.enableFileManager ?? false, enableDocker: host?.enableDocker ?? false, enableTunnel: host?.enableTunnel ?? false, defaultPath: host?.defaultPath ?? "/", forceKeyboardInteractive: host?.forceKeyboardInteractive ?? false, fontSize: host?.terminalConfig?.fontSize ?? 14, fontFamily: host?.terminalConfig?.fontFamily ?? "Caskaydia Cove Nerd Font Mono", theme: normalizedTheme, cursorStyle: (host?.terminalConfig?.cursorStyle ?? "bar") as | "block" | "underline" | "bar", cursorBlink: host?.terminalConfig?.cursorBlink ?? true, scrollback: host?.terminalConfig?.scrollback ?? 10000, letterSpacing: host?.terminalConfig?.letterSpacing ?? 0, lineHeight: host?.terminalConfig?.lineHeight ?? 1.0, bellStyle: (host?.terminalConfig?.bellStyle ?? "none") as | "none" | "sound" | "visual" | "both", rightClickSelectsWord: host?.terminalConfig?.rightClickSelectsWord ?? false, fastScrollModifier: (host?.terminalConfig?.fastScrollModifier ?? "alt") as "alt" | "ctrl" | "shift", fastScrollSensitivity: host?.terminalConfig?.fastScrollSensitivity ?? 5, minimumContrastRatio: host?.terminalConfig?.minimumContrastRatio ?? 1, backspaceMode: (host?.terminalConfig?.backspaceMode ?? "normal") as | "normal" | "control-h", startupSnippetId: host?.terminalConfig?.startupSnippetId ?? null, moshCommand: host?.terminalConfig?.moshCommand ?? "", agentForwarding: host?.terminalConfig?.agentForwarding ?? false, autoMosh: host?.terminalConfig?.autoMosh ?? false, autoTmux: host?.terminalConfig?.autoTmux ?? false, sudoPasswordAutoFill: host?.terminalConfig?.sudoPasswordAutoFill ?? false, sudoPassword: host?.terminalConfig?.sudoPassword ?? "", keepaliveInterval: host?.terminalConfig?.keepaliveInterval ?? 30, keepaliveCountMax: host?.terminalConfig?.keepaliveCountMax ?? 3, environmentVariables: host?.terminalConfig?.environmentVariables ?? ([] as { key: string; value: string }[]), serverTunnels: host?.serverTunnels ?? ([] as Host["serverTunnels"]), jumpHosts: host?.jumpHosts ?? ([] as { hostId: string }[]), portKnockSequence: host?.portKnockSequence ?? ([] as { port: number; protocol: "tcp" | "udp"; delay: number }[]), quickActions: host?.quickActions ?? ([] as { name: string; snippetId: string }[]), rdpUser: host?.rdpUser ?? "", rdpPassword: host?.rdpPassword ?? "", domain: host?.domain ?? "", security: host?.security ?? "", ignoreCert: host?.ignoreCert ?? false, vncPassword: host?.vncPassword ?? "", vncUser: host?.vncUser ?? "", telnetUser: host?.telnetUser ?? "", telnetPassword: host?.telnetPassword ?? "", guacamoleConfig: host?.guacamoleConfig ?? ({} as Record), statsConfig: host?.statsConfig ?? { statusCheckEnabled: true, statusCheckInterval: 60, useGlobalStatusInterval: true, metricsEnabled: true, metricsInterval: 30, useGlobalMetricsInterval: true, enabledWidgets: [ "cpu", "memory", "disk", "network", "uptime", "system", "login_stats", "processes", "ports", "firewall", ], }, }; }); const setField = (k: K, v: (typeof form)[K]) => setForm((p) => ({ ...p, [k]: v })); const setGuacField = (key: string, value: any) => setField("guacamoleConfig", { ...form.guacamoleConfig, [key]: value }); const [saving, setSaving] = useState(false); const [snippets, setSnippets] = useState<{ id: number; name: string }[]>([]); const [shareType, setShareType] = useState<"user" | "role">("user"); const [shareGranteeId, setShareGranteeId] = useState(""); const [sharePermission, setSharePermission] = useState("view"); const [shareExpiryHours, setShareExpiryHours] = useState(""); const [accessList, setAccessList] = useState([]); const [shareUsers, setShareUsers] = useState< { id: string; username: string }[] >([]); const [shareRoles, setShareRoles] = useState<{ id: string; name: string }[]>( [], ); const [sharingLoaded, setSharingLoaded] = useState(false); const [sharingLoadError, setSharingLoadError] = useState(false); const [tunnelStatuses, setTunnelStatuses] = useState>({}); const [connectingTunnel, setConnectingTunnel] = useState(null); useEffect(() => { getSnippets() .then((res: any) => { const arr = Array.isArray(res) ? res : (res?.snippets ?? []); setSnippets( arr.map((s: any) => ({ id: s.id, name: s.name ?? s.title ?? `Snippet ${s.id}`, })), ); }) .catch(() => {}); }, []); useEffect(() => { if (activeTab !== "sharing" || !host) return; if (sharingLoaded) return; setSharingLoaded(true); Promise.all([ getHostAccess(Number(host.id)).catch(() => ({ access: [] })), getUserList().catch(() => ({ users: [] })), getRoles().catch(() => ({ roles: [] })), ]) .then(([accessRes, usersRes, rolesRes]) => { setAccessList((accessRes as any)?.access ?? []); setShareUsers( ((usersRes as any)?.users ?? []).map((u: any) => ({ id: String(u.id ?? u.userId), username: u.username, })), ); setShareRoles( ((rolesRes as any)?.roles ?? []).map((r: any) => ({ id: String(r.id), name: r.name, })), ); }) .catch(() => setSharingLoadError(true)); }, [activeTab, host, sharingLoaded]); useEffect(() => { setSharingLoaded(false); setSharingLoadError(false); setAccessList([]); }, [host?.id]); useEffect(() => { if (activeTab !== "tunnels") return; const unsub = subscribeTunnelStatuses((s) => setTunnelStatuses(s)); return unsub; }, [activeTab]); const handleSave = async () => { setSaving(true); try { const tags = form.tags; const data = { connectionType: protocols.enableSsh ? "ssh" : protocols.enableRdp ? "rdp" : protocols.enableVnc ? "vnc" : "telnet", name: form.name, ip: form.ip, port: protocols.enableSsh ? Number(form.sshPort) : protocols.enableRdp ? Number(form.rdpPort) : protocols.enableVnc ? Number(form.vncPort) : Number(form.telnetPort), username: form.username, folder: form.folder, tags, pin: form.pin, authType: form.authType, password: form.password || null, key: form.key || null, keyPassword: form.keyPassword || null, credentialId: form.credentialId ? Number(form.credentialId) : null, notes: form.notes, macAddress: form.macAddress || null, enableTerminal: form.enableTerminal, enableTunnel: form.enableTunnel, enableFileManager: form.enableFileManager, enableDocker: form.enableDocker, defaultPath: form.defaultPath || "/", useSocks5: form.useSocks5, socks5Host: form.socks5Host || null, socks5Port: form.socks5Port || null, socks5Username: form.socks5Username || null, socks5Password: form.socks5Password || null, enableSsh: protocols.enableSsh, enableRdp: protocols.enableRdp, enableVnc: protocols.enableVnc, enableTelnet: protocols.enableTelnet, sshPort: Number(form.sshPort), rdpPort: Number(form.rdpPort), vncPort: Number(form.vncPort), telnetPort: Number(form.telnetPort), forceKeyboardInteractive: form.forceKeyboardInteractive, rdpUser: form.rdpUser || null, rdpPassword: form.rdpPassword || null, rdpDomain: form.domain || null, rdpSecurity: form.security || null, rdpIgnoreCert: form.ignoreCert, vncPassword: form.vncPassword || null, vncUser: form.vncUser || null, telnetUser: form.telnetUser || null, telnetPassword: form.telnetPassword || null, jumpHosts: form.jumpHosts, portKnockSequence: form.portKnockSequence, tunnelConnections: form.serverTunnels, quickActions: form.quickActions.map((a) => ({ name: a.name, snippetId: Number(a.snippetId), })), statsConfig: form.statsConfig, guacamoleConfig: (protocols.enableRdp || protocols.enableVnc || protocols.enableTelnet) && Object.keys(form.guacamoleConfig).length > 0 ? form.guacamoleConfig : null, terminalConfig: protocols.enableSsh ? { theme: form.theme, cursorBlink: form.cursorBlink, cursorStyle: form.cursorStyle, fontSize: Number(form.fontSize), fontFamily: form.fontFamily, scrollback: Number(form.scrollback), letterSpacing: Number(form.letterSpacing), lineHeight: Number(form.lineHeight), bellStyle: form.bellStyle, rightClickSelectsWord: form.rightClickSelectsWord, fastScrollModifier: form.fastScrollModifier, fastScrollSensitivity: Number(form.fastScrollSensitivity), minimumContrastRatio: Number(form.minimumContrastRatio), backspaceMode: form.backspaceMode, startupSnippetId: form.startupSnippetId ?? null, moshCommand: form.moshCommand || null, agentForwarding: form.agentForwarding, autoMosh: form.autoMosh, autoTmux: form.autoTmux, sudoPasswordAutoFill: form.sudoPasswordAutoFill, sudoPassword: form.sudoPassword || null, keepaliveInterval: Number(form.keepaliveInterval), keepaliveCountMax: Number(form.keepaliveCountMax), environmentVariables: form.environmentVariables, } : null, }; const saved = host ? await updateSSHHost(Number(host.id), data as any) : await createSSHHost(data as any); toast.success(host ? t("hosts.hostUpdated") : t("hosts.hostCreated")); onSave(saved); } catch { toast.error(t("hosts.failedToSave")); } finally { setSaving(false); } }; const authMethod = form.authType; const handleProtocolToggle = ( proto: keyof typeof protocols, value: boolean, ) => { onProtocolChange({ [proto]: value }); const tabForProto: Record = { enableSsh: "ssh", enableRdp: "rdp", enableVnc: "vnc", enableTelnet: "telnet", }; if (!value && activeTab === tabForProto[proto]) onTabChange("general"); if (value && tabForProto[proto]) onTabChange(tabForProto[proto]); }; return (
{activeTab === "general" && ( <> {/* Protocols — enable/disable each connection type */} } >
{[ { proto: "enableSsh" as const, label: "SSH", desc: t("hosts.secureShell"), icon: , portField: "sshPort" as const, }, { proto: "enableRdp" as const, label: "RDP", desc: "Remote Desktop", icon: , portField: "rdpPort" as const, }, { proto: "enableVnc" as const, label: "VNC", desc: t("hosts.virtualNetwork"), icon: , portField: "vncPort" as const, }, { proto: "enableTelnet" as const, label: "Telnet", desc: t("hosts.unencryptedShell"), icon: , portField: "telnetPort" as const, }, ].map(({ proto, label, desc, icon, portField }) => { const enabled = protocols[proto]; return (
{icon}
{label} {desc}
handleProtocolToggle(proto, v) } />
); })}
} >
setField("ip", e.target.value)} />
setField("name", e.target.value)} />
{protocols.enableSsh && (
setField("macAddress", e.target.value)} />
)}
{!protocols.enableSsh && !protocols.enableRdp && !protocols.enableVnc && !protocols.enableTelnet && (
{t("hosts.enableAtLeastOneProtocol")}
)} } >
setField("folder", e.target.value)} />
{form.tags.map((tag) => ( {tag} ))} setField("tagInput", e.target.value)} onKeyDown={(e) => { if ( (e.key === " " || e.key === "Enter") && form.tagInput.trim() ) { e.preventDefault(); const tag = form.tagInput.trim(); if (!form.tags.includes(tag)) setField("tags", [...form.tags, tag]); setField("tagInput", ""); } else if ( e.key === "Backspace" && !form.tagInput && form.tags.length > 0 ) { setField("tags", form.tags.slice(0, -1)); } }} />