diff --git a/frontend/src/components/ui/EditableText.jsx b/frontend/src/components/ui/EditableText.jsx new file mode 100644 index 0000000..3f09b47 --- /dev/null +++ b/frontend/src/components/ui/EditableText.jsx @@ -0,0 +1,123 @@ +// src/components/ui/EditableText.jsx +// Inline hover-to-edit text — shows a pencil icon on hover, click swaps to a +// text input in place. Enter/blur saves, Escape cancels. Use for a single +// display value that's editable in-place without a modal (e.g. a page hero +// title) — for anything with more than one field, use a Modal instead. +// +// Props: +// value — string — current display value +// placeholder — string — shown (italic, muted) when value is empty +// onSave — (newValue) => void | Promise — called on confirm; not called if unchanged +// editable — bool — hide the pencil / disable editing entirely (default: true) +// textStyle — style object applied to the display span AND the input, so +// swapping between them doesn't shift surrounding layout +// className — extra class on the root element +// inputAriaLabel — string — accessible label for the input while editing + +import { useState, useRef, useEffect } from 'react' +import Icon from '@/components/ui/Icon' + +export default function EditableText({ + value, + placeholder = 'Untitled', + onSave, + editable = true, + textStyle = {}, + className = '', + inputAriaLabel = 'Edit value', +}) { + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState(value || '') + const [saving, setSaving] = useState(false) + const inputRef = useRef(null) + + useEffect(() => { + if (editing) { + setDraft(value || '') + // Wait a tick so the input exists before focusing. + requestAnimationFrame(() => inputRef.current?.select()) + } + }, [editing, value]) + + const commit = async () => { + const trimmed = draft.trim() + setEditing(false) + if (trimmed === (value || '')) return + setSaving(true) + try { + await onSave?.(trimmed) + } finally { + setSaving(false) + } + } + + const cancel = () => { + setDraft(value || '') + setEditing(false) + } + + if (editing) { + return ( + setDraft(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === 'Enter') { e.preventDefault(); commit() } + if (e.key === 'Escape') { e.preventDefault(); cancel() } + }} + aria-label={inputAriaLabel} + disabled={saving} + className={className} + style={{ + ...textStyle, + background: 'var(--color-bg-elevated)', + border: '1px solid var(--color-border-strong)', + borderRadius: 'var(--radius-sm)', + padding: '1px 6px', + outline: 'none', + minWidth: '120px', + }} + /> + ) + } + + return ( + + + {value || placeholder} + + {editable && ( + + )} + + ) +} diff --git a/frontend/src/components/ui/LineChart.jsx b/frontend/src/components/ui/LineChart.jsx new file mode 100644 index 0000000..4ca857b --- /dev/null +++ b/frontend/src/components/ui/LineChart.jsx @@ -0,0 +1,514 @@ +// src/components/ui/LineChart.jsx +// Time-series line chart — hand-rolled SVG, no charting library. Single primitive +// reused for every metric-over-time graph in the console (RSSI, ping latency, +// free heap, etc). Never build a one-off inline chart in a page — use this. +// +// Props: +// data — array<{ x: number|string (ms epoch or index), y: number|null }> +// null y-values render as gaps (device offline / no sample), not zeros. +// color — CSS color token, e.g. 'var(--color-primary)' (default: 'var(--color-primary)') +// Ignored if colorStops is set. +// colorStops — array<{ value: number, color: string }>, sorted ascending by +// value — colors the line/fill as a smooth vertical gradient +// keyed to the Y VALUE (not x-position), so "danger red" always +// means the same y-value everywhere on the chart regardless of +// when it occurred. Requires >= 2 stops. +// height — number, px (default: 160) +// formatValue — (y) => string — used in tooltip + trailing value badge +// formatX — (x) => string — used in tooltip +// unit — string appended after formatValue in the trailing badge, e.g. 'dBm', 'ms', 'KB' +// xMin, xMax — number (ms epoch) — explicit axis bounds. When provided, the +// chart renders this FULL span even if data covers less of it +// (e.g. a 30-day range with 1 day of real data shows the data +// bunched at one end, not silently zoomed to fit). Omit to +// auto-fit to the data's own x-range (previous behaviour). +// gapThresholdMs — if the x-gap between two consecutive points exceeds this, +// the segment break is drawn as a dotted "possibly offline" +// connector instead of a silent gap. Omit to just break silently. +// offlineWindows — array<{ start: number, end: number }> (ms epoch) — explicit +// "known offline" windows to render as dotted baseline +// regardless of this chart's own data gaps. Use this instead +// of (or alongside) gapThresholdMs when offline periods are +// computed once from a shared/authoritative signal (e.g. the +// device's heartbeat cadence) and applied identically across +// several charts whose own series may be too sparse for +// per-series gap detection to find the same windows — +// otherwise a chart fed from a low-frequency series (ping, +// diagnostics) can under- or over-detect gaps relative to one +// fed from a high-frequency series (heartbeats) even though +// both describe the same device over the same real outage. +// Always rendered in the 'baseline' style (see gapStyle) +// since these are known facts, not inferred from this +// chart's own two neighboring points. +// gapStyle — 'connector' | 'baseline' (default: 'connector') +// 'connector' draws the dotted line directly between the last point +// before the gap and the first point after it (diagonal if their +// y-values differ). 'baseline' instead draws it as a flat dotted +// line along the chart's bottom edge, spanning the same x-range — +// reads as "no data" rather than implying a value in between. +// emptyLabel — string shown when data has no non-null points +// className — extra classes on the root element + +import { useMemo, useState, useRef, useId } from 'react' + +const PAD = { top: 12, right: 12, bottom: 22, left: 8 } +// Above this many raw points, downsample to ~2 points (min+max) per pixel +// column so spikes survive being zoomed out over a wide time range instead +// of getting smoothed away by naive decimation. +const DOWNSAMPLE_THRESHOLD = 400 + +// Centripetal Catmull-Rom → cubic Bezier. Using each segment's actual +// Euclidean distance (not a fixed 1/6 fraction) keeps tangents proportional +// to real point spacing — with the naive uniform version, a run of tightly- +// packed points followed by a big gap produces an overshooting tangent that +// visibly loops the curve backward before continuing forward. +function clampToRange(v, a, b) { + return Math.min(Math.max(v, Math.min(a, b)), Math.max(a, b)) +} + +function smoothPath(seg) { + if (seg.length < 2) return `M${seg[0].px},${seg[0].py}` + if (seg.length === 2) return `M${seg[0].px},${seg[0].py}L${seg[1].px},${seg[1].py}` + + const dist = (a, b) => Math.max(1e-3, Math.hypot(b.px - a.px, b.py - a.py)) + const n = seg.length + + let d = `M${seg[0].px},${seg[0].py}` + for (let i = 0; i < n - 1; i++) { + const p0 = seg[i - 1] || seg[i] + const p1 = seg[i] + const p2 = seg[i + 1] + const p3 = seg[i + 2] || p2 + + const d1 = dist(p0, p1) + const d2 = dist(p1, p2) + const d3 = dist(p2, p3) + + // Tangent at p1, scaled by the ratio of the adjacent segment length to + // the sum of both neighboring segments — this is what makes it + // "centripetal": short segments pull the tangent in, long ones don't + // over-extend it. + const t1 = d1 / (d1 + d2) + let c1x = p1.px + (p2.px - p0.px) * (t1 / 3) + const c1y = p1.py + (p2.py - p0.py) * (t1 / 3) + + const t2 = d2 / (d2 + d3) + let c2x = p2.px - (p3.px - p1.px) * ((1 - t2) / 3) + const c2y = p2.py - (p3.py - p1.py) * ((1 - t2) / 3) + + // X is always time in these charts, so it must be monotonic — a cubic + // segment whose control point x strays outside its own [p1.x, p2.x] span + // renders as the line visibly reversing direction (looping backward) + // before continuing forward. This happens whenever a neighboring point + // pulls the tangent estimate too far: most commonly right at a segment's + // open boundary (next to a data gap, where there's no further point to + // temper the tangent), and also wherever the min/max-per-bucket + // downsampler emits two points with nearly identical x — a near-vertical + // spike that the same tangent math overshoots on either side of. Clamping + // x keeps every curve horizontally monotonic without altering its y + // shape, which is what removes the loop while leaving normal smoothing + // (evenly spaced points, no reordering) completely unaffected. + c1x = clampToRange(c1x, p1.px, p2.px) + c2x = clampToRange(c2x, p1.px, p2.px) + + d += `C${c1x},${c1y} ${c2x},${c2y} ${p2.px},${p2.py}` + } + return d +} + +function buildSegments(points) { + // Splits into contiguous segments at null gaps — each becomes its own path. + const segments = [] + let current = [] + for (const p of points) { + if (p.y == null) { + if (current.length) segments.push(current) + current = [] + } else { + current.push(p) + } + } + if (current.length) segments.push(current) + return segments +} + +// Min/max-per-pixel-bucket downsampling. Guarantees any spike/dip within a +// bucket survives (both the min AND max point are kept), unlike averaging +// which can smooth a brief extreme out of existence when zoomed out over a +// wide range. Null (gap) points pass through untouched — they carry their +// own meaning and must never be merged into a bucket. +function downsample(points, targetBuckets) { + if (points.length <= targetBuckets * 2) return points + + const validXs = points.filter(p => p.y != null).map(p => p.x) + if (validXs.length === 0) return points + const xMin = Math.min(...validXs) + const xMax = Math.max(...validXs) + const span = Math.max(1, xMax - xMin) + + const buckets = new Map() + for (const p of points) { + if (p.y == null) continue + const bucketIdx = Math.min(targetBuckets - 1, Math.floor(((p.x - xMin) / span) * targetBuckets)) + const existing = buckets.get(bucketIdx) + if (!existing) { + buckets.set(bucketIdx, { min: p, max: p }) + } else { + if (p.y < existing.min.y) existing.min = p + if (p.y > existing.max.y) existing.max = p + } + } + + // Each bucket contributes its min and max point (the same point twice if + // only one sample fell in that bucket) — order doesn't matter here since + // gap markers are merged back in and everything is sorted by x afterward. + const flattened = [] + for (const { min, max } of buckets.values()) { + flattened.push(min) + if (max !== min) flattened.push(max) + } + const gaps = points.filter(p => p.y == null) + return [...flattened, ...gaps].sort((a, b) => a.x - b.x) +} + +export default function LineChart({ + data = [], + color = 'var(--color-primary)', + colorStops = null, + height = 160, + formatValue = v => String(v), + formatX = x => String(x), + unit = '', + xMin: xMinProp = null, + xMax: xMaxProp = null, + gapThresholdMs = null, + offlineWindows = null, + gapStyle = 'connector', + emptyLabel = 'No data yet.', + className = '', +}) { + const containerRef = useRef(null) + const [width, setWidth] = useState(600) + const [hoverIdx, setHoverIdx] = useState(null) + const gradientId = useId().replace(/[^a-zA-Z0-9_-]/g, '') + + // Measure container width responsively — ResizeObserver is the only + // chart-sizing mechanism in the app. + const measureRef = el => { + containerRef.current = el + if (!el) return + const ro = new ResizeObserver(entries => { + const w = entries[0]?.contentRect?.width + if (w) setWidth(w) + }) + ro.observe(el) + el._ro = ro + } + + const validPoints = data.filter(d => d.y != null) + const hasData = validPoints.length > 0 + + const { points, yMin, yMax, xMin, xMax } = useMemo(() => { + if (!hasData) return { points: [], yMin: 0, yMax: 1, xMin: 0, xMax: 1 } + + const xs = data.map(d => Number(d.x)) + const ys = validPoints.map(d => Number(d.y)) + // Explicit range props win — the chart must render the FULL requested + // span (e.g. "last 30 days") even when real data only covers a sliver of + // it, rather than silently auto-zooming to whatever data happens to exist. + let xMin = xMinProp != null ? Number(xMinProp) : Math.min(...xs) + let xMax = xMaxProp != null ? Number(xMaxProp) : Math.max(...xs) + let yMin = Math.min(...ys) + let yMax = Math.max(...ys) + // colorStops are value-anchored on an absolute scale (e.g. alert + // thresholds), not the data's own range — if the y-axis only auto-fit to + // the data, stops outside that range would clamp to the same 0%/100% + // gradient offset and collapse into a hard edge instead of a smooth + // blend. Widening yMin/yMax to cover every stop keeps the full gradient + // visible even when the data currently sits well inside a safe band. + if (colorStops && colorStops.length) { + const stopValues = colorStops.map(s => s.value) + yMin = Math.min(yMin, ...stopValues) + yMax = Math.max(yMax, ...stopValues) + } + + if (xMin === xMax) { xMin -= 1; xMax += 1 } + if (yMin === yMax) { yMin -= 1; yMax += 1 } + // Headroom so the line/dots never touch the plot edge. + const yPad = (yMax - yMin) * 0.12 + yMin -= yPad + yMax += yPad + + const innerW = Math.max(1, width - PAD.left - PAD.right) + const innerH = Math.max(1, height - PAD.top - PAD.bottom) + + let mapped = data.map(d => { + const x = Number(d.x) + const px = PAD.left + ((x - xMin) / (xMax - xMin)) * innerW + const py = d.y == null ? null : PAD.top + innerH - ((Number(d.y) - yMin) / (yMax - yMin)) * innerH + return { x, y: d.y, px, py: py ?? 0 } + }) + // Drop points that fall outside the explicit range entirely (can happen + // if a caller passes data slightly wider than xMin/xMax). + mapped = mapped.filter(p => p.x >= xMin - 1 && p.x <= xMax + 1) + + // Cut every real segment at explicit offline-window boundaries — + // offlineWindows is computed externally (e.g. from a denser series like + // heartbeats) and can disagree with THIS series' own point spacing. Two + // distinct cases both need handling, not just one: + // 1. A real sample happens to fall inside the window (mask it to null). + // 2. No sample at all falls inside a narrow window in a sparse series + // (ping every 60s, diagnostics every 5min) — the point just before + // and just after the window are still adjacent in the array with + // nothing between them, so without inserting explicit boundary gap + // markers, smoothPath() draws one continuous curve straight across + // the "offline" stretch, right on top of the dotted connector. + // Inserting a null-y marker at both edges of the window forces + // buildSegments() to break there even when case 2 applies. + if (offlineWindows && offlineWindows.length) { + mapped = mapped.map(p => { + if (p.y == null) return p + const inWindow = offlineWindows.some(w => p.x >= w.start && p.x <= w.end) + return inWindow ? { ...p, y: null } : p + }) + const boundaryMarkers = [] + for (const w of offlineWindows) { + const spansWindow = mapped.some(p => p.x < w.start) && mapped.some(p => p.x > w.end) + if (!spansWindow) continue + for (const x of [w.start, w.end]) { + if (x < xMin || x > xMax) continue + const px = PAD.left + ((x - xMin) / (xMax - xMin)) * innerW + boundaryMarkers.push({ x, y: null, px, py: 0 }) + } + } + if (boundaryMarkers.length) { + mapped = [...mapped, ...boundaryMarkers].sort((a, b) => a.x - b.x) + } + } + + const targetBuckets = Math.max(50, Math.ceil(width / 3)) + if (mapped.length > DOWNSAMPLE_THRESHOLD) { + mapped = downsample(mapped, targetBuckets) + } + + return { points: mapped, yMin, yMax, xMin, xMax } + }, [data, width, height, hasData, validPoints, xMinProp, xMaxProp, colorStops, offlineWindows]) + + if (!hasData) { + return ( +
+ {emptyLabel} +
+ ) + } + + const segments = buildSegments(points) + const innerW = Math.max(1, width - PAD.left - PAD.right) + const innerH = Math.max(1, height - PAD.top - PAD.bottom) + const baseline = PAD.top + innerH + + const hovered = hoverIdx != null ? points[hoverIdx] : null + const latest = [...points].reverse().find(p => p.y != null) + + // colorStops → an SVG linearGradient keyed to Y VALUE. gradientUnits + // userSpaceOnUse + y1/y2 in pixel-space (not objectBoundingBox) is what + // makes "red" always mean the same underlying value across every chart, + // rather than always meaning "top of whatever this chart's own range is." + const strokeColor = colorStops ? `url(#${gradientId})` : color + const sortedStops = colorStops ? [...colorStops].sort((a, b) => a.value - b.value) : null + + // Dotted "possibly offline" connectors — drawn between the last point of one + // segment and the first point of the next, only when the real x-gap between + // them exceeds gapThresholdMs. A shorter gap (e.g. one missed 30s heartbeat) + // is just a silent break; a longer one is worth flagging visually. + // gapStyle 'baseline' flattens the connector to the chart's bottom edge + // instead of a diagonal between the two real y-values — a straight line + // at the actual readings would visually imply an interpolated value that + // was never measured, which is misleading for a gap that means "offline." + const offlineConnectors = [] + if (gapThresholdMs) { + for (let i = 0; i < segments.length - 1; i++) { + const end = segments[i][segments[i].length - 1] + const start = segments[i + 1][0] + if (start.x - end.x > gapThresholdMs) { + if (gapStyle === 'baseline') { + offlineConnectors.push({ x1: end.px, y1: baseline, x2: start.px, y2: baseline }) + } else { + offlineConnectors.push({ x1: end.px, y1: end.py, x2: start.px, y2: start.py }) + } + } + } + } + + // Explicit offline windows (computed once, shared across charts — see the + // prop doc above) mapped straight into this chart's own pixel space using + // the same xMin/xMax/innerW as the data points, and always drawn along the + // baseline — these are known facts about the device/console, not something + // inferred from this particular series' own two neighboring points, so they + // render even in a stretch where this series happens to have no points at + // all on either side of the window. + if (offlineWindows && offlineWindows.length) { + const toPx = x => PAD.left + ((x - xMin) / (xMax - xMin)) * innerW + for (const w of offlineWindows) { + const clampedStart = Math.max(w.start, xMin) + const clampedEnd = Math.min(w.end, xMax) + if (clampedEnd <= clampedStart) continue + offlineConnectors.push({ x1: toPx(clampedStart), y1: baseline, x2: toPx(clampedEnd), y2: baseline }) + } + } + + const handleMove = e => { + if (!containerRef.current) return + const rect = containerRef.current.getBoundingClientRect() + const relX = e.clientX - rect.left + // Nearest point by px distance. + let nearest = 0 + let nearestDist = Infinity + points.forEach((p, i) => { + const d = Math.abs(p.px - relX) + if (d < nearestDist) { nearestDist = d; nearest = i } + }) + setHoverIdx(nearest) + } + + // Gridlines: 3 horizontal bands (min/mid/max) — recessive, label-free except axis text. + const gridY = [yMin + (yMax - yMin) * 0.0, yMin + (yMax - yMin) * 0.5, yMin + (yMax - yMin) * 1.0] + + const legendColor = sortedStops + ? (latest ? sortedStops.reduce((closest, s) => Math.abs(s.value - latest.y) < Math.abs(closest.value - latest.y) ? s : closest, sortedStops[0]).color : sortedStops[0].color) + : color + + return ( +
setHoverIdx(null)} + > + {/* Trailing value badge — top-right, current/most-recent reading */} + {latest && ( +
+ {formatValue(latest.y)}{unit} +
+ )} + + + {sortedStops && ( + + + {sortedStops.map((s, i) => { + const py = PAD.top + innerH - ((s.value - yMin) / (yMax - yMin)) * innerH + const offset = Math.max(0, Math.min(1, (py - PAD.top) / innerH)) + // Gradient offsets run top(1)→bottom(0) in our y1/y2 direction + // (y1 at bottom = offset 0), so invert: a stop's pixel-y needs + // to map to (1 - offset-from-top). + return + })} + + + )} + + {/* Gridlines */} + {gridY.map((gy, i) => { + const py = PAD.top + innerH - ((gy - yMin) / (yMax - yMin)) * innerH + return ( + + ) + })} + + {/* Area fill under each segment — same color/gradient as the line, + low opacity, closed down to the plot baseline. Each fill path is + its own closed shape per segment, so a gap never produces a + spurious rectangle spanning the whole chart. */} + {segments.map((seg, i) => { + if (seg.length < 2) return null + const curve = smoothPath(seg) + const fillPath = `${curve}L${seg[seg.length - 1].px},${baseline}L${seg[0].px},${baseline}Z` + return + })} + + {/* Dotted connectors across long gaps — "device likely offline" cue, + drawn under the solid segments so segment ends stay crisp on top. */} + {offlineConnectors.map((c, i) => ( + + ))} + + {/* Line segments (smoothed, gaps at null y) */} + {segments.map((seg, i) => ( + + ))} + + {/* Rounded data-end on the most recent point */} + {latest && ( + + )} + + {/* Hover crosshair + marker */} + {hovered && ( + + + {hovered.y != null && ( + + )} + + )} + + {/* X-axis endpoints — always the resolved range bounds, not just + whatever the first/last real sample happens to be, so the axis + reflects the selected time range even when data is sparse. */} + + {formatX(xMin)} + + + {formatX(xMax)} + + + + {/* Tooltip */} + {hovered && ( +
+
{formatX(hovered.x)}
+
+ {hovered.y != null ? `${formatValue(hovered.y)}${unit}` : 'No data'} +
+
+ )} +
+ ) +} diff --git a/frontend/src/components/ui/MultiSelect.jsx b/frontend/src/components/ui/MultiSelect.jsx new file mode 100644 index 0000000..505f519 --- /dev/null +++ b/frontend/src/components/ui/MultiSelect.jsx @@ -0,0 +1,150 @@ +// src/components/ui/MultiSelect.jsx +// Multi-select dropdown — same visual language as Select.jsx (floating portal +// menu, .select-* trigger styling) but with checkbox rows so multiple options +// can be chosen at once. Trigger shows "All", a single label, or "N selected". +// +// Props: +// options — Array<{ value: string, label: ReactNode }> +// value — Array — currently selected values (empty = "all") +// onChange — (nextValues: string[]) => void +// placeholder — shown when value is empty (default: 'All') +// disabled — boolean +// className — extra classes on the trigger button +// id — forwarded for label association + +import { useState, useRef, useEffect } from 'react' +import { createPortal } from 'react-dom' +import { computeFloatingMenuPosition } from '@/lib/floatingMenuPosition' + +export default function MultiSelect({ + options = [], + value = [], + onChange, + placeholder = 'All', + disabled = false, + className = '', + id, + ...props +}) { + const [open, setOpen] = useState(false) + const [menuPos, setMenuPos] = useState({ top: 0, left: 0, width: 0, maxHeight: 280 }) + const triggerRef = useRef(null) + const menuRef = useRef(null) + + useEffect(() => { + if (!open) return + function close(e) { + if (triggerRef.current?.contains(e.target)) return + if (menuRef.current?.contains(e.target)) return + setOpen(false) + } + function closeOnScroll(e) { + if (menuRef.current?.contains(e.target)) return + if (triggerRef.current?.contains(e.target)) return + setOpen(false) + } + document.addEventListener('mousedown', close) + document.addEventListener('scroll', closeOnScroll, true) + return () => { + document.removeEventListener('mousedown', close) + document.removeEventListener('scroll', closeOnScroll, true) + } + }, [open]) + + function openMenu() { + if (disabled) return + if (triggerRef.current) { + setMenuPos(computeFloatingMenuPosition(triggerRef.current.getBoundingClientRect())) + } + setOpen(true) + } + + function handleToggle() { + if (open) setOpen(false) + else openMenu() + } + + function toggleValue(v) { + const next = value.includes(v) ? value.filter((x) => x !== v) : [...value, v] + onChange?.(next) + } + + const label = value.length === 0 + ? placeholder + : value.length === 1 + ? (options.find((o) => o.value === value[0])?.label ?? value[0]) + : `${value.length} selected` + + const menu = open && createPortal( +
+ {value.length > 0 && ( +
{ e.preventDefault(); onChange?.([]) }} + > + Clear selection +
+ )} + {options.map((opt) => { + const checked = value.includes(opt.value) + return ( +
{ e.preventDefault(); toggleValue(opt.value) }} + > + + {opt.label} +
+ ) + })} +
, + document.body + ) + + return ( + <> + + {menu} + + ) +} diff --git a/frontend/src/components/ui/PressHoldButton.jsx b/frontend/src/components/ui/PressHoldButton.jsx new file mode 100644 index 0000000..3c976da --- /dev/null +++ b/frontend/src/components/ui/PressHoldButton.jsx @@ -0,0 +1,124 @@ +// src/components/ui/PressHoldButton.jsx +// A button that requires holding it down for a fixed duration to confirm, +// rather than a single click — for destructive actions where a misclick +// should not be able to trigger the action (a slip releases instantly and +// does nothing). Renders a fill sweeping left-to-right as visual progress. +// +// Props: +// holdMs — number — how long the button must be held, in ms (default: 3000) +// onConfirm — () => void — called once the hold completes +// variant — 'danger' | 'primary' (default: 'danger') +// size — 'sm' | 'md' | 'lg' (default: 'md') +// disabled — boolean +// children — ReactNode — label shown when not held +// holdingLabel — ReactNode — label shown while holding (falls back to children) +// className — extra classes + +import { useRef, useState, useCallback } from 'react' + +export default function PressHoldButton({ + holdMs = 3000, + onConfirm, + variant = 'danger', + size = 'md', + disabled = false, + children, + holdingLabel, + className = '', +}) { + const [progress, setProgress] = useState(0) // 0–100 + const [holding, setHolding] = useState(false) + const rafRef = useRef(null) + const startRef = useRef(0) + + const stop = useCallback(() => { + if (rafRef.current) cancelAnimationFrame(rafRef.current) + rafRef.current = null + setHolding(false) + setProgress(0) + }, []) + + // Named function expression — `tickFrame` inside the body refers to the + // function itself via its own name, not the outer `tick` const binding, + // so there's no self-reference-before-declaration issue on the recursive + // requestAnimationFrame call. + const tick = useCallback(function tickFrame() { + const elapsed = Date.now() - startRef.current + const pct = Math.min(100, (elapsed / holdMs) * 100) + setProgress(pct) + if (pct >= 100) { + setHolding(false) + setProgress(0) + onConfirm?.() + return + } + rafRef.current = requestAnimationFrame(tickFrame) + }, [holdMs, onConfirm]) + + const start = useCallback(() => { + if (disabled) return + startRef.current = Date.now() + setHolding(true) + rafRef.current = requestAnimationFrame(tick) + }, [disabled, tick]) + + const color = variant === 'danger' ? 'var(--color-danger)' : 'var(--color-primary)' + const bg = variant === 'danger' ? 'var(--color-danger-bg)' : 'var(--color-primary-subtle)' + + const sizeStyles = { + sm: { height: 32, paddingInline: 'var(--space-3)', fontSize: 'var(--font-size-xs)' }, + md: { height: 38, paddingInline: 'var(--space-4)', fontSize: 'var(--font-size-sm)' }, + lg: { height: 44, paddingInline: 'var(--space-5)', fontSize: 'var(--font-size-base)' }, + }[size] || {} + + return ( + + ) +} diff --git a/frontend/src/components/ui/SignalIndicator.jsx b/frontend/src/components/ui/SignalIndicator.jsx new file mode 100644 index 0000000..0d028c3 --- /dev/null +++ b/frontend/src/components/ui/SignalIndicator.jsx @@ -0,0 +1,90 @@ +// src/components/ui/SignalIndicator.jsx +// Wi-Fi signal-strength glyph — concentric arcs + dot, filled by RSSI (dBm). +// Never use a raw icon/emoji for signal strength — always use SignalIndicator. +// +// Props: +// rssi — number | null | undefined — signal strength in dBm (e.g. -62). null/undefined = unknown. +// size — 'sm' | 'md' (default: 'md') +// className — extra classes +// +// RSSI → level (4 levels: dot + 3 arcs, lit from the center outward): +// > -50 dBm → Excellent (4/4 lit), var(--color-success) +// >= -60 dBm → Good (3/4 lit), var(--color-info) +// >= -70 dBm → Fair (2/4 lit), var(--color-warning) +// < -70 dBm → Weak (1/4 lit), var(--color-danger) +// null/undefined → 0/4 lit, var(--color-text-muted) — "unknown", not "bad" + +function rssiToLevel(rssi) { + if (rssi > -50) return 4 + if (rssi >= -60) return 3 + if (rssi >= -70) return 2 + return 1 +} + +function rssiToColor(rssi) { + if (rssi == null) return 'var(--color-text-muted)' + if (rssi > -50) return 'var(--color-success)' + if (rssi >= -60) return 'var(--color-info)' + if (rssi >= -70) return 'var(--color-warning)' + return 'var(--color-danger)' +} + +const SIZES = { + sm: { box: 16, strokeWidth: 1.6 }, + md: { box: 20, strokeWidth: 1.8 }, +} + +const DIM_COLOR = 'var(--color-bg-island)' + +export default function SignalIndicator({ rssi, size = 'md', className = '' }) { + const known = rssi != null && Number.isFinite(rssi) + const level = known ? rssiToLevel(rssi) : 0 + const color = rssiToColor(known ? rssi : null) + const { box, strokeWidth } = SIZES[size] || SIZES.md + const label = known + ? `Signal strength: ${['weak', 'fair', 'good', 'excellent'][level - 1]} (${rssi} dBm)` + : 'Signal strength unknown' + + return ( + + {label} + {/* Center dot — level 1 */} + = 1 ? color : DIM_COLOR} style={{ transition: 'fill 0.2s' }} /> + {/* Inner arc — level 2 */} + = 2 ? color : DIM_COLOR} + strokeWidth={strokeWidth} + strokeLinecap="round" + style={{ transition: 'stroke 0.2s' }} + /> + {/* Middle arc — level 3 */} + = 3 ? color : DIM_COLOR} + strokeWidth={strokeWidth} + strokeLinecap="round" + style={{ transition: 'stroke 0.2s' }} + /> + {/* Outer arc — level 4 */} + = 4 ? color : DIM_COLOR} + strokeWidth={strokeWidth} + strokeLinecap="round" + style={{ transition: 'stroke 0.2s' }} + /> + + ) +} diff --git a/frontend/src/components/ui/TimeRangeSelect.jsx b/frontend/src/components/ui/TimeRangeSelect.jsx new file mode 100644 index 0000000..828719e --- /dev/null +++ b/frontend/src/components/ui/TimeRangeSelect.jsx @@ -0,0 +1,83 @@ +// src/components/ui/TimeRangeSelect.jsx +// Time-range picker for history/telemetry pages — a preset dropdown (10m +// through all-time) plus a "Custom…" option opening a small range modal. +// Drives every time-scoped section on a page from one control: pass the +// resolved { since, until } down to whatever fetches/filters data. +// +// Props: +// value — { key: string, since: Date|null, until: Date|null } — since=null means "all-time" +// onChange — (value) => void +// className — extra classes on the trigger + +import { useState } from 'react' +import Select from '@/components/ui/Select' +import Modal from '@/components/ui/Modal' +import Button from '@/components/ui/Button' +import DateTimePicker from '@/components/ui/DateTimePicker' +import { PRESETS, resolveRange } from '@/lib/timeRange' + +function CustomRangeModal({ open, onClose, initialSince, initialUntil, onApply }) { + const [since, setSince] = useState(initialSince ? initialSince.toISOString() : '') + const [until, setUntil] = useState(initialUntil ? initialUntil.toISOString() : '') + + const apply = () => { + onApply({ + since: since ? new Date(since) : null, + until: until ? new Date(until) : null, + }) + onClose() + } + + return ( + +
+ + +
+ + +
+
+
+ ) +} + +export default function TimeRangeSelect({ value, onChange, className = '' }) { + const [showCustom, setShowCustom] = useState(false) + + const handleSelectChange = e => { + const key = e.target.value + if (key === 'custom') { + setShowCustom(true) + return + } + onChange(resolveRange(key)) + } + + const selectValue = value?.key === 'custom' ? 'custom' : (value?.key || '7d') + + return ( + <> + + + setShowCustom(false)} + initialSince={value?.since} + initialUntil={value?.until} + onApply={({ since, until }) => onChange({ key: 'custom', since, until })} + /> + + ) +} diff --git a/frontend/src/lib/timeRange.js b/frontend/src/lib/timeRange.js new file mode 100644 index 0000000..3d98c82 --- /dev/null +++ b/frontend/src/lib/timeRange.js @@ -0,0 +1,26 @@ +// frontend/src/lib/timeRange.js +// Shared time-range presets + resolution logic for TimeRangeSelect and any +// page/component that needs to compute a { since, until } window from a key. + +export const PRESETS = [ + { key: '10m', label: 'Last 10 minutes', ms: 10 * 60 * 1000 }, + { key: '1h', label: 'Last hour', ms: 60 * 60 * 1000 }, + { key: '3h', label: 'Last 3 hours', ms: 3 * 60 * 60 * 1000 }, + { key: '24h', label: 'Last 24 hours', ms: 24 * 60 * 60 * 1000 }, + { key: '3d', label: 'Last 3 days', ms: 3 * 24 * 60 * 60 * 1000 }, + { key: '7d', label: 'Last 7 days', ms: 7 * 24 * 60 * 60 * 1000 }, + { key: '30d', label: 'Last 30 days', ms: 30 * 24 * 60 * 60 * 1000 }, + { key: '3mo', label: 'Last 3 months', ms: 90 * 24 * 60 * 60 * 1000 }, + { key: '6mo', label: 'Last 6 months', ms: 182 * 24 * 60 * 60 * 1000 }, + { key: 'all', label: 'All time', ms: null }, +] + +/** Resolve a preset key (or 'custom') into { key, since: Date|null, until: Date|null }. */ +export function resolveRange(key, customSince, customUntil) { + if (key === 'custom') { + return { key, since: customSince ?? null, until: customUntil ?? null } + } + const preset = PRESETS.find(p => p.key === key) || PRESETS[5] // default: 7d + if (preset.ms == null) return { key: preset.key, since: null, until: null } + return { key: preset.key, since: new Date(Date.now() - preset.ms), until: null } +} diff --git a/frontend/src/pages/dev/StyleGuide.jsx b/frontend/src/pages/dev/StyleGuide.jsx index 6575dc5..9be77a7 100644 --- a/frontend/src/pages/dev/StyleGuide.jsx +++ b/frontend/src/pages/dev/StyleGuide.jsx @@ -6,6 +6,12 @@ import { useState } from 'react' import Button from '@/components/ui/Button' import RowActions from '@/components/ui/RowActions' import StatusBadge from '@/components/ui/StatusBadge' +import SignalIndicator from '@/components/ui/SignalIndicator' +import EditableText from '@/components/ui/EditableText' +import LineChart from '@/components/ui/LineChart' +import PressHoldButton from '@/components/ui/PressHoldButton' +import TimeRangeSelect from '@/components/ui/TimeRangeSelect' +import { resolveRange } from '@/lib/timeRange' import FormField from '@/components/ui/FormField' import Modal from '@/components/ui/Modal' import DataTable from '@/components/ui/DataTable' @@ -18,6 +24,7 @@ import Tabs from '@/components/ui/Tabs' import { ToastProvider, useToast } from '@/components/ui/Toast' import SearchBar from '@/components/ui/SearchBar' import Select from '@/components/ui/Select' +import MultiSelect from '@/components/ui/MultiSelect' import HeaderSearch from '@/components/ui/HeaderSearch' import Breadcrumbs from '@/components/ui/Breadcrumbs' import ConfirmDialog from '@/components/ui/ConfirmDialog' @@ -42,6 +49,35 @@ function urlMapToList(map) { }).sort((a, b) => a.name.localeCompare(b.name)) } +// LineChart demo series — computed once at module init (not per-render) so +// the chart doesn't violate the "no impure calls during render" rule. +const _rssiBase = Date.now() +const RSSI_DEMO_DATA = Array.from({ length: 40 }, (_, i) => ({ + x: _rssiBase - (40 - i) * 30000, + y: -55 + Math.sin(i / 4) * 12 + (Math.sin(i * 7.3) * 2), +})) +const _pingBase = Date.now() +const PING_DEMO_DATA = Array.from({ length: 30 }, (_, i) => ({ + x: _pingBase - (30 - i) * 60000, + y: (i > 10 && i < 16) ? null : 40 + Math.sin(i * 3.1) * 30 + 30, +})) +// Irregular spacing: a tight cluster of points, then a big time jump, then +// another cluster — regression case for the Catmull-Rom overshoot bug (a +// naive uniform-spacing spline loops the curve backward at the jump). +const _irregularBase = Date.now() +const IRREGULAR_DEMO_DATA = [ + ...Array.from({ length: 8 }, (_, i) => ({ x: _irregularBase - 3600000 - i * 15000, y: 45 + Math.sin(i) * 8 })), + ...Array.from({ length: 8 }, (_, i) => ({ x: _irregularBase - i * 15000, y: 50 + Math.cos(i) * 10 })), +].sort((a, b) => a.x - b.x) +// Sparse data in a wide requested range: only 6 points span 90 minutes, but +// xMin/xMax below requests a full 24h axis — the chart should show the data +// bunched near one edge with a mostly-empty axis, not auto-zoom to fit it. +const _sparseNow = Date.now() +const SPARSE_DEMO_DATA = Array.from({ length: 6 }, (_, i) => ({ + x: _sparseNow - (6 - i) * 900000, + y: 20 + i * 3, +})) + const ASSET_ICON_GROUPS = [ { title: 'Global Icons', items: urlMapToList(_globalIconUrls) }, { title: 'Side Menu Icons', items: urlMapToList(_sideMenuIconUrls) }, @@ -404,7 +440,11 @@ export default function StyleGuide() { const [modalSize, setModalSize] = useState('md') const [paginationPage, setPaginationPage] = useState(3) const [paginationSize, setPaginationSize] = useState(20) + const [demoRange, setDemoRange] = useState(() => resolveRange('7d')) const [formValues, setFormValues] = useState({ text: '', email: '', select: '', textarea: '' }) + const [multiSelectDemo, setMultiSelectDemo] = useState(['vesper_plus', 'chronos']) + const [editableDemoValue, setEditableDemoValue] = useState('Bell Tower — North Wing') + const [editableDemoEmpty, setEditableDemoEmpty] = useState('') // DataTable interactive demo state const [tableSortKey, setTableSortKey] = useState('') @@ -499,7 +539,13 @@ export default function StyleGuide() { ['#spacing', 'Spacing'], ['#buttons', 'Button'], ['#badges', 'StatusBadge'], + ['#signal-indicator', 'SignalIndicator'], + ['#editable-text', 'EditableText'], + ['#line-chart', 'LineChart'], + ['#press-hold-button', 'PressHoldButton'], + ['#time-range-select', 'TimeRangeSelect'], ['#formfields', 'FormField'], + ['#multiselect', 'MultiSelect'], ['#spinner', 'Spinner'], ['#datatable', 'DataTable'], ['#rowactions', 'RowActions'], @@ -1008,6 +1054,202 @@ export default function StyleGuide() { + {/* ================================================================ + 5a. SIGNAL INDICATOR + ================================================================ */} +
+ + + + {[-55, -65, -75, -85, -95, null].map((rssi) => ( +
+ + + {rssi == null ? 'unknown' : `${rssi} dBm`} + +
+ ))} +
+
+ + + + + + Bell Tower — North Wing + + + + +
+ + {/* ================================================================ + 5a-1b. EDITABLE TEXT + ================================================================ */} +
+ + + + + + + + + + + + + + + + + + + +
+ + {/* ================================================================ + 5a-2. LINE CHART + ================================================================ */} +
+ + + + Math.round(v)} + formatX={x => new Date(x).toLocaleTimeString()} + colorStops={[ + { value: -90, color: 'var(--color-danger)' }, + { value: -70, color: 'var(--color-warning)' }, + { value: -50, color: 'var(--color-success)' }, + ]} + /> + + + + + + Math.round(v)} + formatX={x => new Date(x).toLocaleTimeString()} + /> + + + + + + v.toFixed(1)} + formatX={x => new Date(x).toLocaleTimeString()} + /> + + + + + + Math.round(v)} + formatX={x => new Date(x).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} + xMin={_sparseNow - 24 * 3600000} + xMax={_sparseNow} + /> + + + + + + + + + +
+ + {/* ================================================================ + 5a-3. PRESS HOLD BUTTON + ================================================================ */} +
+ + + + alert('Confirmed!')}> + Hold to Reset Stats + + + + + + + alert('Confirmed!')} + > + Hold to Confirm (1.5s) + + + + + + + {}}>Small + {}}>Medium + {}}>Large + + + + + + {}}>Cannot hold + + + +
+ + {/* ================================================================ + 5a-4. TIME RANGE SELECT + ================================================================ */} +
+ + + +
+ +
+ + key: {demoRange.key} · since: {demoRange.since ? demoRange.since.toLocaleString() : 'null (all-time)'} + +
+
+ +
+ {/* ================================================================ 5b. PILL BUTTON ================================================================ */} @@ -1281,6 +1523,45 @@ export default function StyleGuide() { + {/* ================================================================ + 6b. MULTI-SELECT + ================================================================ */} +
+ +

+ Same floating-menu language as Select, with checkbox rows so multiple values can be picked. Used for filters like "Board Type". +

+
+ +
+
+ + +
+ {}} + disabled + placeholder="All Board Types" + options={[{ value: 'vesper', label: 'Vesper' }]} + /> +
+
+
+ {/* ================================================================ 7. SPINNER ================================================================ */} diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index afd7a5d..ac60ab8 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -503,6 +503,24 @@ tr:focus-within .btn-table-actions { color: var(--color-primary); } +/* ========================================================================== + MULTI-SELECT (.multi-select-menu) + Reuses .select-trigger / .select-menu / .select-option — adds a checkbox + per row and a "Clear selection" row when something is selected. + ========================================================================== */ + +.multi-select-clear { + color: var(--color-text-muted); + border-bottom: 1px solid var(--color-border); + border-radius: 0; + margin-bottom: var(--space-1); + padding-bottom: var(--space-2); +} + +.multi-select-clear:hover { + color: var(--color-danger); +} + /* Keep old .select-wrapper for any legacy uses */ .select-wrapper { position: relative; @@ -3220,3 +3238,14 @@ tr:focus-within .btn-table-actions { filter: brightness(1.06); box-shadow: var(--shadow-primary-glow); } + +/* EditableText — pencil icon hidden until the whole component is hovered + or the pencil itself has keyboard focus (so it's reachable via Tab). */ +.editable-text:hover .editable-text__pencil, +.editable-text__pencil:focus-visible { + opacity: 1 !important; +} +.editable-text__pencil:hover { + background: var(--color-bg-elevated); + color: var(--color-text-primary); +}