1173 lines
42 KiB
React
1173 lines
42 KiB
React
// frontend/src/pages/bellcloud/devices/tabs/GeneralTab.jsx
|
||
// General Information tab — 3-row bento layout
|
||
|
||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react'
|
||
import api from '@/lib/api'
|
||
import { useToast } from '@/components/ui/Toast'
|
||
import Button from '@/components/ui/Button'
|
||
import StatusBadge from '@/components/ui/StatusBadge'
|
||
import Icon from '@/components/ui/Icon'
|
||
import FormField from '@/components/ui/FormField'
|
||
import Spinner from '@/components/ui/Spinner'
|
||
import { fmtDateTimeMedium } from '@/lib/formatters'
|
||
|
||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||
|
||
const LOG_LEVELS = [0, 1, 2, 3, 4, 5]
|
||
|
||
const LOG_LEVEL_META = {
|
||
0: { label: 'Disabled', color: 'var(--color-text-muted)' },
|
||
1: { label: 'Error', color: 'var(--color-danger)' },
|
||
2: { label: 'Warning', color: 'var(--color-warning)' },
|
||
3: { label: 'Info', color: 'var(--color-info)' },
|
||
4: { label: 'Debug', color: '#9f7aea' },
|
||
5: { label: 'Verbose', color: 'var(--color-success)' },
|
||
}
|
||
|
||
// Glass bg tokens
|
||
const GLASS_BG = 'rgba(28, 32, 38, 0.30)'
|
||
const GLASS_BG_INNER = 'rgba(28, 32, 38, 0.40)'
|
||
|
||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
function parseCoordinates(coordStr) {
|
||
if (!coordStr) return null
|
||
if (typeof coordStr === 'object' && coordStr !== null) {
|
||
const lat = parseFloat(coordStr.lat ?? coordStr.latitude)
|
||
const lng = parseFloat(coordStr.lng ?? coordStr.longitude)
|
||
if (!isNaN(lat) && !isNaN(lng)) return { lat, lng }
|
||
return null
|
||
}
|
||
const numbers = coordStr.match(/-?\d+(?:\.\d+)?/g)
|
||
if (numbers && numbers.length >= 2) {
|
||
let lat = parseFloat(numbers[0])
|
||
let lng = parseFloat(numbers[1])
|
||
if (!isNaN(lat) && !isNaN(lng)) {
|
||
if (/\bS\b/.test(coordStr)) lat = -Math.abs(lat)
|
||
if (/\bW\b/.test(coordStr)) lng = -Math.abs(lng)
|
||
return { lat, lng }
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
function formatCoordinates(coords) {
|
||
if (!coords) return '—'
|
||
const latDir = coords.lat >= 0 ? 'N' : 'S'
|
||
const lngDir = coords.lng >= 0 ? 'E' : 'W'
|
||
return `${Math.abs(coords.lat).toFixed(6)}° ${latDir}, ${Math.abs(coords.lng).toFixed(6)}° ${lngDir}`
|
||
}
|
||
|
||
function getNearestPlaceLabel(data) {
|
||
const addr = data?.address || {}
|
||
const name = addr.city || addr.town || addr.village || addr.hamlet || addr.municipality || data?.display_name?.split(',')[0] || ''
|
||
const region = addr.state || addr.county || ''
|
||
const country = addr.country || ''
|
||
return [name, region, country].filter(Boolean).join(', ')
|
||
}
|
||
|
||
function getLogLevelMeta(levelRaw) {
|
||
if (!Number.isFinite(levelRaw)) return null
|
||
return LOG_LEVEL_META[Number(levelRaw)] || null
|
||
}
|
||
|
||
// ─── OSM Tile helpers ─────────────────────────────────────────────────────────
|
||
|
||
function latLngToTile(lat, lng, zoom) {
|
||
const n = Math.pow(2, zoom)
|
||
const xFrac = ((lng + 180) / 360) * n
|
||
const latRad = (lat * Math.PI) / 180
|
||
const yFrac = ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n
|
||
const x = Math.floor(xFrac)
|
||
const y = Math.floor(yFrac)
|
||
return { x, y, px: (xFrac - x) * 256, py: (yFrac - y) * 256 }
|
||
}
|
||
|
||
function buildTileGrid(lat, lng, zoom = 13, options = {}) {
|
||
const { x, y, px, py } = latLngToTile(lat, lng, zoom)
|
||
const COL_RADIUS = options.colRadius ?? 3
|
||
const ROW_RADIUS = options.rowRadius ?? 1
|
||
const tiles = []
|
||
for (let dy = -ROW_RADIUS; dy <= ROW_RADIUS; dy++) {
|
||
for (let dx = -COL_RADIUS; dx <= COL_RADIUS; dx++) {
|
||
tiles.push({
|
||
url: `https://tiles.stadiamaps.com/tiles/stamen_toner/${zoom}/${x + dx}/${y + dy}.png`,
|
||
col: dx + COL_RADIUS,
|
||
row: dy + ROW_RADIUS,
|
||
})
|
||
}
|
||
}
|
||
return {
|
||
tiles,
|
||
markerOffset: {
|
||
x: COL_RADIUS * 256 + px,
|
||
y: ROW_RADIUS * 256 + py,
|
||
},
|
||
}
|
||
}
|
||
|
||
function getResponsiveTileCounts(width, height) {
|
||
const safeWidth = Number.isFinite(width) && width > 0 ? width : 0
|
||
const safeHeight = Number.isFinite(height) && height > 0 ? height : 0
|
||
|
||
let totalCols = Math.max(7, Math.ceil(safeWidth / 256) + 2)
|
||
let totalRows = Math.max(3, Math.ceil(safeHeight / 256) + 2)
|
||
|
||
if (totalCols % 2 === 0) totalCols += 1
|
||
if (totalRows % 2 === 0) totalRows += 1
|
||
|
||
return {
|
||
totalCols,
|
||
totalRows,
|
||
colRadius: Math.floor(totalCols / 2),
|
||
rowRadius: Math.floor(totalRows / 2),
|
||
}
|
||
}
|
||
|
||
// ─── Keyframes ────────────────────────────────────────────────────────────────
|
||
|
||
let keyframesInjected = false
|
||
function ensureKeyframes() {
|
||
if (keyframesInjected) return
|
||
keyframesInjected = true
|
||
const style = document.createElement('style')
|
||
style.textContent = `
|
||
@keyframes gen-tab-pulse {
|
||
0% { opacity: 0.9; transform: scale(1); }
|
||
70% { opacity: 0; transform: scale(2.4); }
|
||
100% { opacity: 0; transform: scale(2.4); }
|
||
}
|
||
.gen-log-slider {
|
||
-webkit-appearance: none;
|
||
appearance: none;
|
||
width: 100%;
|
||
height: 4px;
|
||
border-radius: 2px;
|
||
outline: none;
|
||
cursor: pointer;
|
||
background: rgba(28,32,38,0.70);
|
||
}
|
||
.gen-log-slider::-webkit-slider-thumb {
|
||
-webkit-appearance: none;
|
||
appearance: none;
|
||
width: 14px;
|
||
height: 14px;
|
||
border-radius: 50%;
|
||
border: 2px solid rgba(192,193,255,0.25);
|
||
cursor: pointer;
|
||
transition: transform 0.15s, box-shadow 0.15s;
|
||
}
|
||
.gen-log-slider::-moz-range-thumb {
|
||
width: 14px;
|
||
height: 14px;
|
||
border-radius: 50%;
|
||
border: 2px solid rgba(192,193,255,0.25);
|
||
cursor: pointer;
|
||
}
|
||
.gen-log-slider:hover::-webkit-slider-thumb {
|
||
transform: scale(1.2);
|
||
}
|
||
`
|
||
document.head.appendChild(style)
|
||
}
|
||
|
||
// ─── Primitive design atoms ───────────────────────────────────────────────────
|
||
|
||
function SectionLabel({ children }) {
|
||
return (
|
||
<span style={{
|
||
fontSize: 'var(--font-size-xs)',
|
||
fontWeight: 'var(--font-weight-semibold)',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: 'var(--tracking-wide)',
|
||
color: 'var(--color-text-muted)',
|
||
}}>
|
||
{children}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
// Glass card — the universal panel wrapper
|
||
function GlassCard({ title, titleAction, children, noPadding, style, fullHeight }) {
|
||
return (
|
||
<div style={{
|
||
background: GLASS_BG,
|
||
backdropFilter: 'var(--blur-modal)',
|
||
WebkitBackdropFilter: 'var(--blur-modal)',
|
||
border: '1px solid var(--color-border)',
|
||
borderRadius: 'var(--radius-xl)',
|
||
boxShadow: 'var(--shadow-card), var(--shadow-md)',
|
||
overflow: 'hidden',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
height: fullHeight ? '100%' : undefined,
|
||
...style,
|
||
}}>
|
||
{title && (
|
||
<div style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
gap: 'var(--space-3)',
|
||
padding: 'var(--space-4) var(--space-5)',
|
||
borderBottom: '1px solid var(--color-border)',
|
||
background: 'linear-gradient(180deg, rgba(192,193,255,0.04) 0%, transparent 100%)',
|
||
flexShrink: 0,
|
||
}}>
|
||
<span style={{
|
||
fontSize: 'var(--font-size-base)',
|
||
fontWeight: 'var(--font-weight-semibold)',
|
||
color: 'var(--color-text-primary)',
|
||
minWidth: 0,
|
||
}}>
|
||
{title}
|
||
</span>
|
||
{titleAction && <div style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}>{titleAction}</div>}
|
||
</div>
|
||
)}
|
||
<div style={noPadding ? { flex: 1, display: 'flex', flexDirection: 'column' } : { padding: 'var(--space-5)', flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||
{children}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Inner surface — glass nested surface
|
||
function InnerSurface({ children, style }) {
|
||
return (
|
||
<div style={{
|
||
background: GLASS_BG_INNER,
|
||
backdropFilter: 'var(--blur-modal)',
|
||
WebkitBackdropFilter: 'var(--blur-modal)',
|
||
border: '1px solid var(--color-border)',
|
||
borderRadius: 'var(--radius-lg)',
|
||
...style,
|
||
}}>
|
||
{children}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function EditActionButton({ onClick, label = 'Edit' }) {
|
||
return (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={onClick}
|
||
icon={<Icon name="edit" size={13} />}
|
||
style={{ whiteSpace: 'nowrap', flexShrink: 0 }}
|
||
>
|
||
{label}
|
||
</Button>
|
||
)
|
||
}
|
||
|
||
function GoogleMapsIcon({ size = 14 }) {
|
||
return (
|
||
<svg width={size} height={size} viewBox="0 0 24 24" aria-hidden="true">
|
||
<path fill="#34A853" d="M6 3.5 11 5.2v15.3L6 18.8z" />
|
||
<path fill="#FBBC04" d="m11 5.2 7-2.2v15.3l-7 2.2z" />
|
||
<path fill="#4285F4" d="m18 3 4 1.4v15.3L18 18.3z" />
|
||
<path fill="#EA4335" d="M15.2 10.2c0 2.4-3.2 6.1-3.2 6.1s-3.2-3.7-3.2-6.1a3.2 3.2 0 1 1 6.4 0Z" />
|
||
<circle cx="12" cy="10.2" r="1.4" fill="#fff" />
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
// ─── Safety Row ───────────────────────────────────────────────────────────────
|
||
|
||
function SafetyRow({ label, description, enabled }) {
|
||
return (
|
||
<InnerSurface style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
padding: 'var(--space-3) var(--space-4)',
|
||
}}>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||
<span style={{
|
||
fontSize: 'var(--font-size-base)',
|
||
fontWeight: 'var(--font-weight-medium)',
|
||
color: 'var(--color-text-primary)',
|
||
}}>
|
||
{label}
|
||
</span>
|
||
<span style={{
|
||
fontSize: 'var(--font-size-xs)',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: 'var(--tracking-wide)',
|
||
color: 'var(--color-text-muted)',
|
||
}}>
|
||
{description}
|
||
</span>
|
||
</div>
|
||
<StatusBadge variant={enabled ? 'success' : 'neutral'}>
|
||
{enabled ? 'Armed' : 'Disarmed'}
|
||
</StatusBadge>
|
||
</InnerSurface>
|
||
)
|
||
}
|
||
|
||
// ─── Log Slider ───────────────────────────────────────────────────────────────
|
||
|
||
function LogSlider({ label, level, onChange, canEdit, color }) {
|
||
const meta = getLogLevelMeta(level)
|
||
const safeLevel = Number.isFinite(level) ? Number(level) : 0
|
||
|
||
// Build CSS gradient for the track — filled up to thumb position
|
||
const pct = (safeLevel / (LOG_LEVELS.length - 1)) * 100
|
||
const trackBg = `linear-gradient(90deg, ${color} 0%, ${color} ${pct}%, rgba(28,32,38,0.70) ${pct}%, rgba(28,32,38,0.70) 100%)`
|
||
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<SectionLabel>{label}</SectionLabel>
|
||
<span style={{
|
||
fontSize: 'var(--font-size-xs)',
|
||
fontWeight: 'var(--font-weight-semibold)',
|
||
fontFamily: 'var(--font-family-mono)',
|
||
color: meta ? meta.color : 'var(--color-text-muted)',
|
||
}}>
|
||
{meta ? meta.label : '—'}
|
||
</span>
|
||
</div>
|
||
<input
|
||
type="range"
|
||
className="gen-log-slider"
|
||
min={0}
|
||
max={5}
|
||
step={1}
|
||
value={safeLevel}
|
||
disabled={!canEdit}
|
||
onChange={canEdit ? e => onChange(Number(e.target.value)) : undefined}
|
||
style={{
|
||
background: trackBg,
|
||
opacity: canEdit ? 1 : 0.6,
|
||
// thumb color via CSS custom property on inline style — works in Safari
|
||
'--thumb-color': color,
|
||
}}
|
||
/>
|
||
{/* tick marks — padding-inline of 7px (half thumb width) aligns ticks with thumb centre */}
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: -2, paddingInline: '7px' }}>
|
||
{LOG_LEVELS.map(l => (
|
||
<div key={l} style={{
|
||
width: 1,
|
||
height: 4,
|
||
background: l === safeLevel ? color : 'rgba(192,193,255,0.12)',
|
||
borderRadius: 1,
|
||
transition: 'background 0.2s',
|
||
}} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Location Map ─────────────────────────────────────────────────────────────
|
||
|
||
function LocationMap({ coords, locationName, locationLabel, canEdit, onEdit }) {
|
||
const ZOOM = 13
|
||
const PIN_X = 72 // % across
|
||
const PIN_Y = 50 // % down
|
||
const containerRef = useRef(null)
|
||
const [mapSize, setMapSize] = useState({ width: 0, height: 0 })
|
||
|
||
useEffect(() => {
|
||
const node = containerRef.current
|
||
if (!node) return
|
||
|
||
const updateSize = () => {
|
||
const { width, height } = node.getBoundingClientRect()
|
||
setMapSize(prev => (
|
||
prev.width === width && prev.height === height
|
||
? prev
|
||
: { width, height }
|
||
))
|
||
}
|
||
|
||
updateSize()
|
||
|
||
if (typeof ResizeObserver === 'undefined') return
|
||
|
||
const observer = new ResizeObserver(updateSize)
|
||
observer.observe(node)
|
||
return () => observer.disconnect()
|
||
}, [])
|
||
|
||
const { totalCols, totalRows, colRadius, rowRadius } = useMemo(
|
||
() => getResponsiveTileCounts(mapSize.width, mapSize.height),
|
||
[mapSize.width, mapSize.height]
|
||
)
|
||
|
||
const { tiles, markerOffset } = useMemo(() => {
|
||
if (!coords) return { tiles: null, markerOffset: null }
|
||
return buildTileGrid(coords.lat, coords.lng, ZOOM, { colRadius, rowRadius })
|
||
}, [coords, colRadius, rowRadius])
|
||
|
||
const googleMapsUrl = coords
|
||
? `https://www.google.com/maps?q=${coords.lat},${coords.lng}`
|
||
: null
|
||
|
||
const handleOpenGoogleMaps = useCallback(() => {
|
||
if (!googleMapsUrl || typeof window === 'undefined') return
|
||
window.open(googleMapsUrl, '_blank', 'noopener,noreferrer')
|
||
}, [googleMapsUrl])
|
||
|
||
return (
|
||
<div style={{
|
||
position: 'relative',
|
||
width: '100%',
|
||
height: '100%',
|
||
minHeight: 0,
|
||
background: 'rgba(10,14,20,0.90)',
|
||
overflow: 'hidden',
|
||
}} ref={containerRef}>
|
||
{/* Tiles */}
|
||
{tiles ? (
|
||
<div
|
||
aria-hidden="true"
|
||
style={{
|
||
position: 'absolute',
|
||
display: 'grid',
|
||
gridTemplateColumns: `repeat(${totalCols}, 256px)`,
|
||
gridTemplateRows: `repeat(${totalRows}, 256px)`,
|
||
top: `calc(${PIN_Y}% - ${markerOffset.y}px)`,
|
||
left: `calc(${PIN_X}% - ${markerOffset.x}px)`,
|
||
pointerEvents: 'none',
|
||
userSelect: 'none',
|
||
}}
|
||
>
|
||
{tiles.map((t, i) => (
|
||
<img
|
||
key={i}
|
||
src={t.url}
|
||
alt=""
|
||
draggable={false}
|
||
style={{
|
||
width: 256,
|
||
height: 256,
|
||
display: 'block',
|
||
gridColumn: t.col + 1,
|
||
gridRow: t.row + 1,
|
||
filter: 'invert(1) brightness(0.32) contrast(1.1)',
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div
|
||
aria-hidden="true"
|
||
style={{
|
||
position: 'absolute',
|
||
inset: 0,
|
||
backgroundImage: 'radial-gradient(circle, rgba(192,193,255,0.04) 1px, transparent 1px)',
|
||
backgroundSize: '28px 28px',
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* Marker */}
|
||
{tiles && markerOffset && (
|
||
<div
|
||
aria-hidden="true"
|
||
style={{
|
||
position: 'absolute',
|
||
top: `${PIN_Y}%`,
|
||
left: `${PIN_X}%`,
|
||
zIndex: 10,
|
||
pointerEvents: 'none',
|
||
}}
|
||
>
|
||
<div style={{
|
||
position: 'absolute',
|
||
width: 10, height: 10, borderRadius: '50%',
|
||
background: 'rgba(250,204,21,1)',
|
||
boxShadow: '0 0 0 2.5px rgba(8,12,18,0.95), 0 0 14px rgba(250,204,21,0.85)',
|
||
top: -5, left: -5,
|
||
}} />
|
||
<div style={{
|
||
position: 'absolute',
|
||
width: 26, height: 26, borderRadius: '50%',
|
||
border: '1.5px solid rgba(250,204,21,0.55)',
|
||
top: -13, left: -13,
|
||
animation: 'gen-tab-pulse 2.2s ease-out infinite',
|
||
}} />
|
||
</div>
|
||
)}
|
||
|
||
{/* Left-fade overlay */}
|
||
<div
|
||
aria-hidden="true"
|
||
style={{
|
||
position: 'absolute', inset: 0,
|
||
background: 'linear-gradient(90deg, rgba(10,14,20,0.72) 0%, rgba(10,14,20,0.48) 22%, rgba(10,14,20,0.18) 45%, rgba(10,14,20,0.04) 70%, rgba(10,14,20,0.0) 100%)',
|
||
pointerEvents: 'none',
|
||
}}
|
||
/>
|
||
|
||
{/* Top-left info stack */}
|
||
<div style={{
|
||
position: 'absolute',
|
||
top: 'var(--space-5)',
|
||
left: 'var(--space-5)',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: 'var(--space-3)',
|
||
zIndex: 20,
|
||
maxWidth: '42%',
|
||
}}>
|
||
<div style={{
|
||
background: 'rgba(28,32,38,0.60)',
|
||
backdropFilter: 'var(--blur-modal)',
|
||
WebkitBackdropFilter: 'var(--blur-modal)',
|
||
border: '1px solid var(--color-border)',
|
||
borderRadius: 'var(--radius-lg)',
|
||
padding: 'var(--space-3) var(--space-4)',
|
||
}}>
|
||
<div style={{ fontSize: 'var(--font-size-xs)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', color: 'var(--color-text-muted)', marginBottom: 4 }}>
|
||
Location
|
||
</div>
|
||
<div style={{ fontSize: 'var(--font-size-base)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-primary)' }}>
|
||
{locationLabel || '—'}
|
||
</div>
|
||
</div>
|
||
|
||
{locationName && (
|
||
<div style={{
|
||
background: 'rgba(28,32,38,0.60)',
|
||
backdropFilter: 'var(--blur-modal)',
|
||
WebkitBackdropFilter: 'var(--blur-modal)',
|
||
border: '1px solid var(--color-border)',
|
||
borderRadius: 'var(--radius-lg)',
|
||
padding: 'var(--space-3) var(--space-4)',
|
||
}}>
|
||
<div style={{ fontSize: 'var(--font-size-xs)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', color: 'var(--color-text-muted)', marginBottom: 4 }}>
|
||
Nearest Place
|
||
</div>
|
||
<div style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-secondary)' }}>
|
||
{locationName}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Coordinates — bottom-left */}
|
||
{coords && (
|
||
<div style={{ position: 'absolute', bottom: 'var(--space-5)', left: 'var(--space-5)', zIndex: 20 }}>
|
||
<button
|
||
type="button"
|
||
onClick={handleOpenGoogleMaps}
|
||
title="Open in Google Maps"
|
||
aria-label="Open coordinates in Google Maps"
|
||
style={{
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
gap: 'var(--space-2)',
|
||
background: 'rgba(28,32,38,0.80)',
|
||
backdropFilter: 'var(--blur-modal)',
|
||
WebkitBackdropFilter: 'var(--blur-modal)',
|
||
border: '1px solid var(--color-border)',
|
||
borderRadius: 'var(--radius-md)',
|
||
padding: '5px var(--space-3)',
|
||
fontSize: 'var(--font-size-xs)',
|
||
fontFamily: 'var(--font-family-mono)',
|
||
color: 'var(--color-text-muted)',
|
||
letterSpacing: '0.03em',
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
<GoogleMapsIcon size={14} />
|
||
{formatCoordinates(coords)}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Edit button — top-right */}
|
||
{canEdit && (
|
||
<div style={{ position: 'absolute', top: 'var(--space-4)', right: 'var(--space-4)', zIndex: 20 }}>
|
||
<EditActionButton onClick={onEdit} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Device Notes ─────────────────────────────────────────────────────────────
|
||
|
||
function DeviceNotesSection({ deviceId, canEdit }) {
|
||
const { toast } = useToast()
|
||
const [notes, setNotes] = useState([])
|
||
const [loaded, setLoaded] = useState(false)
|
||
const [adding, setAdding] = useState(false)
|
||
const [newText, setNewText] = useState('')
|
||
const [savingNew, setSavingNew] = useState(false)
|
||
const [editingId, setEditingId] = useState(null)
|
||
const [editingText, setEditingText] = useState('')
|
||
const [savingEdit, setSavingEdit] = useState(false)
|
||
const [deletingId, setDeletingId] = useState(null)
|
||
|
||
useEffect(() => {
|
||
if (!deviceId) return
|
||
api.get(`/devices/${deviceId}/notes`)
|
||
.then(data => { setNotes(data.notes || []); setLoaded(true) })
|
||
.catch(() => setLoaded(true))
|
||
}, [deviceId])
|
||
|
||
const handleAdd = async () => {
|
||
const content = newText.trim()
|
||
if (!content) return
|
||
setSavingNew(true)
|
||
try {
|
||
const data = await api.post(`/devices/${deviceId}/notes`, { content })
|
||
setNotes(prev => [data.note || { id: Date.now(), content, created_at: new Date().toISOString() }, ...prev])
|
||
setNewText('')
|
||
setAdding(false)
|
||
} catch (err) {
|
||
toast.danger('Error', err.message || 'Failed to add note.')
|
||
} finally {
|
||
setSavingNew(false)
|
||
}
|
||
}
|
||
|
||
const handleUpdate = async (noteId) => {
|
||
const content = editingText.trim()
|
||
if (!content) return
|
||
setSavingEdit(true)
|
||
try {
|
||
const data = await api.put(`/devices/${deviceId}/notes/${noteId}`, { content })
|
||
setNotes(prev => prev.map(n => n.id === noteId ? (data.note || { ...n, content }) : n))
|
||
setEditingId(null)
|
||
} catch (err) {
|
||
toast.danger('Error', err.message || 'Failed to update note.')
|
||
} finally {
|
||
setSavingEdit(false)
|
||
}
|
||
}
|
||
|
||
const handleDelete = async (noteId) => {
|
||
setDeletingId(noteId)
|
||
try {
|
||
await api.delete(`/devices/${deviceId}/notes/${noteId}`)
|
||
setNotes(prev => prev.filter(n => n.id !== noteId))
|
||
} catch (err) {
|
||
toast.danger('Error', err.message || 'Failed to delete note.')
|
||
} finally {
|
||
setDeletingId(null)
|
||
}
|
||
}
|
||
|
||
if (!loaded) {
|
||
return (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', color: 'var(--color-text-muted)', fontSize: 'var(--font-size-sm)' }}>
|
||
<Spinner size="sm" />
|
||
Loading notes…
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
|
||
{notes.length === 0 && !adding && (
|
||
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', fontStyle: 'italic', margin: 0 }}>
|
||
No notes for this device.
|
||
</p>
|
||
)}
|
||
|
||
{notes.map(note => (
|
||
<InnerSurface key={note.id} style={{ padding: 'var(--space-3) var(--space-4)' }}>
|
||
{editingId === note.id ? (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
|
||
<FormField
|
||
name={`note-edit-${note.id}`}
|
||
type="textarea"
|
||
value={editingText}
|
||
onChange={e => setEditingText(e.target.value)}
|
||
rows={3}
|
||
/>
|
||
<div style={{ display: 'flex', gap: 'var(--space-2)' }}>
|
||
<Button variant="primary" size="sm" onClick={() => handleUpdate(note.id)} loading={savingEdit}>
|
||
Save
|
||
</Button>
|
||
<Button variant="ghost" size="sm" onClick={() => setEditingId(null)}>
|
||
Cancel
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 'var(--space-3)' }}>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)', whiteSpace: 'pre-wrap', margin: '0 0 var(--space-1)' }}>
|
||
{note.content}
|
||
</p>
|
||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
|
||
{note.created_by && `${note.created_by} · `}{note.created_at ? fmtDateTimeMedium(note.created_at) : ''}
|
||
</span>
|
||
</div>
|
||
{canEdit && (
|
||
<div style={{ display: 'flex', gap: 'var(--space-1)', flexShrink: 0 }}>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => { setEditingId(note.id); setEditingText(note.content) }}
|
||
aria-label="Edit note"
|
||
>
|
||
<Icon name="edit" size={12} />
|
||
</Button>
|
||
<Button
|
||
variant="danger"
|
||
size="sm"
|
||
loading={deletingId === note.id}
|
||
onClick={() => handleDelete(note.id)}
|
||
aria-label="Delete note"
|
||
>
|
||
<Icon name="delete" size={12} />
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</InnerSurface>
|
||
))}
|
||
|
||
{/* Add note */}
|
||
{canEdit && (
|
||
adding ? (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
|
||
<FormField
|
||
name="new-note"
|
||
type="textarea"
|
||
value={newText}
|
||
onChange={e => setNewText(e.target.value)}
|
||
placeholder="Write a note…"
|
||
rows={3}
|
||
/>
|
||
<div style={{ display: 'flex', gap: 'var(--space-2)' }}>
|
||
<Button variant="primary" size="sm" onClick={handleAdd} loading={savingNew} disabled={!newText.trim()}>
|
||
Add Note
|
||
</Button>
|
||
<Button variant="ghost" size="sm" onClick={() => { setAdding(false); setNewText('') }}>
|
||
Cancel
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<Button variant="secondary" size="sm" icon={<Icon name="plus" size={13} />} onClick={() => setAdding(true)} style={{ alignSelf: 'flex-start' }}>
|
||
Add Note
|
||
</Button>
|
||
)
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Tags Row ─────────────────────────────────────────────────────────────────
|
||
|
||
function TagsRow({ tags, canEdit, onAdd, onRemove, saving }) {
|
||
const [input, setInput] = useState('')
|
||
|
||
const handleAdd = () => {
|
||
const trimmed = input.trim()
|
||
if (!trimmed) return
|
||
onAdd(trimmed)
|
||
setInput('')
|
||
}
|
||
|
||
return (
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 'var(--space-2)' }}>
|
||
{tags.length === 0 && !canEdit && (
|
||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', fontStyle: 'italic' }}>
|
||
No tags.
|
||
</span>
|
||
)}
|
||
{tags.map(tag => (
|
||
<span key={tag} style={{
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
gap: 4,
|
||
padding: '3px 10px',
|
||
borderRadius: 'var(--radius-full)',
|
||
background: 'var(--color-primary-subtle)',
|
||
border: '1px solid rgba(192,193,255,0.20)',
|
||
fontSize: 'var(--font-size-xs)',
|
||
fontWeight: 'var(--font-weight-semibold)',
|
||
color: 'var(--color-primary)',
|
||
letterSpacing: 'var(--tracking-wide)',
|
||
textTransform: 'uppercase',
|
||
}}>
|
||
{tag}
|
||
{canEdit && (
|
||
<button
|
||
type="button"
|
||
onClick={() => onRemove(tag)}
|
||
disabled={saving}
|
||
aria-label={`Remove tag ${tag}`}
|
||
style={{
|
||
background: 'none', border: 'none', padding: 0, lineHeight: 1,
|
||
cursor: 'pointer', color: 'var(--color-text-muted)', fontSize: 13,
|
||
opacity: saving ? 0.4 : 1,
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
)}
|
||
</span>
|
||
))}
|
||
{canEdit && (
|
||
<div style={{ display: 'flex', gap: 'var(--space-2)', alignItems: 'center' }}>
|
||
<input
|
||
type="text"
|
||
value={input}
|
||
onChange={e => setInput(e.target.value)}
|
||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAdd() } }}
|
||
placeholder="Add tag…"
|
||
style={{
|
||
background: GLASS_BG_INNER,
|
||
border: '1px solid var(--color-border)',
|
||
borderRadius: 'var(--radius-md)',
|
||
padding: '4px 10px',
|
||
fontSize: 'var(--font-size-xs)',
|
||
color: 'var(--color-text-primary)',
|
||
outline: 'none',
|
||
width: 110,
|
||
fontFamily: 'var(--font-family-base)',
|
||
}}
|
||
/>
|
||
<Button variant="secondary" size="sm" onClick={handleAdd} disabled={!input.trim() || saving}>
|
||
Add
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── GeneralTab ───────────────────────────────────────────────────────────────
|
||
|
||
export default function GeneralTab({
|
||
device,
|
||
canEdit,
|
||
attr,
|
||
tags,
|
||
setTags,
|
||
onEditLocation,
|
||
onEditAttributes,
|
||
onEditLogging,
|
||
}) {
|
||
const { toast } = useToast()
|
||
const [locationName, setLocationName] = useState(null)
|
||
const [savingTags, setSavingTags] = useState(false)
|
||
|
||
// Logging level state — local editable copies for sliders
|
||
const [serialLevel, setSerialLevel] = useState(attr?.serialLogLevel ?? 0)
|
||
const [sdLevel, setSdLevel] = useState(attr?.sdLogLevel ?? 0)
|
||
const [mqttLevel, setMqttLevel] = useState(attr?.mqttLogLevel ?? 0)
|
||
|
||
const id = device?.id || device?.device_id
|
||
|
||
// Inject CSS keyframes + slider styles
|
||
useEffect(() => { ensureKeyframes() }, [])
|
||
|
||
// Keep slider state in sync if attr changes
|
||
useEffect(() => {
|
||
if (Number.isFinite(attr?.serialLogLevel)) setSerialLevel(attr.serialLogLevel)
|
||
if (Number.isFinite(attr?.sdLogLevel)) setSdLevel(attr.sdLogLevel)
|
||
if (Number.isFinite(attr?.mqttLogLevel)) setMqttLevel(attr.mqttLogLevel)
|
||
}, [attr?.serialLogLevel, attr?.sdLogLevel, attr?.mqttLogLevel])
|
||
|
||
// Nearest place
|
||
const coords = useMemo(() => parseCoordinates(device?.device_location_coordinates), [device?.device_location_coordinates])
|
||
|
||
useEffect(() => {
|
||
if (!coords) return
|
||
setLocationName(null)
|
||
fetch(`https://nominatim.openstreetmap.org/reverse?lat=${coords.lat}&lon=${coords.lng}&format=json`)
|
||
.then(r => r.json())
|
||
.then(data => setLocationName(getNearestPlaceLabel(data) || null))
|
||
.catch(() => setLocationName(null))
|
||
}, [coords])
|
||
|
||
const net = attr?.networkSettings || {}
|
||
const staticIpAddress = Array.isArray(net.ipAddress)
|
||
? net.ipAddress.filter(Boolean).join('.')
|
||
: (net.ipAddress || '')
|
||
|
||
// ── Tag handlers ────────────────────────────────────────────────────────────
|
||
|
||
const handleAddTag = useCallback(async (tag) => {
|
||
const trimmed = tag.trim()
|
||
if (!trimmed || tags.includes(trimmed)) return
|
||
const next = [...tags, trimmed]
|
||
setSavingTags(true)
|
||
try {
|
||
await api.put(`/devices/${id}/tags`, { tags: next })
|
||
setTags(next)
|
||
} catch (err) {
|
||
toast.danger('Error', err.message || 'Failed to add tag.')
|
||
} finally {
|
||
setSavingTags(false)
|
||
}
|
||
}, [id, tags, setTags, toast])
|
||
|
||
const handleRemoveTag = useCallback(async (tag) => {
|
||
const next = tags.filter(t => t !== tag)
|
||
setSavingTags(true)
|
||
try {
|
||
await api.put(`/devices/${id}/tags`, { tags: next })
|
||
setTags(next)
|
||
} catch (err) {
|
||
toast.danger('Error', err.message || 'Failed to remove tag.')
|
||
} finally {
|
||
setSavingTags(false)
|
||
}
|
||
}, [id, tags, setTags, toast])
|
||
|
||
// ── Log level save (on blur / edit) ─────────────────────────────────────────
|
||
|
||
const handleSaveLogging = useCallback(async (field, value) => {
|
||
if (!canEdit) return
|
||
try {
|
||
await api.put(`/devices/${id}`, {
|
||
device_attributes: {
|
||
...(attr || {}),
|
||
serialLogLevel: field === 'serial' ? value : serialLevel,
|
||
sdLogLevel: field === 'sd' ? value : sdLevel,
|
||
mqttLogLevel: field === 'mqtt' ? value : mqttLevel,
|
||
}
|
||
})
|
||
toast.success('Saved', 'Log levels updated.')
|
||
} catch (err) {
|
||
toast.danger('Error', err.message || 'Failed to save log levels.')
|
||
}
|
||
}, [id, attr, serialLevel, sdLevel, mqttLevel, canEdit, toast])
|
||
|
||
// ── Network fields ───────────────────────────────────────────────────────────
|
||
|
||
const netFields = [
|
||
{ label: 'Hostname', value: net.hostname || '—' },
|
||
{ label: 'IP Address', value: staticIpAddress || '—' },
|
||
{ label: 'Gateway', value: net.gateway || '—' },
|
||
{ label: 'DNS', value: net.dns || '—' },
|
||
{ label: 'Subnet', value: net.subnet || '—' },
|
||
{ label: 'MAC Address', value: net.mac || net.macAddress || '—' },
|
||
]
|
||
|
||
// ── Render ──────────────────────────────────────────────────────────────────
|
||
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)', width: '100%', maxWidth: 2000, margin: '0 auto' }}>
|
||
|
||
{/* ════════════════════════════════════════════════════════════════════
|
||
ROW 1 — Map (2/3) + Safety Settings (1/3) equal height
|
||
════════════════════════════════════════════════════════════════════ */}
|
||
<div style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: '2fr 1fr',
|
||
gap: 'var(--space-6)',
|
||
alignItems: 'stretch',
|
||
}}>
|
||
|
||
{/* Map */}
|
||
<div style={{
|
||
borderRadius: 'var(--radius-xl)',
|
||
overflow: 'hidden',
|
||
border: '1px solid var(--color-border)',
|
||
boxShadow: 'var(--shadow-card), var(--shadow-md)',
|
||
minHeight: 320,
|
||
}}>
|
||
<LocationMap
|
||
coords={coords}
|
||
locationName={locationName}
|
||
locationLabel={device?.device_location}
|
||
canEdit={canEdit}
|
||
onEdit={onEditLocation}
|
||
/>
|
||
</div>
|
||
|
||
{/* Safety Settings */}
|
||
<GlassCard
|
||
title="Safety Settings"
|
||
fullHeight
|
||
titleAction={canEdit && (
|
||
<EditActionButton onClick={onEditAttributes} />
|
||
)}
|
||
>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)', flex: 1, justifyContent: 'center' }}>
|
||
<SafetyRow label="Bell Guard" description="Automated striking lock" enabled={attr?.bellGuardOn} />
|
||
<SafetyRow label="Warnings" description="Audible system alerts" enabled={attr?.warningsOn} />
|
||
<SafetyRow label="Safety" description="Manual override lock" enabled={attr?.bellGuardSafetyOn} />
|
||
</div>
|
||
</GlassCard>
|
||
</div>
|
||
|
||
{/* ════════════════════════════════════════════════════════════════════
|
||
ROW 2 — Mechanisms | Log Settings | Network Info equal height
|
||
════════════════════════════════════════════════════════════════════ */}
|
||
<div style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: '1fr 1fr 1fr',
|
||
gap: 'var(--space-6)',
|
||
alignItems: 'stretch',
|
||
}}>
|
||
|
||
{/* Mechanisms */}
|
||
<GlassCard
|
||
title="Mechanisms"
|
||
fullHeight
|
||
titleAction={canEdit && (
|
||
<EditActionButton onClick={onEditAttributes} />
|
||
)}
|
||
>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)', flex: 1 }}>
|
||
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)' }}>
|
||
{[
|
||
{ label: 'Has Bells', value: attr?.hasBells, yes: 'Yes', no: 'No', variant: 'success' },
|
||
{ label: 'Has Clock', value: attr?.hasClock, yes: 'Yes', no: 'No', variant: 'info' },
|
||
].map(({ label, value, yes, no, variant }) => (
|
||
<InnerSurface key={label} style={{
|
||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||
gap: 'var(--space-2)', padding: 'var(--space-4)',
|
||
}}>
|
||
<SectionLabel>{label}</SectionLabel>
|
||
<StatusBadge variant={value ? variant : 'neutral'}>
|
||
{value ? yes : no}
|
||
</StatusBadge>
|
||
</InnerSurface>
|
||
))}
|
||
</div>
|
||
|
||
<InnerSurface style={{
|
||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||
padding: 'var(--space-3) var(--space-4)',
|
||
}}>
|
||
<SectionLabel>Connected Bells</SectionLabel>
|
||
<span style={{
|
||
fontSize: 'var(--font-size-xl)',
|
||
fontWeight: 'var(--font-weight-bold)',
|
||
fontFamily: 'var(--font-family-mono)',
|
||
color: 'var(--color-text-primary)',
|
||
letterSpacing: 'var(--tracking-display)',
|
||
}}>
|
||
{Number.isFinite(attr?.totalBells) ? attr.totalBells : '—'}
|
||
</span>
|
||
</InnerSurface>
|
||
</div>
|
||
</GlassCard>
|
||
|
||
{/* Log Settings */}
|
||
<GlassCard
|
||
title="Log Settings"
|
||
fullHeight
|
||
titleAction={canEdit && (
|
||
<EditActionButton onClick={onEditLogging} />
|
||
)}
|
||
>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)', flex: 1, justifyContent: 'center' }}>
|
||
<LogSlider
|
||
label="Serial Log"
|
||
level={serialLevel}
|
||
color={getLogLevelMeta(serialLevel)?.color || 'var(--color-text-muted)'}
|
||
canEdit={canEdit}
|
||
onChange={v => { setSerialLevel(v); handleSaveLogging('serial', v) }}
|
||
/>
|
||
<LogSlider
|
||
label="SD Storage"
|
||
level={sdLevel}
|
||
color={getLogLevelMeta(sdLevel)?.color || 'var(--color-text-muted)'}
|
||
canEdit={canEdit}
|
||
onChange={v => { setSdLevel(v); handleSaveLogging('sd', v) }}
|
||
/>
|
||
<LogSlider
|
||
label="MQTT Sync"
|
||
level={mqttLevel}
|
||
color={getLogLevelMeta(mqttLevel)?.color || 'var(--color-text-muted)'}
|
||
canEdit={canEdit}
|
||
onChange={v => { setMqttLevel(v); handleSaveLogging('mqtt', v) }}
|
||
/>
|
||
</div>
|
||
</GlassCard>
|
||
|
||
{/* Network Info */}
|
||
<GlassCard
|
||
title="Network Info"
|
||
fullHeight
|
||
titleAction={canEdit && (
|
||
<EditActionButton onClick={onEditAttributes} />
|
||
)}
|
||
>
|
||
<div style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: '1fr 1fr',
|
||
gap: 'var(--space-4)',
|
||
flex: 1,
|
||
alignContent: 'start',
|
||
}}>
|
||
{netFields.map(({ label, value }) => (
|
||
<div key={label} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
|
||
<SectionLabel>{label}</SectionLabel>
|
||
<span style={{
|
||
fontSize: 'var(--font-size-sm)',
|
||
fontFamily: 'var(--font-family-mono)',
|
||
color: value === '—' ? 'var(--color-text-muted)' : 'var(--color-text-primary)',
|
||
fontWeight: 'var(--font-weight-medium)',
|
||
}}>
|
||
{value}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</GlassCard>
|
||
</div>
|
||
|
||
{/* ════════════════════════════════════════════════════════════════════
|
||
ROW 3 — Tags & Notes (full width)
|
||
════════════════════════════════════════════════════════════════════ */}
|
||
<GlassCard title="Tags & Notes">
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-5)' }}>
|
||
|
||
{/* Tags row */}
|
||
<div>
|
||
<div style={{
|
||
fontSize: 'var(--font-size-xs)',
|
||
fontWeight: 'var(--font-weight-semibold)',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: 'var(--tracking-wide)',
|
||
color: 'var(--color-text-muted)',
|
||
marginBottom: 'var(--space-3)',
|
||
}}>
|
||
Tags
|
||
</div>
|
||
<TagsRow
|
||
tags={tags || []}
|
||
canEdit={canEdit}
|
||
onAdd={handleAddTag}
|
||
onRemove={handleRemoveTag}
|
||
saving={savingTags}
|
||
/>
|
||
</div>
|
||
|
||
{/* Divider */}
|
||
<div style={{ height: 1, background: 'var(--color-border)' }} />
|
||
|
||
{/* Device Notes */}
|
||
<div>
|
||
<div style={{
|
||
fontSize: 'var(--font-size-xs)',
|
||
fontWeight: 'var(--font-weight-semibold)',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: 'var(--tracking-wide)',
|
||
color: 'var(--color-text-muted)',
|
||
marginBottom: 'var(--space-3)',
|
||
}}>
|
||
Device Notes
|
||
</div>
|
||
<DeviceNotesSection deviceId={id} canEdit={canEdit} />
|
||
</div>
|
||
|
||
</div>
|
||
</GlassCard>
|
||
|
||
</div>
|
||
)
|
||
}
|