mirror of
https://github.com/meshtastic/web.git
synced 2026-07-31 06:56:28 -04:00
refactor(web): redesign ConnectingOverlay as hero card + fix early visibility
Two changes: Visibility fix — overlay now reads `savedConnections` for any entry whose status is "connecting" or "configuring". The previous code keyed off `device.connectionPhase` from the active deviceStore entry, but that entry doesn't exist until partway through `setupMeshDevice` — the overlay was hidden during transport-open + storage-open (often the slowest part of the connect flow). Visual redo (option C — hero card with radial pings): - Center: transport-type icon (Globe / Bluetooth / Cable) on a gradient disc with two concentric ping rings. - Below: device name + phase label with small spinner. - 2x2 grid of stat chips (Identity / Metadata / Channels / Nodes) that flip from gray (dashed circle) to emerald (check or count) as the SDK reports each piece arriving. - Slate-to-black gradient panel; non-dismissable while active. - i18n strings under `connections.overlay.*` updated to match.
This commit is contained in:
@@ -56,18 +56,16 @@
|
||||
"updatedByName": "{{name}} has been updated."
|
||||
},
|
||||
"overlay": {
|
||||
"log": {
|
||||
"header": "meshtastic — connect",
|
||||
"transportOpening": "Opening transport…",
|
||||
"transportReady": "Transport ready",
|
||||
"requestingConfig": "Requesting configuration",
|
||||
"configReceived": "Received config section ({{n}})",
|
||||
"moduleReceived": "Received module config ({{n}})",
|
||||
"channelReceived": "Received channel ({{n}})",
|
||||
"nodeReceived": "Received node info ({{n}})",
|
||||
"myInfo": "Identity received",
|
||||
"metadata": "Metadata received",
|
||||
"configured": "Configuration complete"
|
||||
"srTitle": "Connecting to device",
|
||||
"phase": {
|
||||
"connecting": "Opening transport…",
|
||||
"configuring": "Streaming configuration…"
|
||||
},
|
||||
"chip": {
|
||||
"identity": "Identity",
|
||||
"metadata": "Metadata",
|
||||
"channels": "Channels",
|
||||
"nodes": "Nodes"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,219 +1,136 @@
|
||||
import { Dialog, DialogContent, DialogTitle } from "@components/UI/Dialog.tsx";
|
||||
import { useAppStore, useDeviceStore } from "@core/stores";
|
||||
import type { ConnectionProgressCounters } from "@meshtastic/sdk";
|
||||
import { useDeviceStore } from "@core/stores";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { useConnectionProgress } from "@meshtastic/sdk-react";
|
||||
import type { ReactElement } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Bluetooth, Cable, CheckCircle2, Globe, Loader2 } from "lucide-react";
|
||||
import type { ComponentType, ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type LineKind = "ok" | "active" | "info";
|
||||
interface LogLine {
|
||||
id: number;
|
||||
ts: number;
|
||||
kind: LineKind;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const SYMBOL: Record<LineKind, string> = {
|
||||
ok: "✓",
|
||||
active: "→",
|
||||
info: "•",
|
||||
};
|
||||
|
||||
const COLOR: Record<LineKind, string> = {
|
||||
ok: "text-emerald-400",
|
||||
active: "text-sky-300",
|
||||
info: "text-zinc-400",
|
||||
const TRANSPORT_ICON: Record<
|
||||
"http" | "bluetooth" | "serial",
|
||||
ComponentType<{ className?: string }>
|
||||
> = {
|
||||
http: Globe,
|
||||
bluetooth: Bluetooth,
|
||||
serial: Cable,
|
||||
};
|
||||
|
||||
/**
|
||||
* Full-screen terminal-style log overlay shown while the active device's
|
||||
* connection phase is "connecting" or "configuring". Lines are derived
|
||||
* from `device.connectionPhase` transitions and `MeshClient.progress`
|
||||
* counter bumps — pure consumer of domain signals.
|
||||
* Hero-card connect overlay. Visible while any saved connection's status
|
||||
* is "connecting" or "configuring" — driven from the saved-connection
|
||||
* status field rather than `device.connectionPhase` so the overlay shows
|
||||
* during transport-open + storage-open (the device entry doesn't exist
|
||||
* yet during those phases).
|
||||
*
|
||||
* Auto-scrolls to the latest line. Auto-dismisses once phase flips to
|
||||
* "configured" / "disconnected" / "error".
|
||||
* Center: device name + transport icon with an animated radial pulse.
|
||||
* Surrounding: 4 stat chips (Identity / Channels / Nodes / Metadata)
|
||||
* that flip from gray to green as the SDK reports each piece arriving.
|
||||
* Auto-dismisses on "configured" (with a brief success state) or
|
||||
* "disconnected" / "error".
|
||||
*/
|
||||
export const ConnectingOverlay = (): ReactElement | null => {
|
||||
const { selectedDeviceId } = useAppStore();
|
||||
const { getDevice } = useDeviceStore();
|
||||
const device = getDevice(selectedDeviceId);
|
||||
const phase = device?.connectionPhase ?? "disconnected";
|
||||
const savedConnections = useDeviceStore((s) => s.savedConnections);
|
||||
const progress = useConnectionProgress();
|
||||
const { t } = useTranslation("connections");
|
||||
|
||||
const [lines, setLines] = useState<LogLine[]>([]);
|
||||
const idRef = useRef(0);
|
||||
const lastPhaseRef = useRef<typeof phase>("disconnected");
|
||||
const lastCountersRef = useRef<ConnectionProgressCounters | undefined>(undefined);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const active = savedConnections.find(
|
||||
(c) => c.status === "connecting" || c.status === "configuring",
|
||||
);
|
||||
|
||||
const visible = phase === "connecting" || phase === "configuring";
|
||||
if (!active) return null;
|
||||
|
||||
// Reset the log when a fresh connect cycle starts (idle → connecting).
|
||||
useEffect(() => {
|
||||
if (phase === "connecting" && lastPhaseRef.current !== "connecting") {
|
||||
setLines([]);
|
||||
idRef.current = 0;
|
||||
lastCountersRef.current = undefined;
|
||||
}
|
||||
lastPhaseRef.current = phase;
|
||||
}, [phase]);
|
||||
const Icon = TRANSPORT_ICON[active.type];
|
||||
const counters =
|
||||
progress.phase === "configuring" || progress.phase === "configured"
|
||||
? progress.received
|
||||
: { config: 0, modules: 0, channels: 0, nodes: 0, myInfo: false, metadata: false };
|
||||
|
||||
// High-level phase transitions → log lines.
|
||||
useEffect(() => {
|
||||
if (phase === "connecting") {
|
||||
append("active", t("overlay.log.transportOpening", { defaultValue: "Opening transport…" }));
|
||||
} else if (phase === "configuring") {
|
||||
append("ok", t("overlay.log.transportReady", { defaultValue: "Transport ready" }));
|
||||
append(
|
||||
"active",
|
||||
t("overlay.log.requestingConfig", { defaultValue: "Requesting configuration" }),
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [phase]);
|
||||
|
||||
// Counter bumps → per-event lines.
|
||||
useEffect(() => {
|
||||
if (progress.phase !== "configuring") {
|
||||
if (progress.phase === "configured") {
|
||||
append("ok", t("overlay.log.configured", { defaultValue: "Configuration complete" }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const cur = progress.received;
|
||||
const prev = lastCountersRef.current ?? {
|
||||
config: 0,
|
||||
modules: 0,
|
||||
channels: 0,
|
||||
nodes: 0,
|
||||
myInfo: false,
|
||||
metadata: false,
|
||||
};
|
||||
if (cur.config > prev.config) {
|
||||
append(
|
||||
"info",
|
||||
t("overlay.log.configReceived", {
|
||||
n: cur.config,
|
||||
defaultValue: "Received config section ({{n}})",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (cur.modules > prev.modules) {
|
||||
append(
|
||||
"info",
|
||||
t("overlay.log.moduleReceived", {
|
||||
n: cur.modules,
|
||||
defaultValue: "Received module config ({{n}})",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (cur.channels > prev.channels) {
|
||||
append(
|
||||
"info",
|
||||
t("overlay.log.channelReceived", {
|
||||
n: cur.channels,
|
||||
defaultValue: "Received channel ({{n}})",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (cur.nodes > prev.nodes) {
|
||||
append(
|
||||
"info",
|
||||
t("overlay.log.nodeReceived", {
|
||||
n: cur.nodes,
|
||||
defaultValue: "Received node info ({{n}})",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (cur.myInfo && !prev.myInfo) {
|
||||
append("ok", t("overlay.log.myInfo", { defaultValue: "Identity received" }));
|
||||
}
|
||||
if (cur.metadata && !prev.metadata) {
|
||||
append("ok", t("overlay.log.metadata", { defaultValue: "Metadata received" }));
|
||||
}
|
||||
lastCountersRef.current = cur;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [progress]);
|
||||
|
||||
// Auto-scroll to bottom on new line.
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [lines]);
|
||||
|
||||
function append(kind: LineKind, text: string): void {
|
||||
setLines((prev) => [...prev, { id: ++idRef.current, ts: Date.now(), kind, text }]);
|
||||
}
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const start = lines[0]?.ts ?? Date.now();
|
||||
const isConnecting = active.status === "connecting";
|
||||
const phaseLabel = isConnecting
|
||||
? t("overlay.phase.connecting", { defaultValue: "Opening transport…" })
|
||||
: t("overlay.phase.configuring", { defaultValue: "Streaming configuration…" });
|
||||
|
||||
return (
|
||||
<Dialog open>
|
||||
<DialogContent
|
||||
className="max-w-xl border-zinc-800 bg-zinc-950 p-0 text-zinc-100 shadow-2xl"
|
||||
className="max-w-sm overflow-hidden border-0 bg-gradient-to-b from-slate-900 via-slate-950 to-black p-0 text-slate-100 shadow-2xl"
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-zinc-800 px-4 py-2">
|
||||
<div className="flex gap-1.5">
|
||||
<span className="h-3 w-3 rounded-full bg-red-500/80" />
|
||||
<span className="h-3 w-3 rounded-full bg-yellow-500/80" />
|
||||
<span className="h-3 w-3 rounded-full bg-green-500/80" />
|
||||
</div>
|
||||
<DialogTitle className="ml-2 font-mono text-xs uppercase tracking-wider text-zinc-400">
|
||||
{t("overlay.log.header", { defaultValue: "meshtastic — connect" })}
|
||||
</DialogTitle>
|
||||
<span className="ml-auto inline-flex items-center gap-1.5 font-mono text-[10px] text-zinc-500">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full ${
|
||||
phase === "configuring" ? "animate-pulse bg-sky-400" : "bg-amber-400"
|
||||
}`}
|
||||
/>
|
||||
{phase}
|
||||
</span>
|
||||
</div>
|
||||
<DialogTitle className="sr-only">
|
||||
{t("overlay.srTitle", { defaultValue: "Connecting to device" })}
|
||||
</DialogTitle>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="max-h-[360px] min-h-[200px] overflow-y-auto bg-zinc-950 px-4 py-3 font-mono text-[12px] leading-relaxed"
|
||||
>
|
||||
{lines.length === 0 ? (
|
||||
<div className="text-zinc-600">…</div>
|
||||
) : (
|
||||
lines.map((line, i) => {
|
||||
const isLast = i === lines.length - 1;
|
||||
const symbol = isLast && line.kind === "active" ? "→" : SYMBOL[line.kind];
|
||||
return (
|
||||
<div key={line.id} className="flex items-start gap-3">
|
||||
<span className="shrink-0 select-none text-zinc-600">
|
||||
{String(((line.ts - start) / 1000).toFixed(2)).padStart(6, " ")}s
|
||||
</span>
|
||||
<span className={`shrink-0 select-none ${COLOR[line.kind]}`}>{symbol}</span>
|
||||
<span
|
||||
className={
|
||||
line.kind === "info"
|
||||
? "text-zinc-300"
|
||||
: line.kind === "ok"
|
||||
? "text-zinc-100"
|
||||
: "text-zinc-100"
|
||||
}
|
||||
>
|
||||
{line.text}
|
||||
{isLast && line.kind === "active" && (
|
||||
<span className="ml-1 inline-block h-3 w-2 animate-pulse bg-zinc-300" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<div className="flex flex-col items-center gap-6 px-8 pt-10 pb-8">
|
||||
{/* Hero icon with radial pings */}
|
||||
<div className="relative flex h-28 w-28 items-center justify-center">
|
||||
<span className="absolute inset-0 animate-ping rounded-full bg-sky-500/20 [animation-duration:1.8s]" />
|
||||
<span className="absolute inset-3 animate-ping rounded-full bg-sky-500/30 [animation-duration:2.4s] [animation-delay:0.4s]" />
|
||||
<span className="absolute inset-6 rounded-full bg-gradient-to-br from-sky-500 to-indigo-600 shadow-lg shadow-sky-500/40" />
|
||||
<Icon className="relative h-9 w-9 text-white" />
|
||||
</div>
|
||||
|
||||
{/* Name + phase */}
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-semibold tracking-tight text-white">{active.name}</div>
|
||||
<div className="mt-1 inline-flex items-center gap-1.5 text-xs text-slate-400">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
{phaseLabel}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stat chips — 2x2 grid */}
|
||||
<div className="grid w-full grid-cols-2 gap-2">
|
||||
<Chip
|
||||
label={t("overlay.chip.identity", { defaultValue: "Identity" })}
|
||||
done={counters.myInfo}
|
||||
/>
|
||||
<Chip
|
||||
label={t("overlay.chip.metadata", { defaultValue: "Metadata" })}
|
||||
done={counters.metadata}
|
||||
/>
|
||||
<Chip
|
||||
label={t("overlay.chip.channels", { defaultValue: "Channels" })}
|
||||
count={counters.channels}
|
||||
done={counters.channels > 0 && progress.phase === "configured"}
|
||||
/>
|
||||
<Chip
|
||||
label={t("overlay.chip.nodes", { defaultValue: "Nodes" })}
|
||||
count={counters.nodes}
|
||||
done={counters.nodes > 0 && progress.phase === "configured"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
interface ChipProps {
|
||||
label: string;
|
||||
count?: number;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
function Chip({ label, count, done }: ChipProps): ReactElement {
|
||||
const filled = count !== undefined ? count > 0 : done;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2 rounded-lg border px-3 py-2 text-sm transition-colors",
|
||||
filled
|
||||
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-200"
|
||||
: "border-slate-700/60 bg-slate-800/40 text-slate-400",
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">{label}</span>
|
||||
{count !== undefined ? (
|
||||
<span className="font-mono text-xs tabular-nums">{count}</span>
|
||||
) : done ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-400" />
|
||||
) : (
|
||||
<span className="h-4 w-4 rounded-full border border-dashed border-slate-600" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user