Adopts useDeviceCommand across DeviceDetail and its tabs so config changes (log levels, clock settings, bell outputs, alert thresholds, backlight, attributes) are sent to the device immediately via control commands and only persisted to Firestore once the device acks success, instead of writing Firestore first and hoping the device eventually picks it up. - GeneralTab: log-level sliders now call log.set_serial/sd/mqtt directly and revert on failure; a background log.get_config + network.info pull reconciles Firestore against the device's actual state once per mount - ClockTab, BellsTab, ControlTab, and the Edit* modals: same live-command-then-persist pattern - EditLoggingModal is removed — its job (log level editing) moved inline into GeneralTab's sliders, so a modal round-trip is no longer needed - DeviceDetail wires the shared useDeviceCommand connection through to each tab and adds a Health tab entry Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
724 lines
30 KiB
React
724 lines
30 KiB
React
// frontend/src/pages/bellcloud/devices/tabs/ClockTab.jsx
|
||
|
||
import { useState, useEffect, useRef } from 'react'
|
||
import api from '@/lib/api'
|
||
import Card from '@/components/ui/Card'
|
||
import Button from '@/components/ui/Button'
|
||
import StatusBadge from '@/components/ui/StatusBadge'
|
||
import SyncClockModal from '@/modals/bellcloud/devices/SyncClockModal'
|
||
|
||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
function extractTime(tsStr) {
|
||
if (!tsStr) return null
|
||
const d = new Date(
|
||
tsStr.replace(' at ', ' ').replace('UTC+0000', 'UTC').replace(/UTC\+(\d{4})/, 'UTC')
|
||
)
|
||
if (!isNaN(d.getTime())) {
|
||
return `${d.getUTCHours().toString().padStart(2, '0')}:${d.getUTCMinutes().toString().padStart(2, '0')}`
|
||
}
|
||
const match = tsStr.match(/(\d{1,2}):(\d{2})/)
|
||
return match ? `${match[1].padStart(2, '0')}:${match[2]}` : null
|
||
}
|
||
|
||
function msToSeconds(ms) {
|
||
if (ms == null || ms === 0) return null
|
||
return `${(ms / 1000).toFixed(1)}s`
|
||
}
|
||
|
||
function bellLabel(n) {
|
||
if (!n || n === 0) return 'Disabled'
|
||
return `Bell ${n}`
|
||
}
|
||
|
||
function outputLabel(n) {
|
||
if (!n || n === 0) return 'Disabled'
|
||
return `Output ${n}`
|
||
}
|
||
|
||
function silenceLabel(isOn, from, to) {
|
||
if (!isOn) return 'Off'
|
||
const f = extractTime(from)
|
||
const t = extractTime(to)
|
||
if (f && t) return `${f} – ${t}`
|
||
return 'On'
|
||
}
|
||
|
||
function formatUtcOffset(offsetSec) {
|
||
if (offsetSec == null) return null
|
||
const totalMinutes = offsetSec / 60
|
||
const sign = totalMinutes >= 0 ? '+' : '-'
|
||
const abs = Math.abs(totalMinutes)
|
||
const hours = Math.floor(abs / 60)
|
||
const minutes = abs % 60
|
||
return minutes === 0 ? `UTC${sign}${hours}` : `UTC${sign}${hours}:${String(minutes).padStart(2, '0')}`
|
||
}
|
||
|
||
// ─── Analog Clock Face ────────────────────────────────────────────────────────
|
||
|
||
function AnalogClock({ time, label, variant = 'primary' }) {
|
||
const canvasRef = useRef(null)
|
||
|
||
useEffect(() => {
|
||
const canvas = canvasRef.current
|
||
if (!canvas) return
|
||
const ctx = canvas.getContext('2d')
|
||
const size = canvas.width
|
||
const cx = size / 2
|
||
const cy = size / 2
|
||
const r = size / 2 - 4
|
||
|
||
const h = time.getHours() % 12
|
||
const m = time.getMinutes()
|
||
const s = time.getSeconds()
|
||
|
||
const isPrimary = variant === 'primary'
|
||
const accentColor = isPrimary ? 'rgba(192,193,255,0.9)' : 'rgba(192,193,255,0.35)'
|
||
const faceColor = isPrimary ? 'rgba(192,193,255,0.06)' : 'rgba(192,193,255,0.03)'
|
||
const rimColor = isPrimary ? 'rgba(192,193,255,0.25)' : 'rgba(192,193,255,0.10)'
|
||
const tickColor = isPrimary ? 'rgba(192,193,255,0.30)' : 'rgba(192,193,255,0.12)'
|
||
const hourHandColor = isPrimary ? 'rgba(192,193,255,1)' : 'rgba(192,193,255,0.45)'
|
||
const minuteHandColor = isPrimary ? 'rgba(210,187,255,0.85)' : 'rgba(210,187,255,0.35)'
|
||
const secondHandColor = isPrimary ? 'rgba(255,92,92,0.85)' : 'rgba(255,92,92,0.4)'
|
||
const centerDotColor = isPrimary ? '#c0c1ff' : 'rgba(192,193,255,0.4)'
|
||
|
||
ctx.clearRect(0, 0, size, size)
|
||
|
||
// Face
|
||
ctx.beginPath()
|
||
ctx.arc(cx, cy, r, 0, Math.PI * 2)
|
||
ctx.fillStyle = faceColor
|
||
ctx.fill()
|
||
|
||
// Outer glow for primary
|
||
if (isPrimary) {
|
||
const grad = ctx.createRadialGradient(cx, cy, r - 2, cx, cy, r + 8)
|
||
grad.addColorStop(0, 'rgba(192,193,255,0.12)')
|
||
grad.addColorStop(1, 'rgba(192,193,255,0)')
|
||
ctx.beginPath()
|
||
ctx.arc(cx, cy, r + 4, 0, Math.PI * 2)
|
||
ctx.fillStyle = grad
|
||
ctx.fill()
|
||
}
|
||
|
||
// Rim
|
||
ctx.beginPath()
|
||
ctx.arc(cx, cy, r, 0, Math.PI * 2)
|
||
ctx.strokeStyle = rimColor
|
||
ctx.lineWidth = isPrimary ? 1.5 : 1
|
||
ctx.stroke()
|
||
|
||
// Ticks
|
||
for (let i = 0; i < 60; i++) {
|
||
const angle = (i / 60) * Math.PI * 2 - Math.PI / 2
|
||
const isHour = i % 5 === 0
|
||
const inner = isHour ? r - 12 : r - 7
|
||
ctx.beginPath()
|
||
ctx.moveTo(cx + Math.cos(angle) * inner, cy + Math.sin(angle) * inner)
|
||
ctx.lineTo(cx + Math.cos(angle) * (r - 2), cy + Math.sin(angle) * (r - 2))
|
||
ctx.strokeStyle = isHour ? accentColor : tickColor
|
||
ctx.lineWidth = isHour ? 1.5 : 0.75
|
||
ctx.stroke()
|
||
}
|
||
|
||
// Hour hand
|
||
const hourAngle = ((h + m / 60) / 12) * Math.PI * 2 - Math.PI / 2
|
||
ctx.beginPath()
|
||
ctx.moveTo(cx, cy)
|
||
ctx.lineTo(cx + Math.cos(hourAngle) * r * 0.52, cy + Math.sin(hourAngle) * r * 0.52)
|
||
ctx.strokeStyle = hourHandColor
|
||
ctx.lineWidth = isPrimary ? 3 : 2
|
||
ctx.lineCap = 'round'
|
||
ctx.stroke()
|
||
|
||
// Minute hand
|
||
const minAngle = ((m + s / 60) / 60) * Math.PI * 2 - Math.PI / 2
|
||
ctx.beginPath()
|
||
ctx.moveTo(cx, cy)
|
||
ctx.lineTo(cx + Math.cos(minAngle) * r * 0.72, cy + Math.sin(minAngle) * r * 0.72)
|
||
ctx.strokeStyle = minuteHandColor
|
||
ctx.lineWidth = isPrimary ? 2 : 1.5
|
||
ctx.lineCap = 'round'
|
||
ctx.stroke()
|
||
|
||
// Second hand
|
||
const secAngle = (s / 60) * Math.PI * 2 - Math.PI / 2
|
||
ctx.beginPath()
|
||
ctx.moveTo(cx, cy)
|
||
ctx.lineTo(cx + Math.cos(secAngle) * r * 0.82, cy + Math.sin(secAngle) * r * 0.82)
|
||
ctx.strokeStyle = secondHandColor
|
||
ctx.lineWidth = 1
|
||
ctx.lineCap = 'round'
|
||
ctx.stroke()
|
||
|
||
// Center dot
|
||
ctx.beginPath()
|
||
ctx.arc(cx, cy, isPrimary ? 4 : 3, 0, Math.PI * 2)
|
||
ctx.fillStyle = centerDotColor
|
||
ctx.fill()
|
||
}, [time, variant])
|
||
|
||
const digitalTime = `${time.getHours().toString().padStart(2, '0')}:${time.getMinutes().toString().padStart(2, '0')}:${time.getSeconds().toString().padStart(2, '0')}`
|
||
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 'var(--space-3)' }}>
|
||
<span style={{
|
||
fontSize: 'var(--font-size-xs)',
|
||
fontWeight: 'var(--font-weight-semibold)',
|
||
letterSpacing: 'var(--tracking-wide)',
|
||
textTransform: 'uppercase',
|
||
color: variant === 'primary' ? 'var(--color-primary)' : 'var(--color-text-muted)',
|
||
}}>
|
||
{label}
|
||
</span>
|
||
<canvas
|
||
ref={canvasRef}
|
||
width={160}
|
||
height={160}
|
||
style={{ display: 'block' }}
|
||
aria-label={`${label}: ${digitalTime}`}
|
||
/>
|
||
<div style={{
|
||
fontFamily: 'var(--font-family-mono)',
|
||
fontSize: 'var(--font-size-base)',
|
||
fontWeight: 'var(--font-weight-medium)',
|
||
color: variant === 'primary' ? 'var(--color-text-primary)' : 'var(--color-text-muted)',
|
||
letterSpacing: '0.04em',
|
||
}}>
|
||
{digitalTime}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── SettingRow ───────────────────────────────────────────────────────────────
|
||
|
||
function SettingRow({ label, value, mono = false, badge }) {
|
||
return (
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 'var(--space-3)', minHeight: 32 }}>
|
||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', flexShrink: 0 }}>
|
||
{label}
|
||
</span>
|
||
<span style={{
|
||
fontSize: 'var(--font-size-sm)',
|
||
color: value === '—' || value === 'Disabled' || value === 'Off' ? 'var(--color-text-muted)' : 'var(--color-text-primary)',
|
||
fontFamily: mono ? 'var(--font-family-mono)' : 'inherit',
|
||
fontWeight: 'var(--font-weight-medium)',
|
||
textAlign: 'right',
|
||
}}>
|
||
{badge ? badge : value ?? '—'}
|
||
</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Divider() {
|
||
return (
|
||
<div style={{ height: 1, backgroundColor: 'var(--color-border)', margin: 'var(--space-1) 0' }} />
|
||
)
|
||
}
|
||
|
||
// ─── HeroTile ─────────────────────────────────────────────────────────────────
|
||
|
||
function HeroTile({ label, value, flash = false }) {
|
||
return (
|
||
<div style={{
|
||
backgroundColor: 'rgba(28, 32, 38, 0.40)',
|
||
backdropFilter: 'var(--blur-modal)',
|
||
WebkitBackdropFilter: 'var(--blur-modal)',
|
||
border: `1px solid ${flash ? 'var(--color-warning)' : 'var(--color-border)'}`,
|
||
borderRadius: 'var(--radius-lg)',
|
||
padding: 'var(--space-3) var(--space-4)',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: 'var(--space-1)',
|
||
transition: 'border-color 0.3s',
|
||
animation: flash ? 'tile-flash 0.6s ease 3' : 'none',
|
||
}}>
|
||
<span style={{
|
||
fontSize: 'var(--font-size-xs)',
|
||
fontWeight: 'var(--font-weight-semibold)',
|
||
letterSpacing: 'var(--tracking-wide)',
|
||
textTransform: 'uppercase',
|
||
color: flash ? 'var(--color-warning)' : 'var(--color-text-muted)',
|
||
transition: 'color 0.3s',
|
||
}}>
|
||
{label}
|
||
</span>
|
||
<span style={{
|
||
fontSize: 'var(--font-size-xl)',
|
||
fontWeight: 'var(--font-weight-bold)',
|
||
color: value ? 'var(--color-text-primary)' : 'var(--color-text-muted)',
|
||
fontFamily: 'var(--font-family-display)',
|
||
letterSpacing: 'var(--tracking-tight)',
|
||
lineHeight: 1.1,
|
||
fontStyle: value ? 'normal' : 'italic',
|
||
}}>
|
||
{value ?? 'not set'}
|
||
</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── ClockTab ─────────────────────────────────────────────────────────────────
|
||
|
||
export default function ClockTab({
|
||
device,
|
||
canEdit,
|
||
clock,
|
||
attr,
|
||
onEditClockSettings,
|
||
onEditAlerts,
|
||
onEditBacklight,
|
||
sendMqttCommand,
|
||
sendMqttCommandSilent,
|
||
loadDevice,
|
||
reloadDeviceSilent,
|
||
}) {
|
||
const [now, setNow] = useState(new Date())
|
||
const [showSync, setShowSync] = useState(false)
|
||
const [flashFields, setFlashFields] = useState(false)
|
||
const [togglingClock, setTogglingClock] = useState(false)
|
||
|
||
useEffect(() => {
|
||
const id = setInterval(() => setNow(new Date()), 1000)
|
||
return () => clearInterval(id)
|
||
}, [])
|
||
|
||
// ── Background refresh: pull true state from the device right after load ──
|
||
// Fires at most once per mount (hasSyncedRef guard — belt-and-suspenders
|
||
// against StrictMode's double-invoke or any parent re-render retriggering
|
||
// this effect). Reconciles enabled/outputs/timings/alerts/backlight/silence
|
||
// against Firestore using semantic (not raw-string) comparison, and silently
|
||
// corrects any real drift — no toast.
|
||
const hasSyncedRef = useRef(false)
|
||
useEffect(() => {
|
||
if (!sendMqttCommandSilent || !device) return
|
||
if (hasSyncedRef.current) return
|
||
hasSyncedRef.current = true
|
||
const deviceId = device.id || device.device_id
|
||
|
||
sendMqttCommandSilent('clock.get_config').then((reply) => {
|
||
const data = reply?.data || {}
|
||
if (!data || Object.keys(data).length === 0) return
|
||
|
||
const trueAlertType = { OFF: 'disabled', SINGLE: 'single', HOURS: 'multi' }[data.alert_type] ?? 'disabled'
|
||
const trueHourBell = data.hour_bell === 255 ? 0 : (data.hour_bell || 0)
|
||
const trueHalfBell = data.half_bell === 255 ? 0 : (data.half_bell || 0)
|
||
const trueQuarterBell = data.quarter_bell === 255 ? 0 : (data.quarter_bell || 0)
|
||
const trueC1 = data.c1output || 0
|
||
const trueC2 = data.c2output || 0
|
||
const truePulse = data.pulse_duration || 0
|
||
const truePause = data.pause_duration || 0
|
||
const trueBacklightOutput = data.backlight_output === 255 ? 0 : (data.backlight_output || 0)
|
||
|
||
// Semantic comparison — normalize times to HH:MM via extractTime so format
|
||
// differences (timezone suffix, seconds, etc.) never register as false drift.
|
||
const hasDrift =
|
||
!!data.enabled !== (attr?.hasClock ?? false) ||
|
||
trueC1 !== (clock?.clockOutputs?.[0] || 0) ||
|
||
trueC2 !== (clock?.clockOutputs?.[1] || 0) ||
|
||
truePulse !== (clock?.clockTimings?.[0] || 0) ||
|
||
truePause !== (clock?.clockTimings?.[1] || 0) ||
|
||
trueAlertType !== (clock?.ringAlerts || 'disabled') ||
|
||
(trueAlertType === 'multi' && data.alert_interval !== clock?.ringIntervals) ||
|
||
trueHourBell !== (clock?.hourAlertsBell || 0) ||
|
||
trueHalfBell !== (clock?.halfhourAlertsBell || 0) ||
|
||
trueQuarterBell !== (clock?.quarterAlertsBell || 0) ||
|
||
!!data.daytime_silence !== (clock?.isDaySilenceOn ?? false) ||
|
||
data.daytime_on !== extractTime(clock?.daySilenceFrom) ||
|
||
data.daytime_off !== extractTime(clock?.daySilenceTo) ||
|
||
!!data.night_silence !== (clock?.isNightSilenceOn ?? false) ||
|
||
data.night_on !== extractTime(clock?.nightSilenceFrom) ||
|
||
data.night_off !== extractTime(clock?.nightSilenceTo) ||
|
||
!!data.backlight !== (clock?.isBacklightAutomationOn ?? false) ||
|
||
trueBacklightOutput !== (clock?.backlightOutput || 0) ||
|
||
(!!data.backlight && data.backlight_on !== extractTime(clock?.backlightTurnOnTime)) ||
|
||
(!!data.backlight && data.backlight_off !== extractTime(clock?.backlightTurnOffTime)) ||
|
||
(data.gmt_offset_sec != null && data.gmt_offset_sec !== clock?.gmtOffsetSec) ||
|
||
(data.dst_offset_sec != null && data.dst_offset_sec !== clock?.dstOffsetSec) ||
|
||
(data.ntp_server != null && data.ntp_server !== clock?.ntpServer)
|
||
|
||
if (!hasDrift) return
|
||
|
||
api.put(`/devices/${deviceId}`, {
|
||
device_attributes: {
|
||
hasClock: !!data.enabled,
|
||
clockSettings: {
|
||
...clock,
|
||
clockOutputs: [trueC1, trueC2],
|
||
clockTimings: [truePulse, truePause],
|
||
ringAlerts: trueAlertType,
|
||
ringIntervals: data.alert_interval,
|
||
hourAlertsBell: trueHourBell,
|
||
halfhourAlertsBell: trueHalfBell,
|
||
quarterAlertsBell: trueQuarterBell,
|
||
isDaySilenceOn: !!data.daytime_silence,
|
||
daySilenceFrom: data.daytime_on ? `2000-01-01T${data.daytime_on}:00Z` : (clock?.daySilenceFrom || ''),
|
||
daySilenceTo: data.daytime_off ? `2000-01-01T${data.daytime_off}:00Z` : (clock?.daySilenceTo || ''),
|
||
isNightSilenceOn: !!data.night_silence,
|
||
nightSilenceFrom: data.night_on ? `2000-01-01T${data.night_on}:00Z` : (clock?.nightSilenceFrom || ''),
|
||
nightSilenceTo: data.night_off ? `2000-01-01T${data.night_off}:00Z` : (clock?.nightSilenceTo || ''),
|
||
isBacklightAutomationOn: !!data.backlight,
|
||
backlightOutput: trueBacklightOutput,
|
||
backlightTurnOnTime: data.backlight_on ? `2000-01-01T${data.backlight_on}:00Z` : '',
|
||
backlightTurnOffTime: data.backlight_off ? `2000-01-01T${data.backlight_off}:00Z` : '',
|
||
gmtOffsetSec: data.gmt_offset_sec ?? clock?.gmtOffsetSec,
|
||
dstOffsetSec: data.dst_offset_sec ?? clock?.dstOffsetSec,
|
||
ntpServer: data.ntp_server ?? clock?.ntpServer,
|
||
},
|
||
},
|
||
}).then(() => reloadDeviceSilent?.())
|
||
}).catch(() => {
|
||
// Device offline or didn't reply — keep showing the last-known Firestore values.
|
||
})
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [device?.id, device?.device_id])
|
||
|
||
// ── Derived values ────────────────────────────────────────────────────────
|
||
|
||
const hasClock = attr?.hasClock ?? false
|
||
const clockOutputs = clock?.clockOutputs || []
|
||
const clockTimings = clock?.clockTimings || []
|
||
const oddOut = clockOutputs[0] || 0
|
||
const evenOut = clockOutputs[1] || 0
|
||
const runPulse = clockTimings[0]
|
||
const pauseTime = clockTimings[1]
|
||
|
||
const alertType = clock?.ringAlerts || 'disabled'
|
||
const alertMasterOn = alertType !== 'disabled' // "master on" = alert_type is not OFF
|
||
const ringIntervals = clock?.ringIntervals
|
||
const hourBell = clock?.hourAlertsBell || 0
|
||
const halfBell = clock?.halfhourAlertsBell || 0
|
||
const quarterBell = clock?.quarterAlertsBell || 0
|
||
const isDaySilenceOn = clock?.isDaySilenceOn ?? false
|
||
const isNightSilenceOn = clock?.isNightSilenceOn ?? false
|
||
|
||
const backlightOn = clock?.isBacklightAutomationOn ?? false
|
||
const backlightOutput = clock?.backlightOutput || 0
|
||
const backlightOnTime = extractTime(clock?.backlightTurnOnTime)
|
||
const backlightOffTime = extractTime(clock?.backlightTurnOffTime)
|
||
|
||
const timezone = formatUtcOffset(clock?.gmtOffsetSec)
|
||
const ntpServer = clock?.ntpServer || null
|
||
const dstActive = !!clock?.dstOffsetSec
|
||
|
||
const alertTypeLabel =
|
||
alertType === 'single' ? 'Single Fire' :
|
||
alertType === 'multi' ? 'Hour Indicating' :
|
||
'Disabled'
|
||
|
||
const alertVariant = alertMasterOn ? 'success' : 'neutral'
|
||
|
||
// All 4 hero fields must be set before ENABLING the master switch
|
||
const allFieldsSet = oddOut > 0 && evenOut > 0 && runPulse > 0 && pauseTime > 0
|
||
|
||
// Sends clock.enable/clock.disable to the device first; Firestore (hasClock)
|
||
// is only updated once the device confirms SUCCESS. sendMqttCommand already
|
||
// shows the blue→green/red toast.
|
||
const handleMasterToggleClick = async () => {
|
||
if (!canEdit || togglingClock) return
|
||
|
||
// Trying to enable but required fields are missing — flash tiles and block
|
||
if (!hasClock && !allFieldsSet) {
|
||
setFlashFields(true)
|
||
setTimeout(() => setFlashFields(false), 2200)
|
||
return
|
||
}
|
||
|
||
const deviceId = device?.id || device?.device_id
|
||
const newValue = !hasClock
|
||
setTogglingClock(true)
|
||
try {
|
||
await sendMqttCommand(newValue ? 'clock.enable' : 'clock.disable')
|
||
await api.put(`/devices/${deviceId}`, {
|
||
device_attributes: { hasClock: newValue },
|
||
})
|
||
await loadDevice?.()
|
||
} catch {
|
||
// sendMqttCommand's toast already reported the failure — nothing further to do.
|
||
} finally {
|
||
setTogglingClock(false)
|
||
}
|
||
}
|
||
|
||
// Backlight period string
|
||
const backlightPeriod = backlightOn && backlightOnTime && backlightOffTime
|
||
? `${backlightOnTime} – ${backlightOffTime}`
|
||
: null
|
||
|
||
// ── Render ─────────────────────────────────────────────────────────────────
|
||
|
||
return (
|
||
<>
|
||
{/* Flash keyframe injected once */}
|
||
<style>{`
|
||
@keyframes tile-flash {
|
||
0%, 100% { border-color: var(--color-border); }
|
||
50% { border-color: var(--color-warning); box-shadow: 0 0 0 2px rgba(251,191,36,0.18); }
|
||
}
|
||
`}</style>
|
||
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)', width: '100%', maxWidth: 2000, margin: '0 auto' }}>
|
||
|
||
{/* ── Hero ──────────────────────────────────────────────────────────── */}
|
||
<div style={{
|
||
position: 'relative',
|
||
borderRadius: 'var(--radius-xl)',
|
||
backgroundColor: 'rgba(28, 32, 38, 0.30)',
|
||
backdropFilter: 'var(--blur-modal)',
|
||
WebkitBackdropFilter: 'var(--blur-modal)',
|
||
border: '1px solid var(--color-border)',
|
||
boxShadow: 'var(--shadow-card), var(--shadow-md)',
|
||
overflow: 'hidden',
|
||
padding: 'var(--space-6) var(--space-8)',
|
||
}}>
|
||
<div aria-hidden="true" style={{
|
||
position: 'absolute', inset: 0, pointerEvents: 'none',
|
||
background: 'radial-gradient(ellipse 50% 80% at 85% 50%, rgba(192,193,255,0.05) 0%, transparent 70%)',
|
||
}} />
|
||
|
||
<div style={{
|
||
position: 'relative',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 'var(--space-8)',
|
||
flexWrap: 'wrap',
|
||
}}>
|
||
|
||
{/* Left — settings panel */}
|
||
<div style={{ flex: 1, minWidth: 260, display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||
|
||
{/* Title + toggle */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
|
||
<span style={{
|
||
fontFamily: 'var(--font-family-display)',
|
||
fontSize: 'var(--font-size-xl)',
|
||
fontWeight: 'var(--font-weight-bold)',
|
||
letterSpacing: 'var(--tracking-tight)',
|
||
color: 'var(--color-text-primary)',
|
||
}}>
|
||
Master Control
|
||
</span>
|
||
<button
|
||
type="button"
|
||
role="switch"
|
||
aria-checked={hasClock}
|
||
aria-label="Toggle clock"
|
||
onClick={handleMasterToggleClick}
|
||
disabled={togglingClock}
|
||
style={{
|
||
position: 'relative',
|
||
width: 36,
|
||
height: 20,
|
||
borderRadius: 'var(--radius-full)',
|
||
backgroundColor: hasClock ? 'var(--color-success)' : 'var(--color-bg-island)',
|
||
border: `1px solid ${hasClock ? 'var(--color-success)' : 'var(--color-border-strong)'}`,
|
||
flexShrink: 0,
|
||
cursor: canEdit && !togglingClock ? 'pointer' : 'default',
|
||
transition: 'background-color 0.2s, border-color 0.2s',
|
||
padding: 0,
|
||
opacity: togglingClock ? 0.6 : 1,
|
||
}}
|
||
>
|
||
<div style={{
|
||
position: 'absolute',
|
||
top: 2,
|
||
left: hasClock ? 17 : 2,
|
||
width: 14,
|
||
height: 14,
|
||
borderRadius: 'var(--radius-full)',
|
||
backgroundColor: hasClock ? 'var(--color-text-inverse)' : 'var(--color-text-muted)',
|
||
transition: 'left 0.2s',
|
||
}} />
|
||
</button>
|
||
<span style={{
|
||
fontSize: 'var(--font-size-sm)',
|
||
color: hasClock ? 'var(--color-success)' : 'var(--color-text-muted)',
|
||
fontWeight: 'var(--font-weight-medium)',
|
||
}}>
|
||
{hasClock ? 'Active' : 'Inactive'}
|
||
</span>
|
||
</div>
|
||
|
||
{/* 2×2 tile grid */}
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)' }}>
|
||
<HeroTile label="Pulse Duration" value={msToSeconds(runPulse)} flash={flashFields && !runPulse} />
|
||
<HeroTile label="Pause Duration" value={msToSeconds(pauseTime)} flash={flashFields && !pauseTime} />
|
||
<HeroTile label="ODD Output" value={oddOut ? outputLabel(oddOut) : null} flash={flashFields && !oddOut} />
|
||
<HeroTile label="EVEN Output" value={evenOut ? outputLabel(evenOut) : null} flash={flashFields && !evenOut} />
|
||
</div>
|
||
|
||
{/* Actions row */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
|
||
{canEdit && (
|
||
<Button variant="ghost" size="sm" onClick={onEditClockSettings}>Edit Settings</Button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowSync(true)}
|
||
style={{
|
||
background: 'none', border: 'none',
|
||
padding: '0 var(--space-1)',
|
||
fontSize: 'var(--font-size-sm)',
|
||
color: 'var(--color-primary)',
|
||
cursor: 'pointer',
|
||
opacity: 0.75,
|
||
transition: 'opacity 0.15s',
|
||
}}
|
||
onMouseEnter={e => (e.currentTarget.style.opacity = 1)}
|
||
onMouseLeave={e => (e.currentTarget.style.opacity = 0.75)}
|
||
>
|
||
Sync Clocktower Time
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right — clocks, with equal breathing room on all sides */}
|
||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 'var(--space-12)', padding: '0 var(--space-8)' }}>
|
||
<AnalogClock time={now} label="RTC Time" variant="primary" />
|
||
<AnalogClock time={now} label="Clocktower" variant="primary" />
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Cards grid — 3 columns ────────────────────────────────────────── */}
|
||
<div className="masonry-grid masonry-grid--3">
|
||
|
||
{/* Alert Settings */}
|
||
<Card
|
||
title="Alert Settings"
|
||
action={canEdit && (
|
||
<Button variant="ghost" size="sm" onClick={onEditAlerts}>Edit</Button>
|
||
)}
|
||
>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||
|
||
{/* Master + type inline */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)', flexWrap: 'wrap' }}>
|
||
<StatusBadge variant={alertVariant}>{alertMasterOn ? 'Enabled' : 'Disabled'}</StatusBadge>
|
||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>·</span>
|
||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)', fontWeight: 'var(--font-weight-medium)' }}>
|
||
{alertTypeLabel}
|
||
</span>
|
||
{alertType === 'multi' && ringIntervals && (
|
||
<>
|
||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>·</span>
|
||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
|
||
Tempo {msToSeconds(ringIntervals)}
|
||
</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
<Divider />
|
||
|
||
{/* Bell assignments + silence periods — shared 3-column grid */}
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 'var(--space-2) var(--space-2)', rowGap: 'var(--space-4)' }}>
|
||
{[
|
||
{ label: 'Hours', value: bellLabel(hourBell) },
|
||
{ label: 'Half-Hours', value: bellLabel(halfBell) },
|
||
{ label: 'Quarters', value: bellLabel(quarterBell) },
|
||
].map(({ label, value }) => (
|
||
<div key={label} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
|
||
<span style={{
|
||
fontSize: 'var(--font-size-xs)',
|
||
color: 'var(--color-text-muted)',
|
||
letterSpacing: 'var(--tracking-wide)',
|
||
textTransform: 'uppercase',
|
||
}}>
|
||
{label}
|
||
</span>
|
||
<span style={{
|
||
fontSize: 'var(--font-size-base)',
|
||
fontWeight: 'var(--font-weight-semibold)',
|
||
color: value === 'Disabled' ? 'var(--color-text-muted)' : 'var(--color-text-primary)',
|
||
fontFamily: 'var(--font-family-display)',
|
||
letterSpacing: 'var(--tracking-tight)',
|
||
}}>
|
||
{value}
|
||
</span>
|
||
</div>
|
||
))}
|
||
|
||
{/* Silence periods in cols 1–2, col 3 intentionally empty */}
|
||
{[
|
||
{ label: 'Day Silence', isOn: isDaySilenceOn, from: clock?.daySilenceFrom, to: clock?.daySilenceTo },
|
||
{ label: 'Night Silence', isOn: isNightSilenceOn, from: clock?.nightSilenceFrom, to: clock?.nightSilenceTo },
|
||
].map(({ label, isOn, from, to }) => (
|
||
<div key={label} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
|
||
<span style={{
|
||
fontSize: 'var(--font-size-xs)',
|
||
color: 'var(--color-text-muted)',
|
||
letterSpacing: 'var(--tracking-wide)',
|
||
textTransform: 'uppercase',
|
||
}}>
|
||
{label}
|
||
</span>
|
||
<span style={{
|
||
fontSize: 'var(--font-size-base)',
|
||
fontWeight: 'var(--font-weight-semibold)',
|
||
color: isOn ? 'var(--color-text-primary)' : 'var(--color-text-muted)',
|
||
fontFamily: isOn ? 'var(--font-family-mono)' : 'inherit',
|
||
letterSpacing: isOn ? '0.03em' : 0,
|
||
}}>
|
||
{silenceLabel(isOn, from, to)}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
</div>
|
||
</Card>
|
||
|
||
{/* Backlight Automation */}
|
||
<Card
|
||
title="Backlight Automation"
|
||
action={canEdit && (
|
||
<Button variant="ghost" size="sm" onClick={onEditBacklight}>Edit</Button>
|
||
)}
|
||
>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
|
||
<SettingRow
|
||
label="Automation"
|
||
badge={<StatusBadge variant={backlightOn ? 'success' : 'neutral'}>{backlightOn ? 'Automated' : 'Disabled'}</StatusBadge>}
|
||
/>
|
||
<SettingRow label="Output" value={outputLabel(backlightOutput)} />
|
||
{backlightOn && (
|
||
<SettingRow
|
||
label="Period"
|
||
value={backlightPeriod || '—'}
|
||
mono={!!backlightPeriod}
|
||
/>
|
||
)}
|
||
</div>
|
||
</Card>
|
||
|
||
{/* Network & Time */}
|
||
<Card title="Network & Time">
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
|
||
<SettingRow label="Timezone" value={timezone || '—'} mono={!!timezone} />
|
||
<SettingRow label="NTP Server" value={ntpServer || '—'} mono={!!ntpServer} />
|
||
<SettingRow
|
||
label="DST"
|
||
badge={<StatusBadge variant={dstActive ? 'success' : 'neutral'}>{dstActive ? 'Active' : 'Not Active'}</StatusBadge>}
|
||
/>
|
||
</div>
|
||
{canEdit && (
|
||
<div style={{ marginTop: 'var(--space-4)', paddingTop: 'var(--space-4)', borderTop: '1px solid var(--color-border)' }}>
|
||
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
|
||
Read-only — reported directly by the device. Editing timezone/NTP settings from the console is not yet supported.
|
||
</p>
|
||
</div>
|
||
)}
|
||
</Card>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<SyncClockModal open={showSync} onClose={() => setShowSync(false)} device={device} />
|
||
</>
|
||
)
|
||
}
|