Scaffold lights app: Tasmota MQTT control, schedules with sunrise/sunset triggers

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-10-14 12:33:57 +00:00
commit 371582cbd6
41 changed files with 10283 additions and 0 deletions

13
frontend/Dockerfile Normal file
View file

@ -0,0 +1,13 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
ARG VITE_HOTEL_NAME
ENV VITE_HOTEL_NAME=$VITE_HOTEL_NAME
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html/lights
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

18
frontend/index.html Normal file
View file

@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Lighting" />
<link rel="apple-touch-icon" href="/lights/icons/icon-192.png" />
<meta name="theme-color" content="#b45309" />
<title>Lighting</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

40
frontend/nginx.conf Normal file
View file

@ -0,0 +1,40 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
client_max_body_size 12m;
location /lights/api/auth/ {
proxy_pass http://10.10.10.101:3001/api/auth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /lights/api/ {
proxy_pass http://backend:3001/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
add_header Cache-Control "no-store";
}
location /lights/health {
proxy_pass http://backend:3001/health;
}
location ~* /lights/.*\.(js|css|png|ico|svg|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
location /lights/ {
add_header Cache-Control "no-cache" always;
try_files $uri /lights/index.html;
}
location = / {
return 301 /lights/;
}
}

6243
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

25
frontend/package.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "hnf-lights-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2",
"vite": "^6.0.5",
"vite-plugin-pwa": "^1.3.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

41
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,41 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import AuthGate, { useAuth } from './components/AuthGate'
import IosInstallHint from './components/IosInstallHint'
import Layout from './components/Layout'
import Dashboard from './pages/Dashboard'
import Schedules from './pages/Schedules'
import Devices from './pages/Devices'
import Log from './pages/Log'
import Settings from './pages/Settings'
import { can } from './types'
function Home() {
const { user } = useAuth()
if (can(user, 'view')) return <Navigate to="/dashboard" replace />
if (can(user, 'manage_devices')) return <Navigate to="/devices" replace />
if (can(user, 'settings')) return <Navigate to="/settings" replace />
return <div className="page"><p className="muted">You don't have access to any Lighting pages yet.</p></div>
}
export default function App() {
return (
<>
<BrowserRouter basename="/lights">
<AuthGate>
<Layout>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/schedules" element={<Schedules />} />
<Route path="/devices" element={<Devices />} />
<Route path="/log" element={<Log />} />
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>
</AuthGate>
</BrowserRouter>
<IosInstallHint />
</>
)
}

54
frontend/src/api.ts Normal file
View file

@ -0,0 +1,54 @@
import type {
StateResponse, LightDevice, LightGroup, Schedule, ScheduleInput, DiscoveredDevice, LightEvent, AppSetting, PowerState,
} from './types'
const BASE = '/lights/api'
async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...opts.headers },
...opts,
})
if (res.status === 401) {
;(window.top ?? window).location.href = '/login'
throw new Error('Unauthenticated')
}
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || `Request failed: ${res.status}`)
}
return res.json()
}
const json = (method: string, body: unknown): RequestInit => ({ method, body: JSON.stringify(body) })
// State + control
export const fetchState = (): Promise<StateResponse> => request('/state')
export const setDevicePower = (id: number, state: PowerState) => request(`/devices/${id}/power`, json('POST', { state }))
export const setGroupPower = (id: number, state: PowerState) => request(`/groups/${id}/power`, json('POST', { state }))
export const reapplySchedules = (): Promise<{ applied: number }> => request('/reconcile', { method: 'POST' })
// Devices
export interface DeviceInput { name?: string; location?: string | null; mqtt_topic?: string; channel?: number; active?: boolean }
export const fetchDiscovered = (): Promise<DiscoveredDevice[]> => request('/devices/discovered')
export const createDevice = (body: DeviceInput): Promise<LightDevice> => request('/devices', json('POST', body))
export const updateDevice = (id: number, body: DeviceInput): Promise<LightDevice> => request(`/devices/${id}`, json('PATCH', body))
export const deleteDevice = (id: number) => request(`/devices/${id}`, { method: 'DELETE' })
// Groups
export const createGroup = (name: string, device_ids: number[]): Promise<LightGroup> => request('/groups', json('POST', { name, device_ids }))
export const updateGroup = (id: number, name: string, device_ids: number[]): Promise<LightGroup> => request(`/groups/${id}`, json('PATCH', { name, device_ids }))
export const deleteGroup = (id: number) => request(`/groups/${id}`, { method: 'DELETE' })
// Schedules
export const fetchSchedules = (): Promise<Schedule[]> => request('/schedules')
export const createSchedule = (body: ScheduleInput): Promise<Schedule> => request('/schedules', json('POST', body))
export const updateSchedule = (id: number, body: Partial<ScheduleInput>): Promise<Schedule> => request(`/schedules/${id}`, json('PATCH', body))
export const deleteSchedule = (id: number) => request(`/schedules/${id}`, { method: 'DELETE' })
// Log + settings
export const fetchEvents = (limit = 200): Promise<LightEvent[]> => request(`/events?limit=${limit}`)
export const getSettings = (): Promise<{ settings: AppSetting[] }> => request('/settings')
export const saveSettings = (settings: { key: string; value: string }[]) => request('/settings', json('PUT', { settings }))
export const fetchMqttStatus = (): Promise<{ connected: boolean; note: string }> => request('/settings/mqtt-status')

View file

@ -0,0 +1,169 @@
import { useEffect, useRef, useState, createContext, useContext } from 'react'
import type { User } from '../types'
function getInactivityMs(): number | null {
if (window.matchMedia('(display-mode: standalone)').matches) return null
const c = document.cookie.split(';').map(s => s.trim()).find(s => s.startsWith('hnf_inactivity_mins='))
if (!c) return null
const mins = parseInt(c.split('=')[1])
return isNaN(mins) || mins <= 0 ? null : mins * 60 * 1000
}
// Only bounce to the central login when actually embedded in the portal shell.
// A directly-opened browser tab must never navigate away from its own scope.
function isEmbedded() {
return window.top !== window
}
interface AuthCtx { user: User }
const Ctx = createContext<AuthCtx | null>(null)
export function useAuth() {
const ctx = useContext(Ctx)
if (!ctx) throw new Error('useAuth must be used inside AuthGate')
return ctx
}
export default function AuthGate({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<'checking' | 'authed' | 'login'>('checking')
const [user, setUser] = useState<User | null>(null)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
fetch('/api/auth/verify?app=lights', { credentials: 'include' })
.then(async r => {
if (r.ok) {
setUser(await r.json())
setState('authed')
} else if (isEmbedded()) {
window.top!.location.href = `/login?from=${encodeURIComponent('/app/lights')}`
} else {
setState('login')
}
})
.catch(() => { if (!isEmbedded()) setState('login') })
}, [])
// Inactivity auto-logout — configurable per device (Admin Settings → Device).
useEffect(() => {
const ms = getInactivityMs()
if (state !== 'authed' || !ms) return
const timeoutMs: number = ms
async function forceLogout() {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
setUser(null)
setState('login')
}
function reset() {
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(forceLogout, timeoutMs)
}
const events = ['mousemove', 'keydown', 'click', 'touchstart'] as const
events.forEach(e => window.addEventListener(e, reset, { passive: true }))
reset()
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
events.forEach(e => window.removeEventListener(e, reset))
}
}, [state])
async function login(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError('')
try {
const res = await fetch('/api/auth/login', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
if (!res.ok) { setError('Invalid email or password'); return }
const verify = await fetch('/api/auth/verify?app=lights', { credentials: 'include' })
if (verify.ok) {
setUser(await verify.json())
setState('authed')
} else {
setError("You don't have access to this app.")
}
} catch {
setError('Connection error — please try again')
} finally {
setLoading(false)
}
}
if (state === 'checking') {
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
height: '100vh', fontFamily: 'var(--font)', color: 'var(--text-mid)'
}}>
Loading…
</div>
)
}
if (state === 'login') {
return (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
justifyContent: 'center', height: '100dvh', padding: '1.5rem',
background: 'var(--navy-dark)',
}}>
<div style={{
background: 'var(--navy)', borderRadius: 'var(--radius)',
padding: '2rem', width: '100%', maxWidth: '360px',
border: '1px solid var(--surface-2)',
}}>
<h1 style={{ fontSize: '1.4rem', marginBottom: '0.25rem', color: 'var(--gold)' }}>
Lighting
</h1>
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', marginBottom: '1.5rem' }}>
{import.meta.env.VITE_HOTEL_NAME}
</p>
<form onSubmit={login} style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<input
type="email" value={email} onChange={e => setEmail(e.target.value)}
placeholder="Email" required autoComplete="email"
style={inputStyle}
/>
<input
type="password" value={password} onChange={e => setPassword(e.target.value)}
placeholder="Password" required autoComplete="current-password"
style={inputStyle}
/>
{error && <p style={{ color: 'var(--danger)', fontSize: '0.875rem' }}>{error}</p>}
<button type="submit" disabled={loading} style={{
background: loading ? 'var(--surface-2)' : 'var(--gold)',
color: loading ? 'var(--text-muted)' : 'var(--navy-dark)',
border: 'none', borderRadius: '6px', padding: '0.625rem',
fontSize: '1rem', fontWeight: 600, marginTop: '0.25rem',
}}>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form>
</div>
</div>
)
}
return (
<Ctx.Provider value={{ user: user! }}>
{children}
</Ctx.Provider>
)
}
const inputStyle: React.CSSProperties = {
background: 'var(--navy-dark)', border: '1px solid var(--surface-2)',
borderRadius: '6px', color: 'var(--text)', padding: '0.625rem 0.75rem',
fontSize: '1rem', width: '100%', outline: 'none',
}

View file

@ -0,0 +1,89 @@
import { useEffect, useState } from 'react'
import { Share, ExternalLink, X } from 'lucide-react'
function isIos() {
return /iPad|iPhone|iPod/.test(navigator.userAgent) ||
// iPadOS 13+ reports as 'MacIntel' but has touch support, unlike a real Mac
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
}
function isStandalone() {
return window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as unknown as { standalone?: boolean }).standalone === true
}
// Safari's UA also matches "Safari", so this checks for the other iOS
// browsers/in-app webviews that spoof it — none of them can add to the
// home screen; only Safari itself can.
function isNonSafariIosBrowser() {
return /CriOS|FxiOS|EdgiOS|OPiOS|mercury|GSA|DuckDuckGo|Instagram|FBAN|FBAV|Line\//.test(navigator.userAgent)
}
/**
* iOS has no `beforeinstallprompt` API — Safari never fires it, so the
* portal's Install button (?install=1) does nothing there. This shows the
* manual steps instead: Share -> Add to Home Screen in Safari, or a prompt
* to switch to Safari first if the page was opened in another browser/app.
*/
export default function IosInstallHint() {
const [mode, setMode] = useState<'safari' | 'other' | null>(null)
useEffect(() => {
if (!isIos() || isStandalone()) return
if (!new URLSearchParams(window.location.search).has('install')) return
setMode(isNonSafariIosBrowser() ? 'other' : 'safari')
}, [])
if (!mode) return null
return (
<div style={{
position: 'fixed', inset: 0, zIndex: 9999,
background: 'rgba(15,15,32,0.55)',
display: 'flex', alignItems: 'flex-end', justifyContent: 'center',
padding: '0 16px 24px',
}}>
<div style={{
position: 'relative',
width: '100%', maxWidth: '360px',
background: 'var(--card-bg)', borderRadius: 'var(--radius)',
border: '1px solid var(--card-border)', boxShadow: 'var(--shadow-md)',
padding: '20px', textAlign: 'center',
}}>
<button
onClick={() => setMode(null)}
aria-label="Dismiss"
style={{
position: 'absolute', top: '10px', right: '10px',
background: 'none', border: 'none', color: 'var(--text-mid)',
padding: '4px', cursor: 'pointer', lineHeight: 0,
}}
>
<X size={16} strokeWidth={1.75} />
</button>
<div style={{
width: '44px', height: '44px', margin: '0 auto 12px',
borderRadius: '50%', background: 'var(--gold)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{mode === 'safari'
? <Share size={22} strokeWidth={1.75} color="var(--navy)" />
: <ExternalLink size={22} strokeWidth={1.75} color="var(--navy)" />}
</div>
{mode === 'safari' ? (
<p style={{ fontSize: '14px', color: 'var(--text-dark)', lineHeight: 1.5, margin: 0 }}>
To install this app, tap the <strong>Share</strong> icon in Safari's
toolbar, then choose <strong>Add to Home Screen</strong>.
</p>
) : (
<p style={{ fontSize: '14px', color: 'var(--text-dark)', lineHeight: 1.5, margin: 0 }}>
iOS only allows installing apps from <strong>Safari</strong>. Open this
page in Safari, then tap Share → <strong>Add to Home Screen</strong>.
</p>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,74 @@
import { useState, useEffect } from 'react'
import { NavLink, useLocation } from 'react-router-dom'
import { Lightbulb, LayoutGrid, CalendarClock, Cpu, History, Settings, Menu, LogOut } from 'lucide-react'
import { useAuth } from './AuthGate'
import { can } from '../types'
const ICON_PROPS = { size: 16, strokeWidth: 1.75 }
const NAV = [
{ to: '/dashboard', label: 'Control', icon: LayoutGrid, cap: 'view' },
{ to: '/schedules', label: 'Schedules', icon: CalendarClock, cap: 'view' },
{ to: '/devices', label: 'Devices', icon: Cpu, cap: 'manage_devices' },
{ to: '/log', label: 'Activity', icon: History, cap: 'view' },
{ to: '/settings', label: 'Settings', icon: Settings, cap: 'settings' },
]
export default function Layout({ children }: { children: React.ReactNode }) {
const { user } = useAuth()
const items = NAV.filter(n => can(user, n.cap))
const location = useLocation()
const [menuOpen, setMenuOpen] = useState(false)
async function logout() {
await fetch('/lights/api/auth/logout', { method: 'POST', credentials: 'include' })
window.location.reload()
}
useEffect(() => { setMenuOpen(false) }, [location.pathname])
return (
<div className={`app-shell${menuOpen ? ' menu-open' : ''}`}>
<aside className="sidebar">
<div className="sidebar-logo">
<Lightbulb size={18} strokeWidth={1.75} />
Lighting
</div>
<nav className="sidebar-nav">
{items.map(({ to, label, icon: Icon }) => (
<NavLink key={to} to={to} className={({ isActive }) => isActive ? 'active' : ''}>
<Icon {...ICON_PROPS} />
{label}
</NavLink>
))}
</nav>
<div className="sidebar-user" style={{ whiteSpace: 'normal' }}>
<div style={{ fontWeight: 600, color: 'var(--text)', fontSize: '12px', marginBottom: '2px' }}>{user.name}</div>
<div style={{ fontSize: '11px', marginBottom: '8px' }}>{user.email}</div>
<button onClick={logout} style={{
display: 'flex', alignItems: 'center', gap: '6px',
background: 'none', border: 'none', color: 'inherit',
fontSize: '12px', padding: 0, cursor: 'pointer',
}}>
<LogOut size={13} strokeWidth={1.75} />
Sign out
</button>
</div>
</aside>
{menuOpen && <div className="menu-backdrop" onClick={() => setMenuOpen(false)} />}
<header className="top-bar">
<button className="top-bar-burger" onClick={() => setMenuOpen(o => !o)}>
<Menu size={20} strokeWidth={1.75} />
</button>
<Lightbulb size={18} strokeWidth={1.75} color="var(--gold)" />
<span className="top-bar-title">Lighting</span>
</header>
<main className="page-content">
{children}
</main>
</div>
)
}

370
frontend/src/index.css Normal file
View file

@ -0,0 +1,370 @@
/* Stack design system tokens — include verbatim in every app */
:root {
--navy: #1a1a2e;
--navy-dark: #0f0f20;
--gold: #c9a84c;
--gold-light: #e8c96d;
--surface: rgba(255,255,255,0.07);
--surface-2: rgba(255,255,255,0.08);
--text: rgba(255,255,255,0.88);
--text-muted: rgba(255,255,255,0.48);
--body-bg: #f4f5f7;
--card-bg: #ffffff;
--card-border: #e4e8ee;
--text-dark: #1e293b;
--text-mid: #64748b;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.07), 0 1px 2px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
--danger: #dc2626;
--radius: 10px;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
body { background: var(--body-bg); color: var(--text-dark); font-family: var(--font); }
/* App theme + semantic tokens */
:root {
--app-primary: #b45309;
--app-primary-light: #0891b2;
--sev-warning: #d97706;
--sev-critical: #dc2626;
--sev-ok: #16a34a;
--danger-bg: #fef2f2;
--warn-bg: #fffbeb;
--ok-bg: #f0fdf4;
--sidebar-w: 240px;
--topbar-h: 56px;
}
*, *::before, *::after { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; font-size: 14px; }
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--card-border); border-radius: 2px; }
/* ── App shell ─────────────────────────────────────────────── */
.app-shell { display: flex; height: 100vh; overflow: hidden; }
.sidebar {
width: var(--sidebar-w);
background: var(--navy);
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow-y: auto;
}
.sidebar-logo {
padding: 20px 16px 12px;
color: var(--gold);
font-size: 13px;
font-weight: 600;
letter-spacing: .05em;
text-transform: uppercase;
display: flex;
align-items: center;
gap: 8px;
}
.sidebar-logo svg { opacity: .8; }
.sidebar-nav { flex: 1; padding: 8px 0; }
.sidebar-nav a {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
color: var(--text-muted);
text-decoration: none;
font-size: 13.5px;
transition: background .15s, color .15s;
}
.sidebar-nav a:hover { background: var(--surface); color: var(--text); }
.sidebar-nav a.active { background: rgba(201,168,76,.1); color: var(--gold); }
.sidebar-user {
padding: 12px 16px;
border-top: 1px solid var(--surface-2);
color: var(--text-muted);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.top-bar {
display: none;
height: var(--topbar-h);
background: var(--navy);
color: var(--text);
align-items: center;
padding: 0 12px;
gap: 10px;
flex-shrink: 0;
}
.top-bar-title { flex: 1; font-size: 15px; font-weight: 600; color: var(--gold); }
.page-content { flex: 1; overflow-y: auto; display: flex; flex-direction: column; }
@media (max-width: 768px) {
.sidebar {
position: fixed;
top: 0; left: 0; bottom: 0;
z-index: 200;
transform: translateX(calc(-1 * var(--sidebar-w)));
transition: transform 0.25s ease;
}
.app-shell.menu-open .sidebar { transform: translateX(0); }
.top-bar { display: flex; }
.app-shell { flex-direction: column; }
.field-row { flex-direction: column; }
}
.menu-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 199;
}
.top-bar-burger {
background: none;
border: none;
color: var(--text);
cursor: pointer;
display: flex;
align-items: center;
padding: 4px;
flex-shrink: 0;
}
/* ── Page chrome ───────────────────────────────────────────── */
.page { padding: 20px; max-width: 1100px; width: 100%; margin: 0 auto; }
.page-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.page-header h1 { font-size: 18px; margin: 0; flex: 1; }
/* ── Buttons ───────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--card-border);
background: var(--card-bg);
color: var(--text-dark);
border-radius: var(--radius);
padding: 7px 14px;
font-size: 13px;
cursor: pointer;
font-family: var(--font);
transition: background .12s, border-color .12s;
}
.btn:hover { border-color: var(--text-mid); }
.btn:disabled { opacity: .5; cursor: default; }
.btn-primary { background: var(--gold); border-color: var(--gold); color: var(--navy); font-weight: 600; }
.btn-primary:hover { background: var(--gold-light); border-color: var(--gold-light); }
.btn-danger { background: var(--danger); border-color: var(--danger); color: #fff; }
.btn-sm { padding: 4px 10px; font-size: 12px; border-radius: 8px; }
/* ── Forms ─────────────────────────────────────────────────── */
.field { margin-bottom: 12px; }
.field label { display: block; font-size: 12px; font-weight: 600; color: var(--text-mid); margin-bottom: 4px; }
.field input[type="text"], .field input[type="email"], .field input[type="date"],
.field input[type="number"], .field select, .field textarea {
width: 100%;
border: 1px solid var(--card-border);
border-radius: 8px;
padding: 8px 10px;
font-size: 13.5px;
font-family: var(--font);
color: var(--text-dark);
background: var(--card-bg);
}
.field textarea { min-height: 72px; resize: vertical; }
.field-row { display: flex; gap: 12px; }
.field-row > .field { flex: 1; }
.field-check { display: flex; align-items: center; gap: 8px; font-size: 13.5px; cursor: pointer; }
.field-check input { width: 16px; height: 16px; accent-color: var(--gold); }
.field-hint { font-size: 11.5px; color: var(--text-mid); margin-top: 3px; }
/* ── Cards ─────────────────────────────────────────────────── */
.card {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
padding: 14px 16px;
margin-bottom: 10px;
}
/* ── Asset grid / cards ────────────────────────────────────── */
.asset-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
.asset-card {
border-left: 4px solid var(--sev-ok);
}
.asset-card.sev-warning { border-left-color: var(--sev-warning); }
.asset-card.sev-critical { border-left-color: var(--sev-critical); }
.asset-card-title { font-weight: 600; font-size: 14px; margin-bottom: 4px; display: flex; align-items: center; gap: 6px; justify-content: space-between; }
.asset-card-meta { font-size: 12px; color: var(--text-mid); margin-bottom: 8px; }
.asset-field-list { display: flex; flex-direction: column; gap: 4px; }
.asset-field-row { display: flex; justify-content: space-between; gap: 8px; font-size: 12.5px; }
.asset-field-key { color: var(--text-mid); }
.asset-field-value { font-weight: 600; color: var(--text-dark); }
/* ── Badges ────────────────────────────────────────────────── */
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
border-radius: 20px;
padding: 2px 9px;
font-size: 11px;
font-weight: 600;
color: #fff;
white-space: nowrap;
}
.badge-sev-warning { background: var(--sev-warning); }
.badge-sev-critical { background: var(--sev-critical); }
.badge-sev-ok { background: var(--sev-ok); }
.badge-status-open { background: var(--sev-critical); }
.badge-status-acknowledged { background: var(--sev-warning); }
.badge-status-resolved { background: var(--sev-ok); }
.badge-outline {
background: transparent;
border: 1px solid var(--card-border);
color: var(--text-mid);
font-weight: 500;
}
/* ── Modal ─────────────────────────────────────────────────── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(15,15,32,.55);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 24px 12px;
z-index: 100;
overflow-y: auto;
}
.modal {
background: var(--card-bg);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
width: 100%;
max-width: 680px;
padding: 20px;
margin: auto 0;
}
.modal-header { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 14px; }
.modal-header h2 { font-size: 16px; margin: 0; flex: 1; }
.modal-close {
background: none;
border: none;
cursor: pointer;
color: var(--text-mid);
padding: 2px;
display: flex;
}
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; flex-wrap: wrap; }
/* ── Photos ────────────────────────────────────────────────── */
@keyframes spin { to { transform: rotate(360deg); } }
.spin { animation: spin .75s linear infinite; }
/* ── Tables ────────────────────────────────────────────────── */
.table-wrap { overflow-x: auto; background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius); box-shadow: var(--shadow-sm); }
table.data { width: 100%; border-collapse: collapse; font-size: 13px; }
table.data th {
text-align: left;
padding: 9px 12px;
font-size: 11.5px;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--text-mid);
border-bottom: 1px solid var(--card-border);
white-space: nowrap;
}
table.data td { padding: 9px 12px; border-bottom: 1px solid var(--card-border); vertical-align: top; }
table.data tr:last-child td { border-bottom: none; }
table.data tr.clickable { cursor: pointer; }
/* ── Misc ──────────────────────────────────────────────────── */
.empty-state { text-align: center; color: var(--text-mid); padding: 40px 16px; font-size: 13.5px; }
.error-banner {
background: var(--danger-bg);
border: 1px solid var(--danger);
color: var(--danger);
border-radius: var(--radius);
padding: 10px 14px;
margin-bottom: 12px;
font-size: 13px;
}
.ok-banner {
background: var(--ok-bg);
border: 1px solid var(--sev-ok);
color: var(--sev-ok);
border-radius: var(--radius);
padding: 10px 14px;
margin-bottom: 12px;
font-size: 13px;
}
.warn-banner {
background: var(--warn-bg);
border: 1px solid var(--sev-warning);
color: var(--sev-warning);
border-radius: var(--radius);
padding: 10px 14px;
margin-bottom: 12px;
font-size: 13px;
}
.section-title { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--text-mid); margin: 18px 0 8px; }
.muted { color: var(--text-mid); }
/* Sidebar scrollbar */
.sidebar::-webkit-scrollbar, .sidebar-nav::-webkit-scrollbar { width: 4px; }
.sidebar::-webkit-scrollbar-track, .sidebar-nav::-webkit-scrollbar-track { background: transparent; }
.sidebar::-webkit-scrollbar-thumb, .sidebar-nav::-webkit-scrollbar-thumb { background: rgba(201,168,76,0.35); border-radius: 2px; }
.sidebar::-webkit-scrollbar-thumb:hover, .sidebar-nav::-webkit-scrollbar-thumb:hover { background: rgba(201,168,76,0.65); }
.sidebar, .sidebar-nav { scrollbar-width: thin; scrollbar-color: rgba(201,168,76,0.35) transparent; }
/* ── Lights ─────────────────────────────────────────────────── */
.field input[type="time"] {
width: 100%; padding: 8px 10px; font-size: 13.5px; font-family: inherit;
border: 1px solid var(--card-border); border-radius: 8px; background: var(--card-bg); color: var(--text-dark);
}
.tile-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; }
.tile {
background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius);
box-shadow: var(--shadow-sm); padding: 12px 14px; display: flex; flex-direction: column; gap: 8px;
}
.tile.on { border-color: var(--gold); }
.tile.offline { opacity: .55; }
.tile-top { display: flex; align-items: center; gap: 8px; }
.tile-name { font-weight: 600; font-size: 13.5px; flex: 1; min-width: 0; overflow-wrap: anywhere; }
.tile-meta { font-size: 11.5px; color: var(--text-mid); }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-mid); flex-shrink: 0; }
.dot.online { background: var(--sev-ok); }
.dot.offline { background: var(--sev-critical); }
.switch {
position: relative; width: 44px; height: 24px; border-radius: 12px; border: none; cursor: pointer;
background: var(--card-border); flex-shrink: 0; transition: background .15s;
}
.switch::after {
content: ''; position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%;
background: #fff; box-shadow: var(--shadow-sm); transition: transform .15s;
}
.switch.on { background: var(--gold); }
.switch.on::after { transform: translateX(20px); }
.switch:disabled { opacity: .5; cursor: default; }
.day-picker { display: flex; gap: 6px; flex-wrap: wrap; }
.day-picker button {
width: 38px; height: 32px; border-radius: 8px; border: 1px solid var(--card-border);
background: var(--card-bg); color: var(--text-dark); font-size: 12px; cursor: pointer;
}
.day-picker button.sel { background: var(--navy); border-color: var(--navy); color: #fff; }
.check-list { display: flex; flex-direction: column; gap: 6px; max-height: 240px; overflow-y: auto; padding: 4px 0; }
.sun-strip { display: flex; gap: 16px; align-items: center; font-size: 13px; color: var(--text-mid); flex-wrap: wrap; }
.sun-strip span { display: inline-flex; align-items: center; gap: 5px; }

19
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,19 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
// When opened from the portal's install button (?install=1), trigger the
// PWA install prompt as soon as the browser offers it (Chrome/Edge only).
if (new URLSearchParams(window.location.search).has('install')) {
window.addEventListener('beforeinstallprompt', e => {
e.preventDefault()
;(e as Event & { prompt: () => Promise<void> }).prompt()
}, { once: true })
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

View file

@ -0,0 +1,138 @@
import { useEffect, useRef, useState } from 'react'
import { Sunrise, Sunset, WifiOff, RefreshCw, Lightbulb, LightbulbOff } from 'lucide-react'
import { useAuth } from '../components/AuthGate'
import { fetchState, setDevicePower, setGroupPower, reapplySchedules } from '../api'
import type { StateResponse, LightDevice, PowerState } from '../types'
import { can } from '../types'
const POLL_MS = 5000
function fmtTime(iso: string | null, tz: string) {
if (!iso) return '—'
return new Date(iso).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', timeZone: tz })
}
export default function Dashboard() {
const { user } = useAuth()
const canControl = can(user, 'control')
const [data, setData] = useState<StateResponse | null>(null)
const [error, setError] = useState('')
const [msg, setMsg] = useState('')
const [busy, setBusy] = useState<string | null>(null)
const timer = useRef<number>()
async function load() {
try { setData(await fetchState()); setError('') }
catch (e) { setError(e instanceof Error ? e.message : 'Failed to load') }
}
useEffect(() => {
load()
timer.current = window.setInterval(load, POLL_MS)
return () => window.clearInterval(timer.current)
}, [])
async function act(key: string, fn: () => Promise<unknown>) {
setBusy(key); setError('')
try { await fn() } catch (e) { setError(e instanceof Error ? e.message : 'Command failed') }
// The device confirms over MQTT a moment after the command.
setTimeout(() => { load(); setBusy(null) }, 1200)
}
async function reapply() {
setError(''); setMsg('')
try {
const { applied } = await reapplySchedules()
setMsg(applied ? `Corrected ${applied} light${applied === 1 ? '' : 's'} to match their schedules` : 'Everything already matches its schedule')
setTimeout(() => setMsg(''), 4000)
load()
} catch (e) { setError(e instanceof Error ? e.message : 'Failed') }
}
if (!data) return <div className="page">{error ? <div className="error-banner">{error}</div> : <p className="muted">Loading…</p>}</div>
const byId = new Map(data.devices.map(d => [d.id, d]))
const active = data.devices.filter(d => d.active)
const locations = [...new Set(active.map(d => d.location || 'Other'))]
function DeviceTile({ d }: { d: LightDevice }) {
const on = d.state === 'on'
const offline = d.online === false
return (
<div className={`tile${on ? ' on' : ''}${offline ? ' offline' : ''}`}>
<div className="tile-top">
<span className={`dot${d.online === true ? ' online' : offline ? ' offline' : ''}`} />
<span className="tile-name">{d.name}</span>
<button
className={`switch${on ? ' on' : ''}`}
disabled={!canControl || busy === `d${d.id}`}
aria-label={`Turn ${d.name} ${on ? 'off' : 'on'}`}
onClick={() => act(`d${d.id}`, () => setDevicePower(d.id, on ? 'off' : 'on'))}
/>
</div>
<div className="tile-meta">
{offline ? <><WifiOff size={11} strokeWidth={1.75} /> Offline</> : d.state === null ? 'State unknown' : on ? 'On' : 'Off'}
</div>
</div>
)
}
const groupBtn = (id: number, state: PowerState, label: string) => (
<button className="btn btn-sm" disabled={!canControl || busy === `g${id}`}
onClick={() => act(`g${id}`, () => setGroupPower(id, state))}>{label}</button>
)
return (
<div className="page">
<div className="page-header">
<h1>Lighting</h1>
<div className="sun-strip">
<span><Sunrise size={15} strokeWidth={1.75} /> {fmtTime(data.sun?.sunrise ?? null, data.timezone)}</span>
<span><Sunset size={15} strokeWidth={1.75} /> {fmtTime(data.sun?.sunset ?? null, data.timezone)}</span>
</div>
{canControl && (
<button className="btn btn-sm" onClick={reapply} title="Set every light to what its schedules say it should be right now (manual changes made since the last schedule event are kept)">
<RefreshCw size={13} strokeWidth={1.75} /> Re-apply schedules
</button>
)}
</div>
{error && <div className="error-banner">{error}</div>}
{msg && <div className="ok-banner">{msg}</div>}
{!data.mqtt_connected && <div className="warn-banner">The lighting controller can't reach the MQTT broker — switches below may be out of date and commands will fail.</div>}
{!data.sun && <div className="warn-banner">No site location set, so sunrise/sunset schedules won't run. Add latitude/longitude in Settings.</div>}
{data.groups.length > 0 && (
<>
<div className="section-title">Groups</div>
<div className="tile-grid">
{data.groups.map(g => {
const members = g.device_ids.map(id => byId.get(id)).filter((d): d is LightDevice => !!d)
const onCount = members.filter(d => d.state === 'on').length
return (
<div key={g.id} className={`tile${onCount ? ' on' : ''}`}>
<div className="tile-top">
<span className="tile-name">{g.name}</span>
{onCount ? <Lightbulb size={16} strokeWidth={1.75} color="var(--gold)" /> : <LightbulbOff size={16} strokeWidth={1.75} color="var(--text-mid)" />}
</div>
<div className="tile-meta">{onCount} of {members.length} on</div>
{canControl && <div style={{ display: 'flex', gap: 6 }}>{groupBtn(g.id, 'on', 'All on')}{groupBtn(g.id, 'off', 'All off')}</div>}
</div>
)
})}
</div>
</>
)}
{active.length === 0 && <div className="empty-state">No lights set up yet. Add them under Devices.</div>}
{locations.map(loc => (
<div key={loc}>
<div className="section-title">{loc}</div>
<div className="tile-grid">
{active.filter(d => (d.location || 'Other') === loc).map(d => <DeviceTile key={d.id} d={d} />)}
</div>
</div>
))}
</div>
)
}

View file

@ -0,0 +1,196 @@
import { useEffect, useState } from 'react'
import { Plus, Pencil, Trash2, X, Radar } from 'lucide-react'
import { fetchState, fetchDiscovered, createDevice, updateDevice, deleteDevice, createGroup, updateGroup, deleteGroup } from '../api'
import type { StateResponse, LightDevice, LightGroup, DiscoveredDevice } from '../types'
interface DeviceForm { id: number | null; name: string; location: string; mqtt_topic: string; channel: number }
interface GroupForm { id: number | null; name: string; device_ids: number[] }
export default function Devices() {
const [data, setData] = useState<StateResponse | null>(null)
const [discovered, setDiscovered] = useState<DiscoveredDevice[]>([])
const [error, setError] = useState('')
const [deviceForm, setDeviceForm] = useState<DeviceForm | null>(null)
const [groupForm, setGroupForm] = useState<GroupForm | null>(null)
const [saving, setSaving] = useState(false)
function load() {
fetchState().then(setData).catch(e => setError(e.message))
fetchDiscovered().then(setDiscovered).catch(() => {})
}
useEffect(() => {
load()
const t = window.setInterval(load, 10000)
return () => window.clearInterval(t)
}, [])
async function run(fn: () => Promise<unknown>, done: () => void) {
setSaving(true); setError('')
try { await fn(); done(); load() }
catch (e) { setError(e instanceof Error ? e.message : 'Failed') }
finally { setSaving(false) }
}
const saveDevice = (e: React.FormEvent) => {
e.preventDefault()
if (!deviceForm) return
const body = { name: deviceForm.name, location: deviceForm.location || null, mqtt_topic: deviceForm.mqtt_topic.trim(), channel: deviceForm.channel }
run(() => deviceForm.id ? updateDevice(deviceForm.id, body) : createDevice(body), () => setDeviceForm(null))
}
const saveGroup = (e: React.FormEvent) => {
e.preventDefault()
if (!groupForm) return
run(() => groupForm.id ? updateGroup(groupForm.id, groupForm.name, groupForm.device_ids) : createGroup(groupForm.name, groupForm.device_ids), () => setGroupForm(null))
}
const removeDevice = (d: LightDevice) => {
if (confirm(`Remove "${d.name}"? Any schedules targeting it are deleted too.`)) run(() => deleteDevice(d.id), () => {})
}
const removeGroup = (g: LightGroup) => {
if (confirm(`Delete group "${g.name}"? Any schedules targeting it are deleted too (the lights themselves are kept).`)) run(() => deleteGroup(g.id), () => {})
}
if (!data) return <div className="page">{error ? <div className="error-banner">{error}</div> : <p className="muted">Loading…</p>}</div>
return (
<div className="page">
<div className="page-header">
<h1>Devices</h1>
<button className="btn btn-primary" onClick={() => setDeviceForm({ id: null, name: '', location: '', mqtt_topic: '', channel: 1 })}>
<Plus size={14} strokeWidth={1.75} /> Add light
</button>
</div>
{error && <div className="error-banner">{error}</div>}
{discovered.length > 0 && (
<>
<div className="section-title"><Radar size={13} strokeWidth={1.75} style={{ verticalAlign: -2 }} /> Found on the broker, not added yet</div>
<div className="table-wrap">
<table className="data">
<tbody>
{discovered.map(d => (
<tr key={d.topic}>
<td><code>{d.topic}</code></td>
<td className="muted">{d.channels.length > 1 ? `${d.channels.length} relays` : '1 relay'}</td>
<td className="muted">{d.online === false ? 'Offline' : 'Online'}</td>
<td style={{ textAlign: 'right' }}>
<button className="btn btn-sm" onClick={() => setDeviceForm({ id: null, name: d.topic, location: '', mqtt_topic: d.topic, channel: d.channels[0] ?? 1 })}>
<Plus size={13} strokeWidth={1.75} /> Add
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
<div className="section-title">Lights</div>
{data.devices.length === 0 ? <div className="empty-state">No lights yet. Add one, or pick one found on the broker above.</div> : (
<div className="table-wrap">
<table className="data">
<thead><tr><th>Name</th><th>Location</th><th>Tasmota topic</th><th>Status</th><th></th></tr></thead>
<tbody>
{data.devices.map(d => (
<tr key={d.id} style={{ opacity: d.active ? 1 : 0.5 }}>
<td>{d.name}</td>
<td>{d.location ?? '—'}</td>
<td><code>{d.mqtt_topic}{d.channel > 1 ? ` · relay ${d.channel}` : ''}</code></td>
<td>{!d.active ? 'Disabled' : d.online === false ? 'Offline' : d.state ? d.state.toUpperCase() : 'Unknown'}</td>
<td style={{ whiteSpace: 'nowrap', textAlign: 'right' }}>
<button className="btn btn-sm" onClick={() => setDeviceForm({ id: d.id, name: d.name, location: d.location ?? '', mqtt_topic: d.mqtt_topic, channel: d.channel })}><Pencil size={13} strokeWidth={1.75} /></button>{' '}
<button className="btn btn-sm" onClick={() => removeDevice(d)}><Trash2 size={13} strokeWidth={1.75} /></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="section-title" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span>Groups</span>
<button className="btn btn-sm" onClick={() => setGroupForm({ id: null, name: '', device_ids: [] })}><Plus size={13} strokeWidth={1.75} /> Add group</button>
</div>
{data.groups.length === 0 ? <div className="empty-state">Groups let one button or schedule control several lights, e.g. "Car park" or "Reception".</div> : (
<div className="table-wrap">
<table className="data">
<tbody>
{data.groups.map(g => (
<tr key={g.id}>
<td>{g.name}</td>
<td className="muted">{g.device_ids.map(id => data.devices.find(d => d.id === id)?.name).filter(Boolean).join(', ') || 'No lights'}</td>
<td style={{ whiteSpace: 'nowrap', textAlign: 'right' }}>
<button className="btn btn-sm" onClick={() => setGroupForm({ id: g.id, name: g.name, device_ids: g.device_ids })}><Pencil size={13} strokeWidth={1.75} /></button>{' '}
<button className="btn btn-sm" onClick={() => removeGroup(g)}><Trash2 size={13} strokeWidth={1.75} /></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{deviceForm && (
<div className="modal-overlay" onClick={() => setDeviceForm(null)}>
<form className="modal" onClick={e => e.stopPropagation()} onSubmit={saveDevice}>
<div className="modal-header">
<h2>{deviceForm.id ? 'Edit light' : 'Add light'}</h2>
<button type="button" className="modal-close" onClick={() => setDeviceForm(null)}><X size={18} strokeWidth={1.75} /></button>
</div>
<div className="field"><label>Name</label>
<input type="text" required value={deviceForm.name} onChange={e => setDeviceForm({ ...deviceForm, name: e.target.value })} placeholder="Car park – north post" />
</div>
<div className="field"><label>Location</label>
<input type="text" value={deviceForm.location} onChange={e => setDeviceForm({ ...deviceForm, location: e.target.value })} placeholder="Car park" />
<div className="field-hint">Lights are grouped by location on the control screen.</div>
</div>
<div className="field-row">
<div className="field"><label>Tasmota topic</label>
<input type="text" required value={deviceForm.mqtt_topic} onChange={e => setDeviceForm({ ...deviceForm, mqtt_topic: e.target.value })} placeholder="tasmota_A1B2C3" />
<div className="field-hint">The switch's MQTT "Topic" (Tasmota → Configuration → MQTT).</div>
</div>
<div className="field"><label>Relay</label>
<input type="number" min={1} max={8} required value={deviceForm.channel} onChange={e => setDeviceForm({ ...deviceForm, channel: Number(e.target.value) })} />
<div className="field-hint">1 unless it's a multi-relay switch.</div>
</div>
</div>
<div className="modal-actions">
<button type="button" className="btn" onClick={() => setDeviceForm(null)}>Cancel</button>
<button type="submit" className="btn btn-primary" disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
</div>
</form>
</div>
)}
{groupForm && (
<div className="modal-overlay" onClick={() => setGroupForm(null)}>
<form className="modal" onClick={e => e.stopPropagation()} onSubmit={saveGroup}>
<div className="modal-header">
<h2>{groupForm.id ? 'Edit group' : 'Add group'}</h2>
<button type="button" className="modal-close" onClick={() => setGroupForm(null)}><X size={18} strokeWidth={1.75} /></button>
</div>
<div className="field"><label>Name</label>
<input type="text" required value={groupForm.name} onChange={e => setGroupForm({ ...groupForm, name: e.target.value })} placeholder="Car park" />
</div>
<div className="field"><label>Lights in this group</label>
<div className="check-list">
{data.devices.map(d => (
<label key={d.id} className="field-check">
<input type="checkbox" checked={groupForm.device_ids.includes(d.id)}
onChange={e => setGroupForm({ ...groupForm, device_ids: e.target.checked ? [...groupForm.device_ids, d.id] : groupForm.device_ids.filter(x => x !== d.id) })} />
{d.name}{d.location ? <span className="muted"> · {d.location}</span> : null}
</label>
))}
</div>
</div>
<div className="modal-actions">
<button type="button" className="btn" onClick={() => setGroupForm(null)}>Cancel</button>
<button type="submit" className="btn btn-primary" disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
</div>
</form>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,43 @@
import { useEffect, useState } from 'react'
import { fetchEvents } from '../api'
import type { LightEvent } from '../types'
const SOURCE_LABELS: Record<LightEvent['source'], string> = {
manual: 'Manual', schedule: 'Schedule', reconcile: 'Catch-up', physical: 'Wall switch / device',
}
export default function Log() {
const [events, setEvents] = useState<LightEvent[]>([])
const [error, setError] = useState('')
useEffect(() => {
const load = () => fetchEvents().then(setEvents).catch(e => setError(e.message))
load()
const t = window.setInterval(load, 15000)
return () => window.clearInterval(t)
}, [])
return (
<div className="page">
<div className="page-header"><h1>Activity</h1></div>
{error && <div className="error-banner">{error}</div>}
{events.length === 0 ? <div className="empty-state">Nothing has changed yet.</div> : (
<div className="table-wrap">
<table className="data">
<thead><tr><th>When</th><th>Light</th><th>Change</th><th>Cause</th></tr></thead>
<tbody>
{events.map(ev => (
<tr key={ev.id}>
<td>{new Date(ev.at).toLocaleString('en-GB', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })}</td>
<td>{ev.device_name}</td>
<td>{ev.action === 'on' ? 'Turned on' : 'Turned off'}</td>
<td>{SOURCE_LABELS[ev.source]}{ev.detail ? <span className="muted"> · {ev.detail}</span> : null}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,217 @@
import { useEffect, useState } from 'react'
import { Plus, Pencil, Trash2, X, Sunrise, Sunset, Clock } from 'lucide-react'
import { useAuth } from '../components/AuthGate'
import { fetchSchedules, fetchState, createSchedule, updateSchedule, deleteSchedule } from '../api'
import type { Schedule, ScheduleInput, StateResponse, TriggerType, PowerState } from '../types'
import { can, DAY_LABELS } from '../types'
const ALL_DAYS = [0, 1, 2, 3, 4, 5, 6]
const BLANK: ScheduleInput = {
name: '', enabled: true, target_type: 'group', target_id: 0, action: 'on',
trigger_type: 'sunset', time_of_day: '18:00', offset_minutes: 0, days: ALL_DAYS,
}
function describeTrigger(s: Schedule) {
if (s.trigger_type === 'fixed') return s.time_of_day ?? ''
const base = s.trigger_type === 'sunrise' ? 'Sunrise' : 'Sunset'
if (!s.offset_minutes) return base
return `${base} ${s.offset_minutes < 0 ? '−' : '+'} ${Math.abs(s.offset_minutes)} min`
}
function describeDays(days: number[]) {
if (days.length === 7) return 'Every day'
if (days.length === 5 && [1, 2, 3, 4, 5].every(d => days.includes(d))) return 'Mon–Fri'
if (days.length === 2 && days.includes(0) && days.includes(6)) return 'Weekends'
return days.map(d => DAY_LABELS[d]).join(', ')
}
export default function Schedules() {
const { user } = useAuth()
const canEdit = can(user, 'schedules')
const [schedules, setSchedules] = useState<Schedule[]>([])
const [state, setState] = useState<StateResponse | null>(null)
const [error, setError] = useState('')
const [editing, setEditing] = useState<{ id: number | null; form: ScheduleInput } | null>(null)
const [saving, setSaving] = useState(false)
function load() {
fetchSchedules().then(setSchedules).catch(e => setError(e.message))
fetchState().then(setState).catch(() => {})
}
useEffect(load, [])
const targetName = (s: { target_type: string; target_id: number }) => {
if (!state) return '…'
const list = s.target_type === 'group' ? state.groups : state.devices
return list.find(t => t.id === s.target_id)?.name ?? '(deleted)'
}
function openNew() {
const first = state?.groups[0] ? { target_type: 'group' as const, target_id: state.groups[0].id }
: { target_type: 'device' as const, target_id: state?.devices[0]?.id ?? 0 }
setEditing({ id: null, form: { ...BLANK, ...first } })
}
function openEdit(s: Schedule) {
setEditing({
id: s.id,
form: {
name: s.name, enabled: s.enabled, target_type: s.target_type, target_id: s.target_id, action: s.action,
trigger_type: s.trigger_type, time_of_day: s.time_of_day ?? '18:00', offset_minutes: s.offset_minutes, days: s.days,
},
})
}
async function save(e: React.FormEvent) {
e.preventDefault()
if (!editing) return
setSaving(true); setError('')
try {
const f = editing.form
const body: ScheduleInput = f.trigger_type === 'fixed'
? { ...f, offset_minutes: 0 } : { ...f, time_of_day: undefined }
if (editing.id) await updateSchedule(editing.id, body); else await createSchedule(body)
setEditing(null); load()
} catch (err) { setError(err instanceof Error ? err.message : 'Save failed') }
finally { setSaving(false) }
}
async function toggle(s: Schedule) {
await updateSchedule(s.id, { enabled: !s.enabled }).catch(e => setError(e.message))
load()
}
async function remove(s: Schedule) {
if (!confirm(`Delete schedule "${s.name}"?`)) return
await deleteSchedule(s.id).catch(e => setError(e.message))
load()
}
const set = (patch: Partial<ScheduleInput>) => setEditing(ed => ed && { ...ed, form: { ...ed.form, ...patch } })
const targets = editing && state ? (editing.form.target_type === 'group' ? state.groups : state.devices) : []
const TriggerIcon = ({ t }: { t: TriggerType }) =>
t === 'sunrise' ? <Sunrise size={14} strokeWidth={1.75} /> : t === 'sunset' ? <Sunset size={14} strokeWidth={1.75} /> : <Clock size={14} strokeWidth={1.75} />
return (
<div className="page">
<div className="page-header">
<h1>Schedules</h1>
{canEdit && <button className="btn btn-primary" onClick={openNew}><Plus size={14} strokeWidth={1.75} /> New schedule</button>}
</div>
{error && <div className="error-banner">{error}</div>}
{state && !state.sun && schedules.some(s => s.trigger_type !== 'fixed') && (
<div className="warn-banner">Sunrise/sunset schedules won't run until a site location is set in Settings.</div>
)}
{schedules.length === 0 ? <div className="empty-state">No schedules yet.</div> : (
<div className="table-wrap">
<table className="data">
<thead><tr><th>Name</th><th>Does</th><th>When</th><th>Days</th><th>Next run</th><th></th></tr></thead>
<tbody>
{schedules.map(s => (
<tr key={s.id} style={{ opacity: s.enabled ? 1 : 0.5 }}>
<td>{s.name}</td>
<td>Turn <strong>{targetName(s)}</strong> {s.action}</td>
<td><span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}><TriggerIcon t={s.trigger_type} />{describeTrigger(s)}</span></td>
<td>{describeDays(s.days)}</td>
<td>{s.next_run ? new Date(s.next_run).toLocaleString('en-GB', { weekday: 'short', hour: '2-digit', minute: '2-digit', timeZone: state?.timezone }) : '—'}</td>
<td style={{ whiteSpace: 'nowrap', textAlign: 'right' }}>
{canEdit && (
<>
<button className={`switch${s.enabled ? ' on' : ''}`} onClick={() => toggle(s)} aria-label="Enable or disable" style={{ marginRight: 8, verticalAlign: 'middle' }} />
<button className="btn btn-sm" onClick={() => openEdit(s)}><Pencil size={13} strokeWidth={1.75} /></button>{' '}
<button className="btn btn-sm" onClick={() => remove(s)}><Trash2 size={13} strokeWidth={1.75} /></button>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{editing && (
<div className="modal-overlay" onClick={() => setEditing(null)}>
<form className="modal" onClick={e => e.stopPropagation()} onSubmit={save}>
<div className="modal-header">
<h2>{editing.id ? 'Edit schedule' : 'New schedule'}</h2>
<button type="button" className="modal-close" onClick={() => setEditing(null)}><X size={18} strokeWidth={1.75} /></button>
</div>
<div className="field">
<label>Name</label>
<input type="text" required value={editing.form.name} onChange={e => set({ name: e.target.value })} placeholder="Car park on at dusk" />
</div>
<div className="field-row">
<div className="field">
<label>Turn</label>
<select value={editing.form.action} onChange={e => set({ action: e.target.value as PowerState })}>
<option value="on">On</option><option value="off">Off</option>
</select>
</div>
<div className="field">
<label>Target type</label>
<select value={editing.form.target_type} onChange={e => {
const t = e.target.value as 'device' | 'group'
const list = state ? (t === 'group' ? state.groups : state.devices) : []
set({ target_type: t, target_id: list[0]?.id ?? 0 })
}}>
<option value="group">Group</option><option value="device">Single light</option>
</select>
</div>
<div className="field">
<label>Target</label>
<select required value={editing.form.target_id} onChange={e => set({ target_id: Number(e.target.value) })}>
{targets.length === 0 && <option value="">None available</option>}
{targets.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
</div>
</div>
<div className="field-row">
<div className="field">
<label>When</label>
<select value={editing.form.trigger_type} onChange={e => set({ trigger_type: e.target.value as TriggerType })}>
<option value="fixed">At a fixed time</option>
<option value="sunrise">Sunrise</option>
<option value="sunset">Sunset</option>
</select>
</div>
{editing.form.trigger_type === 'fixed' ? (
<div className="field">
<label>Time</label>
<input type="time" required value={editing.form.time_of_day ?? ''} onChange={e => set({ time_of_day: e.target.value })} />
</div>
) : (
<div className="field">
<label>Offset (minutes)</label>
<input type="number" min={-720} max={720} value={editing.form.offset_minutes ?? 0} onChange={e => set({ offset_minutes: Number(e.target.value) })} />
<div className="field-hint">Negative = before, positive = after</div>
</div>
)}
</div>
<div className="field">
<label>Days</label>
<div className="day-picker">
{DAY_LABELS.map((label, d) => (
<button type="button" key={d} className={editing.form.days.includes(d) ? 'sel' : ''}
onClick={() => set({ days: editing.form.days.includes(d) ? editing.form.days.filter(x => x !== d) : [...editing.form.days, d].sort() })}>
{label}
</button>
))}
</div>
</div>
<label className="field-check">
<input type="checkbox" checked={editing.form.enabled} onChange={e => set({ enabled: e.target.checked })} /> Enabled
</label>
<div className="modal-actions">
<button type="button" className="btn" onClick={() => setEditing(null)}>Cancel</button>
<button type="submit" className="btn btn-primary" disabled={saving || editing.form.days.length === 0 || !editing.form.target_id}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</form>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,79 @@
import { useEffect, useState } from 'react'
import { Wifi, WifiOff } from 'lucide-react'
import { getSettings, saveSettings, fetchMqttStatus } from '../api'
const SETTING_LABELS: Record<string, { label: string; hint: string; placeholder?: string }> = {
latitude: {
label: 'Latitude',
hint: 'Site latitude in decimal degrees (e.g. 50.2). Needed for sunrise/sunset schedules.',
placeholder: '50.2',
},
longitude: {
label: 'Longitude',
hint: 'Site longitude in decimal degrees — negative is west (e.g. -5.1).',
placeholder: '-5.1',
},
timezone: {
label: 'Timezone',
hint: 'IANA timezone name. Decides what a fixed time like 18:00 means, and handles daylight saving.',
placeholder: 'Europe/London',
},
}
export default function Settings() {
const [values, setValues] = useState<Record<string, string>>({})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [msg, setMsg] = useState('')
const [mqttConnected, setMqttConnected] = useState<boolean | null>(null)
const [mqttNote, setMqttNote] = useState('')
useEffect(() => {
getSettings()
.then(({ settings }) => setValues(Object.fromEntries(settings.map(s => [s.key, s.value ?? '']))))
.catch(e => setError(e instanceof Error ? e.message : 'Failed to load settings'))
.finally(() => setLoading(false))
fetchMqttStatus().then(s => { setMqttConnected(s.connected); setMqttNote(s.note) }).catch(() => {})
}, [])
async function handleSave() {
setSaving(true); setError(''); setMsg('')
try {
await saveSettings(Object.entries(values).map(([key, value]) => ({ key, value })))
setMsg('Settings saved'); setTimeout(() => setMsg(''), 2500)
} catch (e) {
setError(e instanceof Error ? e.message : 'Save failed')
} finally {
setSaving(false)
}
}
if (loading) return <div className="page"><p className="muted">Loading…</p></div>
return (
<div className="page" style={{ maxWidth: 640 }}>
<div className="page-header"><h1>Settings</h1></div>
{error && <div className="error-banner">{error}</div>}
{msg && <div className="ok-banner">{msg}</div>}
<div className="card" style={{ marginBottom: 16, display: 'flex', alignItems: 'flex-start', gap: 10 }}>
{mqttConnected ? <Wifi size={18} strokeWidth={1.75} color="var(--sev-ok)" /> : <WifiOff size={18} strokeWidth={1.75} color="var(--sev-critical)" />}
<div>
<div style={{ fontWeight: 600, fontSize: 13.5 }}>MQTT broker: {mqttConnected === null ? 'checking…' : mqttConnected ? 'connected' : 'not connected'}</div>
<div className="field-hint">{mqttNote}</div>
</div>
</div>
{Object.entries(SETTING_LABELS).map(([key, meta]) => (
<div className="field" key={key}>
<label>{meta.label}</label>
<input type="text" value={values[key] ?? ''} placeholder={meta.placeholder}
onChange={e => setValues({ ...values, [key]: e.target.value })} />
<div className="field-hint">{meta.hint}</div>
</div>
))}
<button className="btn btn-primary" disabled={saving} onClick={handleSave}>{saving ? 'Saving…' : 'Save Settings'}</button>
</div>
)
}

96
frontend/src/types.ts Normal file
View file

@ -0,0 +1,96 @@
export type PowerState = 'on' | 'off'
export type TriggerType = 'fixed' | 'sunrise' | 'sunset'
export interface LightDevice {
id: number
name: string
location: string | null
mqtt_topic: string
channel: number
active: boolean
state: PowerState | null
online: boolean | null
state_at: string | null
}
export interface LightGroup {
id: number
name: string
device_ids: number[]
}
export interface SunTimes {
sunrise: string | null
sunset: string | null
}
export interface StateResponse {
devices: LightDevice[]
groups: LightGroup[]
mqtt_connected: boolean
sun: SunTimes | null
timezone: string
}
export interface Schedule {
id: number
name: string
enabled: boolean
target_type: 'device' | 'group'
target_id: number
action: PowerState
trigger_type: TriggerType
time_of_day: string | null
offset_minutes: number
days: number[]
next_run: string | null
}
export interface ScheduleInput {
name: string
enabled: boolean
target_type: 'device' | 'group'
target_id: number
action: PowerState
trigger_type: TriggerType
time_of_day?: string
offset_minutes?: number
days: number[]
}
export interface DiscoveredDevice {
topic: string
online: boolean | null
channels: number[]
last_seen: string
}
export interface LightEvent {
id: number
at: string
device_id: number | null
device_name: string
action: PowerState
source: 'manual' | 'schedule' | 'reconcile' | 'physical'
detail: string | null
}
export interface AppSetting {
key: string
value: string
updated_at: string
}
export const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
export interface User {
user_id: number
name: string
email: string
is_admin: boolean
caps: string[] // bare slugs — verify?app=lights strips the prefix
}
export function can(user: User, cap: string): boolean {
return user.is_admin || user.caps.includes(cap)
}

1
frontend/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

19
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["src"]
}

31
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,31 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
base: '/lights/',
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: 'Lighting',
short_name: 'Lighting',
start_url: '/lights/',
scope: '/lights/',
display: 'standalone',
theme_color: '#b45309',
background_color: '#b45309',
icons: [
{ src: '/lights/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/lights/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
],
},
workbox: {
navigateFallback: '/lights/index.html',
navigateFallbackDenylist: [/\/api\//],
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
},
}),
],
})