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>
55 lines
2 KiB
TypeScript
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>
|
|
)
|
|
}
|