hvac/frontend/src/components/KioskModal.tsx
jtricerolph 2db7d5f4d0 Add kiosk wall-panel control view
Full-screen /kiosk route, separate from the staff SPA: dedicated long-lived
kiosk login (own password, hashed, mints its own hnf_session cookie locally
via CENTRAL_AUTH_SECRET -- no central auth changes needed), scrollable grid
of small mode/setpoint blocks per live-controllable device (shelly_trv,
mhi_mqtt, daikin), tap-to-edit modal reusing the existing per-type control
panels, and a type-aware bulk multiselect (separate TRV/AC sections, only
applied to matching selected devices, TRV commands staggered).

- backend/src/routes/kiosk.js: public POST /api/kiosk/login
- backend/src/routes/settings.js: admin set/check kiosk password (bcrypt)
- backend/src/routes/config.js: exclude/protect kiosk_password_hash
- frontend/src/pages/Kiosk.tsx + components/Kiosk*: the kiosk UI
- reuses GET /api/status for the combined device list -- no new status
  endpoint needed since the kiosk token now also carries the 'view' cap

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 20:44:50 +00:00

55 lines
2 KiB
TypeScript

import { useEffect, useRef, useState } from 'react'
import { X } from 'lucide-react'
// Auto-dismiss back to the device grid after this long with no interaction — same
// pattern as weather's DayModal (this is also a kiosk: a modal left open by
// whoever last tapped it shouldn't sit there indefinitely). Longer than weather's
// 20s since dialling in a setpoint takes more thought than glancing at a forecast.
const IDLE_MS = 30_000
interface KioskModalProps {
title: string
onClose: () => void
children: React.ReactNode
wide?: boolean
}
export default function KioskModal({ title, onClose, children, wide }: KioskModalProps) {
const [resetTick, setResetTick] = useState(0)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
function reset() {
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(onClose, IDLE_MS)
setResetTick(t => t + 1)
}
reset()
// pointerdown covers touch + mouse + stylus in one; deliberately no mousemove.
const events = ['pointerdown', 'keydown', 'wheel'] as const
events.forEach(e => window.addEventListener(e, reset))
window.addEventListener('scroll', reset, true) // capture — scroll doesn't bubble
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
events.forEach(e => window.removeEventListener(e, reset))
window.removeEventListener('scroll', reset, true)
}
}, [onClose])
return (
<div className="modal-overlay" onClick={onClose}>
<div className={`modal${wide ? ' modal-lg' : ''}`} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>{title}</h2>
<button className="modal-close" onClick={onClose} aria-label="Close">
<X size={20} strokeWidth={1.75} />
</button>
</div>
{children}
<div className="kiosk-modal-timeout-track">
<div className="kiosk-modal-timeout-bar" key={resetTick} />
</div>
</div>
</div>
)
}