Files
bellsystems-cp/frontend/src/pages/bellcloud/devices/tabs/GeneralTab.jsx
T

1173 lines
42 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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>
)
}