2025-10-08 10:56:42 -04:00
|
|
|
import React, { useEffect, useState, useRef, Suspense, lazy } from 'react';
|
2025-10-02 13:14:13 -04:00
|
|
|
import { API_URL } from '../config.js';
|
|
|
|
|
import { authFetch } from '../utils/authFetch.js';
|
2025-10-08 10:56:42 -04:00
|
|
|
import Wheel from '@uiw/react-color-wheel';
|
2025-10-02 13:14:13 -04:00
|
|
|
|
|
|
|
|
export default function Lighting() {
|
|
|
|
|
const [state, setState] = useState({ power: '', red: 0, blue: 0, green: 0, brightness: 100 });
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [error, setError] = useState('');
|
|
|
|
|
const [success, setSuccess] = useState(false);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
authFetch(`${API_URL}/lighting/state`)
|
|
|
|
|
.then(res => res.json())
|
|
|
|
|
.then(data => {
|
|
|
|
|
setState(data);
|
|
|
|
|
setLoading(false);
|
|
|
|
|
})
|
|
|
|
|
.catch(() => {
|
|
|
|
|
setError('Failed to fetch lighting state');
|
|
|
|
|
setLoading(false);
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
|
|
|
|
|
2025-10-08 10:56:42 -04:00
|
|
|
useEffect(() => {
|
|
|
|
|
setState({ power: '', red: 0, blue: 0, green: 0, brightness: 100 });
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const handleColorChange = (color) => {
|
|
|
|
|
console.log('Handle color change:', color);
|
|
|
|
|
const { r, g, b } = color.rgb;
|
|
|
|
|
updateLighting({
|
|
|
|
|
...state,
|
|
|
|
|
red: r,
|
|
|
|
|
green: g,
|
|
|
|
|
blue: b,
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2025-10-02 13:14:13 -04:00
|
|
|
const debounceRef = useRef();
|
2025-10-08 10:56:42 -04:00
|
|
|
|
|
|
|
|
const updateLighting = (newState) => {
|
|
|
|
|
setState(newState);
|
|
|
|
|
|
|
|
|
|
// Clear any pending timeout
|
|
|
|
|
if (debounceRef.current) {
|
|
|
|
|
clearTimeout(debounceRef.current);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Set new timeout for API call
|
2025-10-02 13:14:13 -04:00
|
|
|
debounceRef.current = setTimeout(() => {
|
|
|
|
|
authFetch(`${API_URL}/lighting/state`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
2025-10-08 10:56:42 -04:00
|
|
|
body: JSON.stringify(newState),
|
2025-10-02 13:14:13 -04:00
|
|
|
})
|
2025-10-08 10:56:42 -04:00
|
|
|
.then(() => setSuccess(true))
|
|
|
|
|
.catch(() => setError('Failed to update lighting state'));
|
|
|
|
|
}, 100); // 100ms debounce for 25 req/2s rate limit
|
2025-10-02 13:14:13 -04:00
|
|
|
};
|
|
|
|
|
|
2025-10-08 10:56:42 -04:00
|
|
|
const handleSubmit = (e) => {
|
2025-10-02 13:14:13 -04:00
|
|
|
e.preventDefault();
|
|
|
|
|
setLoading(true);
|
|
|
|
|
authFetch(`${API_URL}/lighting/state`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify(state),
|
|
|
|
|
})
|
2025-10-08 10:56:42 -04:00
|
|
|
.then(() => setSuccess(true))
|
|
|
|
|
.catch(() => setError('Failed to update lighting state'))
|
|
|
|
|
.finally(() => setLoading(false));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handlePowerToggle = () => {
|
|
|
|
|
const newPower = state.power === 'on' ? 'off' : 'on';
|
|
|
|
|
updateLighting({ ...state, power: newPower });
|
2025-10-02 13:14:13 -04:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Guard against undefined values
|
|
|
|
|
const colorPreview = `rgb(${state.red ?? 0},${state.green ?? 0},${state.blue ?? 0})`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="w-full min-h-[60vh] flex justify-center items-center mt-12">
|
|
|
|
|
<form
|
|
|
|
|
onSubmit={handleSubmit}
|
|
|
|
|
className="max-w-md w-full p-8 rounded-xl shadow bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 flex flex-col items-center justify-center"
|
|
|
|
|
style={{ margin: '0 auto' }}
|
|
|
|
|
>
|
|
|
|
|
<h2 className="text-2xl font-bold mb-6 text-center text-neutral-800 dark:text-neutral-200">Lighting Controls</h2>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center justify-between mb-8">
|
|
|
|
|
<span className="font-semibold text-lg mr-4">Power</span>
|
|
|
|
|
<label className="flex items-center gap-6 cursor-pointer select-none">
|
|
|
|
|
<div
|
|
|
|
|
className={`relative w-14 h-8 flex items-center bg-gray-300 dark:bg-gray-700 rounded-full p-1 transition-colors duration-300 ${state.power === 'on' ? 'bg-green-400' : 'bg-red-400'}`}
|
|
|
|
|
onClick={handlePowerToggle}
|
|
|
|
|
style={{ cursor: 'pointer' }}
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
className={`absolute left-1 top-1 w-6 h-6 rounded-full bg-white shadow-md transition-transform duration-300`}
|
|
|
|
|
style={{ transform: state.power === 'on' ? 'translateX(24px)' : 'translateX(0)' }}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<span className={`text-base font-bold min-w-[48px] text-center ${state.power === 'on' ? 'text-green-700' : 'text-red-700'}`}>{state.power === 'on' ? 'On' : 'Off'}</span>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="mb-8 flex flex-col items-center">
|
|
|
|
|
<label className="font-semibold mb-2">Color</label>
|
2025-10-08 10:56:42 -04:00
|
|
|
<div style={{
|
|
|
|
|
width: '180px',
|
|
|
|
|
height: '180px',
|
|
|
|
|
borderRadius: '1rem',
|
|
|
|
|
boxShadow: '0 2px 16px 0 #0002',
|
|
|
|
|
marginBottom: '1rem',
|
|
|
|
|
padding: '0',
|
|
|
|
|
overflow: 'hidden'
|
|
|
|
|
}}>
|
|
|
|
|
<Wheel
|
|
|
|
|
hsva={{ h: 0, s: 0, v: 100, a: 1 }}
|
|
|
|
|
onChange={(color) => {
|
|
|
|
|
const { h, s, v } = color.hsva;
|
|
|
|
|
// Convert percentages to decimals and handle undefined v
|
|
|
|
|
const rgb = hsvToRgb(
|
|
|
|
|
h / 360, // hue: 0-360 -> 0-1
|
|
|
|
|
s / 100, // saturation: 0-100 -> 0-1
|
|
|
|
|
(v ?? 100) / 100 // value: 0-100 -> 0-1, default to 1 if undefined
|
|
|
|
|
);
|
|
|
|
|
console.log('Converting color:', color.hsva, 'to RGB:', rgb);
|
|
|
|
|
updateLighting({
|
|
|
|
|
...state,
|
|
|
|
|
red: rgb.red,
|
|
|
|
|
green: rgb.green,
|
|
|
|
|
blue: rgb.blue
|
|
|
|
|
});
|
|
|
|
|
}}
|
|
|
|
|
width={180}
|
|
|
|
|
height={180}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2025-10-02 13:14:13 -04:00
|
|
|
</div>
|
|
|
|
|
<div className="mb-8 flex flex-col items-center w-full">
|
|
|
|
|
<label className="font-semibold mb-2" htmlFor="brightness">Brightness</label>
|
|
|
|
|
<input
|
|
|
|
|
id="brightness"
|
|
|
|
|
type="range"
|
|
|
|
|
min={0}
|
|
|
|
|
max={100}
|
|
|
|
|
value={state.brightness}
|
2025-10-08 10:56:42 -04:00
|
|
|
onChange={e => {
|
|
|
|
|
const newValue = Number(e.target.value);
|
|
|
|
|
const newState = {
|
|
|
|
|
...state,
|
|
|
|
|
brightness: newValue,
|
|
|
|
|
power: newValue === 0 ? 'off' : state.power
|
|
|
|
|
};
|
|
|
|
|
setState(newState);
|
|
|
|
|
|
|
|
|
|
if (debounceRef.current) {
|
|
|
|
|
clearTimeout(debounceRef.current);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
debounceRef.current = setTimeout(() => {
|
|
|
|
|
updateLighting(newState);
|
|
|
|
|
}, 100); // 100ms debounce for 25 req/2s rate limit
|
|
|
|
|
}}
|
2025-10-02 13:14:13 -04:00
|
|
|
className="w-full max-w-xs accent-yellow-500"
|
|
|
|
|
/>
|
|
|
|
|
<span className="mt-2 text-sm font-bold text-yellow-700 dark:text-yellow-400">{state.brightness}</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-col items-center mb-8">
|
|
|
|
|
<span className="mb-2 text-sm text-neutral-700 dark:text-neutral-300">Live Color Preview</span>
|
|
|
|
|
<div
|
|
|
|
|
className="w-28 h-28 rounded-full border border-neutral-400 dark:border-neutral-700 shadow-xl transition-all duration-500"
|
|
|
|
|
style={{
|
|
|
|
|
background: state.power === 'on' ? colorPreview : 'linear-gradient(135deg, #222 60%, #444 100%)',
|
|
|
|
|
opacity: state.power === 'on' ? 1 : 0.3,
|
|
|
|
|
boxShadow: state.power === 'on' ? `0 0 32px 0 ${colorPreview}` : 'none',
|
|
|
|
|
outline: state.power === 'on' ? `2px solid ${colorPreview}` : '2px solid #333',
|
|
|
|
|
transition: 'background 0.3s, box-shadow 0.3s, outline 0.3s, opacity 0.3s',
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{error && <div className="mb-4 text-red-500 text-center font-bold animate-pulse">{error}</div>}
|
|
|
|
|
{success && <div className="mb-4 text-green-600 text-center font-bold animate-bounce">Updated!</div>}
|
|
|
|
|
{loading && <div className="mb-4 text-indigo-500 text-center font-bold animate-pulse">Loading...</div>}
|
|
|
|
|
</form>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Helper functions for color conversion
|
|
|
|
|
function rgbToHex(r, g, b) {
|
|
|
|
|
r = typeof r === 'number' && !isNaN(r) ? r : 0;
|
|
|
|
|
g = typeof g === 'number' && !isNaN(g) ? g : 0;
|
|
|
|
|
b = typeof b === 'number' && !isNaN(b) ? b : 0;
|
|
|
|
|
return (
|
|
|
|
|
'#' +
|
|
|
|
|
[r, g, b]
|
|
|
|
|
.map(x => {
|
|
|
|
|
const hex = x.toString(16);
|
|
|
|
|
return hex.length === 1 ? '0' + hex : hex;
|
|
|
|
|
})
|
|
|
|
|
.join('')
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-08 10:56:42 -04:00
|
|
|
function hsvToRgb(h, s, v) {
|
|
|
|
|
h = Math.max(0, Math.min(1, h));
|
|
|
|
|
s = Math.max(0, Math.min(1, s));
|
|
|
|
|
v = Math.max(0, Math.min(1, v));
|
|
|
|
|
|
|
|
|
|
let r, g, b;
|
|
|
|
|
const i = Math.floor(h * 6);
|
|
|
|
|
const f = h * 6 - i;
|
|
|
|
|
const p = v * (1 - s);
|
|
|
|
|
const q = v * (1 - f * s);
|
|
|
|
|
const t = v * (1 - (1 - f) * s);
|
|
|
|
|
|
|
|
|
|
switch (i % 6) {
|
|
|
|
|
case 0: r = v; g = t; b = p; break;
|
|
|
|
|
case 1: r = q; g = v; b = p; break;
|
|
|
|
|
case 2: r = p; g = v; b = t; break;
|
|
|
|
|
case 3: r = p; g = q; b = v; break;
|
|
|
|
|
case 4: r = t; g = p; b = v; break;
|
|
|
|
|
case 5: r = v; g = p; b = q; break;
|
|
|
|
|
default: r = 0; g = 0; b = 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
red: Math.max(0, Math.min(255, Math.round(r * 255))),
|
|
|
|
|
green: Math.max(0, Math.min(255, Math.round(g * 255))),
|
|
|
|
|
blue: Math.max(0, Math.min(255, Math.round(b * 255)))
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-02 13:14:13 -04:00
|
|
|
function hexToRgb(hex) {
|
|
|
|
|
hex = hex.replace('#', '');
|
|
|
|
|
if (hex.length === 3) {
|
|
|
|
|
hex = hex.split('').map(x => x + x).join('');
|
|
|
|
|
}
|
|
|
|
|
const num = parseInt(hex, 16);
|
|
|
|
|
return {
|
|
|
|
|
red: (num >> 16) & 255,
|
|
|
|
|
green: (num >> 8) & 255,
|
|
|
|
|
blue: num & 255,
|
|
|
|
|
};
|
|
|
|
|
}
|