Initial Switch to V2. Completely Overhauled Backend, Frontend and General Structure.

This commit is contained in:
2026-04-17 14:45:30 +03:00
parent eb773c5531
commit 0a8a42d69b
447 changed files with 70696 additions and 492 deletions
@@ -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} />
</>
)
}