Initial Switch to V2. Completely Overhauled Backend, Frontend and General Structure.
This commit is contained in:
@@ -0,0 +1,643 @@
|
||||
// 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 { useToast } from '@/components/ui/Toast'
|
||||
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'
|
||||
}
|
||||
|
||||
// ─── 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,
|
||||
onDeviceUpdated,
|
||||
}) {
|
||||
const [now, setNow] = useState(new Date())
|
||||
const [showSync, setShowSync] = useState(false)
|
||||
const [flashFields, setFlashFields] = useState(false)
|
||||
const [togglingClock, setTogglingClock] = useState(false)
|
||||
const { toast } = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(new Date()), 1000)
|
||||
return () => clearInterval(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 = clock?.ringAlertsMasterOn ?? false
|
||||
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 = attr?.timezone || device?.timezone || null
|
||||
const ntpServer = attr?.ntpServer || attr?.networkSettings?.ntpServer || null
|
||||
const autoDst = attr?.autoDST ?? false
|
||||
|
||||
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
|
||||
|
||||
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 {
|
||||
const updated = await api.put(`/devices/${deviceId}`, {
|
||||
device_attributes: { ...(attr || {}), hasClock: newValue },
|
||||
})
|
||||
if (onDeviceUpdated) {
|
||||
// If API returns the full device object, use it; otherwise patch manually
|
||||
if (updated?.device_attributes !== undefined) {
|
||||
onDeviceUpdated(updated)
|
||||
} else {
|
||||
onDeviceUpdated(prev => ({
|
||||
...prev,
|
||||
device_attributes: { ...(prev?.device_attributes || {}), hasClock: newValue },
|
||||
}))
|
||||
}
|
||||
}
|
||||
toast.success(newValue ? 'Clock enabled' : 'Clock disabled', '')
|
||||
} catch (err) {
|
||||
toast.danger('Error', err.message || 'Failed to update clock status.')
|
||||
} 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="Auto-DST"
|
||||
badge={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
||||
<StatusBadge variant="neutral">{autoDst ? 'Enabled' : 'Disabled'}</StatusBadge>
|
||||
<span style={{
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
color: 'var(--color-text-muted)',
|
||||
backgroundColor: 'rgba(28, 32, 38, 0.40)',
|
||||
padding: '2px var(--space-2)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid var(--color-border)',
|
||||
}}>
|
||||
Coming Soon
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</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)' }}>
|
||||
Timezone and NTP configuration are managed via device attributes. Auto-DST adjustment is pending implementation.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SyncClockModal open={showSync} onClose={() => setShowSync(false)} device={device} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user