diff --git a/client/ui/frontend/src/components/Tooltip.tsx b/client/ui/frontend/src/components/Tooltip.tsx index c923667da..4badd65e5 100644 --- a/client/ui/frontend/src/components/Tooltip.tsx +++ b/client/ui/frontend/src/components/Tooltip.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useRef, useState } from "react"; +import { ReactNode, useEffect, useRef, useState } from "react"; import * as RTooltip from "@radix-ui/react-tooltip"; import { cn } from "@/lib/cn"; @@ -9,8 +9,15 @@ type Props = { align?: RTooltip.TooltipContentProps["align"]; delayDuration?: number; sideOffset?: number; + alignOffset?: number; interactive?: boolean; keepOpenOnClick?: boolean; + // Overrides the default tooltip-content chrome (background, padding, + // border, radius). Use when a richer body needs popover-style layout. + contentClassName?: string; + // Ms to wait after pointer-leave before closing. Lets the user cross + // a gap between trigger and content without the tooltip snapping shut. + closeDelay?: number; }; export const Tooltip = ({ @@ -20,14 +27,35 @@ export const Tooltip = ({ align = "center", delayDuration = 200, sideOffset = 6, + alignOffset = 0, interactive = false, keepOpenOnClick = true, + contentClassName, + closeDelay = 0, }: Props) => { const [open, setOpen] = useState(false); const hoveringRef = useRef(false); + const closeTimer = useRef | null>(null); + + const cancelClose = () => { + if (closeTimer.current) { + clearTimeout(closeTimer.current); + closeTimer.current = null; + } + }; + const scheduleClose = () => { + cancelClose(); + if (closeDelay <= 0) { + setOpen(false); + return; + } + closeTimer.current = setTimeout(() => setOpen(false), closeDelay); + }; + useEffect(() => () => cancelClose(), []); const handleOpenChange = (next: boolean) => { if (!next && keepOpenOnClick && hoveringRef.current) return; + if (next) cancelClose(); setOpen(next); }; @@ -41,10 +69,11 @@ export const Tooltip = ({ asChild onPointerEnter={() => { hoveringRef.current = true; + cancelClose(); }} onPointerLeave={() => { hoveringRef.current = false; - setOpen(false); + scheduleClose(); }} > {children} @@ -54,15 +83,19 @@ export const Tooltip = ({ side={side} align={align} sideOffset={sideOffset} + alignOffset={alignOffset} + onPointerEnter={interactive ? cancelClose : undefined} + onPointerLeave={interactive ? scheduleClose : undefined} onPointerDownOutside={ interactive ? undefined : (e) => e.preventDefault() } className={cn( - "z-50 select-none rounded-md border border-nb-gray-850 bg-nb-gray-900 px-2 py-1", - "text-xs text-nb-gray-100 shadow-lg", + "z-50 select-none text-xs text-nb-gray-100 shadow-lg", "data-[state=delayed-open]:animate-in data-[state=closed]:animate-out", "data-[state=closed]:fade-out-0 data-[state=delayed-open]:fade-in-0", !interactive && "pointer-events-none", + contentClassName ?? + "rounded-md border border-nb-gray-850 bg-nb-gray-900 px-2 py-1", )} > {content} diff --git a/client/ui/frontend/src/components/TruncatedText.tsx b/client/ui/frontend/src/components/TruncatedText.tsx new file mode 100644 index 000000000..bf4e33bd2 --- /dev/null +++ b/client/ui/frontend/src/components/TruncatedText.tsx @@ -0,0 +1,42 @@ +import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { Tooltip } from "@/components/Tooltip"; + +type Props = { + text: string; + className?: string; + tooltipContent?: ReactNode; + delayDuration?: number; +}; + +// Renders text with `truncate`; measures scrollWidth vs clientWidth after +// layout and wraps in a Tooltip only when the text actually overflows. Avoids +// the "tooltip on hover even though everything fits" annoyance. The caller +// supplies the wrapper styling (font, max-width, etc.) via className — this +// component only owns the truncate + measure + tooltip behavior. +export const TruncatedText = ({ + text, + className, + tooltipContent, + delayDuration = 600, +}: Props) => { + const ref = useRef(null); + const [overflowing, setOverflowing] = useState(false); + + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + setOverflowing(el.scrollWidth > el.clientWidth); + }, [text]); + + const span = ( + + {text} + + ); + if (!overflowing) return span; + return ( + + {span} + + ); +}; diff --git a/client/ui/frontend/src/modules/main/advanced/exit-nodes/ExitNodes.tsx b/client/ui/frontend/src/modules/main/advanced/exit-nodes/ExitNodes.tsx index bc17b5160..bcc628b86 100644 --- a/client/ui/frontend/src/modules/main/advanced/exit-nodes/ExitNodes.tsx +++ b/client/ui/frontend/src/modules/main/advanced/exit-nodes/ExitNodes.tsx @@ -6,6 +6,7 @@ import { WaypointsIcon } from "lucide-react"; import type { Network } from "@bindings/services/models.js"; import { cn } from "@/lib/cn"; import { SearchInput } from "@/components/inputs/SearchInput"; +import { TruncatedText } from "@/components/TruncatedText"; import { EmptyState } from "@/components/empty-state/EmptyState"; import { NoResults } from "@/components/empty-state/NoResults"; import { useStatus } from "@/contexts/StatusContext"; @@ -159,13 +160,14 @@ const Row = ({ value, label, first }: RowProps) => ( "wails-no-draggable cursor-pointer outline-none text-left", )} > - - {label} - +
+ +
disabled={disabled} className={cn( "inline-flex items-center gap-1.5 h-9 px-2 rounded-md", - "text-sm text-nb-gray-100", + "text-sm text-nb-gray-200", "outline-none hover:bg-nb-gray-900 data-[state=open]:bg-nb-gray-900 transition-colors duration-150", "disabled:opacity-50 disabled:pointer-events-none", - "wails-no-draggable", + "wails-no-draggable cursor-default", )} > - {active.label}{" "} - - ({counts[active.value]}) - + {active.label} ({counts[active.value]}) - + {filters.map((f) => { diff --git a/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx b/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx index 2a1aa850f..cac842082 100644 --- a/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx +++ b/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx @@ -1,11 +1,12 @@ import { useEffect, useMemo, useRef, useState, type ComponentType } from "react"; import { useTranslation } from "react-i18next"; -import * as Popover from "@radix-ui/react-popover"; import * as ScrollArea from "@radix-ui/react-scroll-area"; import { GlobeIcon, type LucideProps, NetworkIcon, WorkflowIcon } from "lucide-react"; import type { Network } from "@bindings/services/models.js"; import { cn } from "@/lib/cn"; import { CopyToClipboard } from "@/components/CopyToClipboard"; +import { Tooltip } from "@/components/Tooltip"; +import { TruncatedText } from "@/components/TruncatedText"; import { SearchInput } from "@/components/inputs/SearchInput"; import { EmptyState } from "@/components/empty-state/EmptyState"; import { NoResults } from "@/components/empty-state/NoResults"; @@ -76,11 +77,7 @@ export const Networks = () => { const { t } = useTranslation(); const { status } = useStatus(); const isConnected = status?.status === "Connected"; - const { - networkRoutes: realNetworkRoutes, - toggleNetwork, - setNetworksSelected, - } = useNetworks(); + const { networkRoutes: realNetworkRoutes, toggleNetwork, setNetworksSelected } = useNetworks(); const networkRoutes = mockOr(realNetworkRoutes, mockNetworkRoutes); const [search, setSearch] = useState(""); const [filter, setFilter] = useState("all"); @@ -164,9 +161,7 @@ export const Networks = () => { const selectedInView = filtered.filter((r) => r.selected).length; const allSelected = filtered.length > 0 && selectedInView === filtered.length; - const bulkLabel = allSelected - ? t("networks.bulk.disableAll") - : t("networks.bulk.enableAll"); + const bulkLabel = allSelected ? t("networks.bulk.disableAll") : t("networks.bulk.enableAll"); const onBulkClick = () => { if (filtered.length === 0) return; @@ -262,7 +257,7 @@ const NetworksList = ({ data, onToggle }: NetworksListProps) => { key={n.id} onClick={() => onToggle(n.id, n.selected)} className={cn( - "group flex items-start gap-2.5 pl-6 pr-8 py-3 min-w-0 first:mt-2", + "group flex items-start gap-2.5 pl-6 pr-9 py-3 min-w-0 first:mt-2", "hover:bg-nb-gray-900/40 transition-colors", "wails-no-draggable cursor-pointer", )} @@ -271,29 +266,21 @@ const NetworksList = ({ data, onToggle }: NetworksListProps) => {
- - {n.id} - + />
-
e.stopPropagation()} - > +
e.stopPropagation()}> onToggle(n.id, n.selected)} - label={ - n.selected - ? t("networks.selected") - : t("networks.unselected") - } + label={n.selected ? t("networks.selected") : t("networks.unselected")} />
@@ -307,7 +294,7 @@ const ResourceIconBadge = ({ type }: { type: ResourceType }) => { return (
@@ -327,9 +314,12 @@ const Subtitle = ({ network }: { network: Network }) => { return (
- - {network.range} - +
); @@ -339,90 +329,63 @@ const Subtitle = ({ network }: { network: Network }) => { }; const DomainSubtitle = ({ domain, ips }: { domain: string; ips: string[] }) => { - const first = ips[0]; - const extra = ips.length - 1; - + const span = ( + + {domain} + + ); return ( - <> -
- - - {domain} - - -
- {first && ( -
- - - {first} - - - {extra > 0 && } -
- )} - +
+ + {ips.length > 0 ? ( + } + delayDuration={300} + closeDelay={300} + side={"right"} + align={"start"} + alignOffset={-8} + interactive + keepOpenOnClick + contentClassName={cn( + "max-w-[18rem] max-h-72 overflow-auto", + "rounded-lg border border-nb-gray-900 bg-nb-gray-935", + "p-2 pr-4", + )} + > + {span} + + ) : ( + span + )} + +
); }; -const ResolvedIpsPopover = ({ ips }: { ips: string[] }) => { +const ResolvedIpsTooltip = ({ ips }: { ips: string[] }) => { const { t } = useTranslation(); - const extra = ips.length - 1; - return ( - - - - - - -
- {t("networks.ips.heading")} -
-
    - {ips.map((ip) => ( -
  • - - - {ip} - - -
  • - ))} -
-
-
-
+ <> +
+ {t("networks.ips.heading")} +
+
    + {ips.map((ip) => ( +
  • + + + {ip} + + +
  • + ))} +
+ ); }; @@ -450,11 +413,7 @@ const NetworkToggle = ({ checked, onChange, label, mixed }: ToggleProps) => ( diff --git a/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx b/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx index 4977618b3..560c8061b 100644 --- a/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx +++ b/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx @@ -1,12 +1,4 @@ -import { - ComponentType, - ReactNode, - useCallback, - useEffect, - useLayoutEffect, - useRef, - useState, -} from "react"; +import { ComponentType, ReactNode, useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { AnimatePresence, motion, type Transition } from "framer-motion"; import * as Popover from "@radix-ui/react-popover"; @@ -17,6 +9,7 @@ import { ArrowUpDownIcon, ArrowUpIcon, CableIcon, + ChevronDownIcon, ClockIcon, GaugeIcon, HandshakeIcon, @@ -34,6 +27,7 @@ import type { PeerStatus } from "@bindings/services/models.js"; import { cn } from "@/lib/cn"; import { CopyToClipboard } from "@/components/CopyToClipboard"; import { Tooltip } from "@/components/Tooltip"; +import { TruncatedText } from "@/components/TruncatedText"; import { formatBytes, formatRelative, latencyColor } from "@/lib/formatters"; import { useStatus } from "@/contexts/StatusContext"; import { usePeerDetail } from "@/contexts/PeerDetailContext"; @@ -249,6 +243,22 @@ const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => { )} + {peer.relayed && ( + + {peer.relayAddress ? ( + + + + ) : ( + DASH + )} + + )} {peer.latencyMs > 0 && ( @@ -282,6 +292,11 @@ const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => { {statusSince} + {peer.networks.length > 0 && ( + + + + )} { type={peer.remoteIceCandidateType} endpoint={peer.remoteIceCandidateEndpoint} /> - {peer.relayed && ( - - {peer.relayAddress ? ( - - - - ) : ( - DASH - )} - - )} - {peer.networks.length > 0 && ( - - - - )} {peer.pubKey ? ( { ); }; -// "{N} Resources" trigger that opens a hover popover listing each routed -// network on its own line with a click-to-copy entry. Mirrors the resolved-IPs -// popover in the Resources tab (Networks.tsx ResolvedIpsPopover). -// Row value: first network inline, plus a "+N more" hover pill opening a -// popover with the full list. Mirrors the resolved-IPs pattern in -// Networks.tsx so the Resources tab and the peer detail row look consistent. -const ResourcesValue = ({ networks }: { networks: string[] }) => { - const first = networks[0]; - const extra = networks.length - 1; - return ( -
- - - - {extra > 0 && } -
- ); -}; +// Single "View {n}" badge with a chevron that opens a click popover listing +// each routed resource on its own line with a click-to-copy entry. Avoids +// the repetitive "first item + N more" pattern given the row already has a +// "Resources" label and Layers icon. +const ResourcesValue = ({ networks }: { networks: string[] }) => ( + +); -const ResourcesMorePopover = ({ - networks, - extra, -}: { - networks: string[]; - extra: number; -}) => { - const { t } = useTranslation(); +const ResourcesPopover = ({ networks }: { networks: string[] }) => { const [open, setOpen] = useState(false); - const closeTimer = useRef | null>(null); - - const cancelClose = () => { - if (closeTimer.current) { - clearTimeout(closeTimer.current); - closeTimer.current = null; - } - }; - // Close with a small delay so the mouse can cross the sideOffset gap - // between trigger and content without the popover snapping shut. - const scheduleClose = () => { - cancelClose(); - closeTimer.current = setTimeout(() => setOpen(false), 300); - }; - useEffect(() => () => cancelClose(), []); return ( @@ -438,13 +400,11 @@ const ResourcesMorePopover = ({ side={"bottom"} align={"end"} sideOffset={6} - onMouseEnter={cancelClose} - onMouseLeave={scheduleClose} onOpenAutoFocus={(e) => e.preventDefault()} className={cn( - "z-50 w-64 max-h-72 overflow-auto", + "z-50 max-w-[18rem] max-h-72 overflow-auto", "rounded-lg border border-nb-gray-900 bg-nb-gray-935", - "p-2 shadow-lg outline-none", + "p-2 pr-4 shadow-lg outline-none", )} >
    @@ -453,7 +413,7 @@ const ResourcesMorePopover = ({ {n} @@ -468,37 +428,15 @@ const ResourcesMorePopover = ({ ); }; -// Truncates the value to one line with the row's available width; on hover, -// shows the full string in a tooltip — but only when actually clipped. Same -// pattern as TruncatedName in Peers.tsx / TruncatedEmail in ProfileDropdown. -const TruncatedRowValue = ({ value, mono }: { value: string; mono?: boolean }) => { - const ref = useRef(null); - const [overflowing, setOverflowing] = useState(false); - - useLayoutEffect(() => { - const el = ref.current; - if (!el) return; - setOverflowing(el.scrollWidth > el.clientWidth); - }, [value]); - - const span = ( - - {value} - - ); - if (!overflowing) return span; - return ( - - {span} - - ); -}; +const TruncatedRowValue = ({ value, mono }: { value: string; mono?: boolean }) => ( + +); const Row = ({ icon: Icon, iconClassName, label, children }: RowProps) => (
  • diff --git a/client/ui/frontend/src/modules/main/advanced/peers/PeerFilters.tsx b/client/ui/frontend/src/modules/main/advanced/peers/PeerFilters.tsx index d0e97c03f..b42ac4e14 100644 --- a/client/ui/frontend/src/modules/main/advanced/peers/PeerFilters.tsx +++ b/client/ui/frontend/src/modules/main/advanced/peers/PeerFilters.tsx @@ -39,7 +39,7 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => { disabled={disabled} className={cn( "inline-flex items-center gap-1.5 h-9 px-2 rounded-md", - "text-sm text-nb-gray-100", + "text-sm text-nb-gray-200", "outline-none hover:bg-nb-gray-900 data-[state=open]:bg-nb-gray-900 transition-colors duration-150", "disabled:opacity-50 disabled:pointer-events-none", "wails-no-draggable cursor-default", @@ -47,15 +47,9 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => { > - {active.label}{" "} - - ({counts[active.value]}) - + {active.label} ({counts[active.value]}) - + {filters.map((f) => { @@ -68,21 +62,10 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => { > {f.label}{" "} - - ({counts[f.value]}) - + ({counts[f.value]}) - - {checked && ( - - )} + + {checked && } ); diff --git a/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx index 1fe21be51..9dd57f848 100644 --- a/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx +++ b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import * as ScrollArea from "@radix-ui/react-scroll-area"; import { ChevronRightIcon, LaptopIcon } from "lucide-react"; @@ -12,6 +12,7 @@ import { latencyColor } from "@/lib/formatters"; import { useStatus } from "@/contexts/StatusContext"; import { usePeerDetail } from "@/contexts/PeerDetailContext"; import { Tooltip } from "@/components/Tooltip"; +import { TruncatedText } from "@/components/TruncatedText"; import { mockOr, mockPeers } from "@/lib/mock"; import { PeerFilters, StatusFilter } from "./PeerFilters"; @@ -67,27 +68,55 @@ export const Peers = () => { }; }, [peers]); - // Initial order: online-first, then alphabetically by fqdn / ip. After - // that, positions are sticky — a peer flipping Connected→Connecting→Idle - // no longer jumps groups. Newly discovered peers append at the end - // (sorted online-first / by-name among themselves). Mirrors the - // networks-list and exit-nodes-list orderRef pattern. + // Initial order: online-first, then alphabetically by fqdn / ip. Once + // peers have settled, positions become sticky — a peer flipping + // Connected→Connecting→Idle no longer jumps groups. Newly discovered + // peers append at the end (sorted online-first / by-name among + // themselves). Mirrors the networks-list and exit-nodes-list orderRef + // pattern. + // + // Stay in live-sort mode until every peer has reached a stable state + // (Connected or Idle). The daemon emits all peers as "Connecting" right + // after Up, which collapses the online-first sort into pure + // alphabetical — committing then would lock that incorrect order and + // the list would stay alphabetical even after every peer becomes + // Connected. Once nothing is Connecting we commit and go sticky. const orderRef = useRef([]); + const stickyRef = useRef(false); const ordered = useMemo(() => { - const byKey = new Map(peers.map((p) => [p.pubKey, p])); - const kept = orderRef.current.filter((k) => byKey.has(k)); - const known = new Set(kept); - const fresh = peers - .filter((p) => !known.has(p.pubKey)) - .sort((a, b) => { + const sortOnlineFirst = (list: PeerStatus[]) => + [...list].sort((a, b) => { const aOnline = isOnline(a.connStatus); const bOnline = isOnline(b.connStatus); if (aOnline !== bOnline) return aOnline ? -1 : 1; const aName = (a.fqdn || a.ip).toLowerCase(); const bName = (b.fqdn || b.ip).toLowerCase(); return aName.localeCompare(bName); - }) - .map((p) => p.pubKey); + }); + + // Reset on empty (Disconnect → reconnect) so the next session + // re-sorts from scratch instead of replaying the stale orderRef. + if (peers.length === 0) { + orderRef.current = []; + stickyRef.current = false; + return []; + } + + if (!stickyRef.current) { + const sorted = sortOnlineFirst(peers); + if (peers.every((p) => p.connStatus !== "Connecting")) { + orderRef.current = sorted.map((p) => p.pubKey); + stickyRef.current = true; + } + return sorted; + } + + const byKey = new Map(peers.map((p) => [p.pubKey, p])); + const kept = orderRef.current.filter((k) => byKey.has(k)); + const known = new Set(kept); + const fresh = sortOnlineFirst(peers.filter((p) => !known.has(p.pubKey))).map( + (p) => p.pubKey, + ); const next = [...kept, ...fresh]; orderRef.current = next; return next.map((k) => byKey.get(k)!); @@ -171,15 +200,12 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => { key={peer.pubKey} onClick={() => setSelected(peer)} className={cn( - "group flex items-start gap-2.5 px-7 py-3 min-w-0 first:mt-2", + "group flex items-start gap-2.5 pl-6 pr-4 py-3 min-w-0 first:mt-2", "hover:bg-nb-gray-900/40 transition-colors", "wails-no-draggable cursor-default", )} > - + {
    - +
    @@ -224,35 +255,3 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => {
); }; - -// Same pattern as TruncatedEmail in ProfileDropdown: render the span with a -// fixed max-width + truncate, measure scrollWidth vs clientWidth after layout, -// and wrap in a Tooltip only when the text actually overflows. Avoids the -// "tooltip on hover even though everything fits" annoyance. -const TruncatedName = ({ name }: { name: string }) => { - const ref = useRef(null); - const [overflowing, setOverflowing] = useState(false); - - useLayoutEffect(() => { - const el = ref.current; - if (!el) return; - setOverflowing(el.scrollWidth > el.clientWidth); - }, [name]); - - const span = ( - - {name} - - ); - if (!overflowing) return span; - return ( - - {span} - - ); -}; diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json index f7586e3c0..492bf3c3c 100644 --- a/client/ui/i18n/locales/de/common.json +++ b/client/ui/i18n/locales/de/common.json @@ -355,7 +355,6 @@ "peers.status.disconnected": "Getrennt", "peers.details.relayAddress": "Relay", "peers.details.networks": "Ressourcen", - "peers.details.more": "+{count} weitere", "peers.details.relayed": "Relayed", "peers.details.p2p": "P2P", "peers.details.rosenpass": "Rosenpass aktiviert", @@ -368,7 +367,6 @@ "networks.empty.description": "Für diesen Peer wurden keine geleiteten Netzwerke freigegeben.", "networks.selected": "Ausgewählt", "networks.unselected": "Nicht ausgewählt", - "networks.ips.more": "+{count} weitere", "networks.ips.heading": "Aufgelöste IPs", "networks.bulk.selectionCount": "{selected} von {total} aktiv", "networks.bulk.enableAll": "Alle aktivieren", diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json index e830be5e1..564bdf89a 100644 --- a/client/ui/i18n/locales/en/common.json +++ b/client/ui/i18n/locales/en/common.json @@ -371,7 +371,6 @@ "peers.status.disconnected": "Disconnected", "peers.details.relayAddress": "Relay", "peers.details.networks": "Resources", - "peers.details.more": "+{count} more", "peers.details.relayed": "Relayed", "peers.details.p2p": "P2P", "peers.details.rosenpass": "Rosenpass enabled", @@ -384,7 +383,6 @@ "networks.empty.description": "No routed networks have been shared with this peer.", "networks.selected": "Selected", "networks.unselected": "Not selected", - "networks.ips.more": "+{count} more", "networks.ips.heading": "Resolved IPs", "networks.bulk.selectionCount": "{selected} of {total} Active", "networks.bulk.enableAll": "Enable all", diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json index eb55dd61e..2967171bd 100644 --- a/client/ui/i18n/locales/hu/common.json +++ b/client/ui/i18n/locales/hu/common.json @@ -355,7 +355,6 @@ "peers.status.disconnected": "Lecsatlakozva", "peers.details.relayAddress": "Relay", "peers.details.networks": "Erőforrások", - "peers.details.more": "+{count} további", "peers.details.relayed": "Relayed", "peers.details.p2p": "P2P", "peers.details.rosenpass": "Rosenpass engedélyezve", @@ -368,7 +367,6 @@ "networks.empty.description": "Ehhez a társhoz nem osztottak meg útvonalas hálózatokat.", "networks.selected": "Kiválasztva", "networks.unselected": "Nincs kiválasztva", - "networks.ips.more": "+{count} további", "networks.ips.heading": "Feloldott IP-címek", "networks.bulk.selectionCount": "{selected} / {total} aktív", "networks.bulk.enableAll": "Összes engedélyezése",