Add Lighting component and integrate with navigation and routing
- Introduced Lighting component - Updated AppLayout to include Lighting in the child components. - Added Lighting route to navigation with authentication requirement.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import React, { Suspense, lazy } from 'react';
|
||||
import Memes from './Memes.jsx';
|
||||
import Lighting from './Lighting.jsx';
|
||||
import { toast } from 'react-toastify';
|
||||
import { JoyUIRootIsland } from './Components.jsx';
|
||||
import { PrimeReactProvider } from "primereact/api";
|
||||
@@ -39,6 +40,7 @@ export default function Root({ child, user = undefined }) {
|
||||
{child == "Memes" && <Memes client:only="react" />}
|
||||
{child == "qs2.MediaRequestForm" && <MediaRequestForm client:only="react" />}
|
||||
{child == "qs2.RequestManagement" && <RequestManagement client:only="react" />}
|
||||
{child == "Lighting" && <Lighting client:only="react" />}
|
||||
</JoyUIRootIsland>
|
||||
</PrimeReactProvider>
|
||||
);
|
||||
|
||||
@@ -613,7 +613,7 @@ export default function Player({ user }) {
|
||||
|
||||
const handleRemoveFromQueue = async (uuid) => {
|
||||
try {
|
||||
const response = await authFetch(`${API_URL}/radio/remove_from_queue`, {
|
||||
const response = await authFetch(`${API_URL}/radio/queue_remove`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -637,12 +637,13 @@ export default function Player({ user }) {
|
||||
const [queueSearch, setQueueSearch] = useState("");
|
||||
const fetchQueue = async (page = queuePage, rows = queueRows, search = queueSearch) => {
|
||||
const start = page * rows;
|
||||
console.log("Fetching queue for station (ref):", activeStationRef.current);
|
||||
try {
|
||||
const response = await authFetch(`${API_URL}/radio/get_queue`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
station: activeStation,
|
||||
station: activeStationRef.current, // Use ref to ensure latest value
|
||||
start,
|
||||
length: rows,
|
||||
search,
|
||||
@@ -650,7 +651,6 @@ export default function Player({ user }) {
|
||||
});
|
||||
const data = await response.json();
|
||||
setQueueData(data.items || []);
|
||||
// Use recordsFiltered for search, otherwise recordsTotal
|
||||
setQueueTotalRecords(
|
||||
typeof search === 'string' && search.length > 0
|
||||
? data.recordsFiltered ?? data.recordsTotal ?? 0
|
||||
@@ -663,9 +663,24 @@ export default function Player({ user }) {
|
||||
|
||||
useEffect(() => {
|
||||
if (isQueueVisible) {
|
||||
console.log("Fetching queue for station:", activeStation);
|
||||
fetchQueue(queuePage, queueRows, queueSearch);
|
||||
}
|
||||
}, [isQueueVisible, queuePage, queueRows, queueSearch]);
|
||||
}, [isQueueVisible, queuePage, queueRows, queueSearch, activeStation]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("Active station changed to:", activeStation);
|
||||
if (isQueueVisible) {
|
||||
fetchQueue(queuePage, queueRows, queueSearch);
|
||||
}
|
||||
}, [activeStation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isQueueVisible) {
|
||||
console.log("Track changed, refreshing queue for station:", activeStation);
|
||||
fetchQueue(queuePage, queueRows, queueSearch);
|
||||
}
|
||||
}, [currentTrackUuid]);
|
||||
|
||||
// Always define queueFooter, fallback to Close button if user is not available
|
||||
const isDJ = (user && user.roles.includes('dj')) || ENVIRONMENT === "Dev";
|
||||
|
||||
210
src/components/Lighting.jsx
Normal file
210
src/components/Lighting.jsx
Normal file
@@ -0,0 +1,210 @@
|
||||
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { API_URL } from '../config.js';
|
||||
import { authFetch } from '../utils/authFetch.js';
|
||||
import { HexColorPicker } from "react-colorful";
|
||||
|
||||
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);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Live update handler
|
||||
const debounceRef = useRef();
|
||||
const updateLighting = newState => {
|
||||
// If brightness is 0, force power off
|
||||
let { power, red, green, blue, brightness } = newState;
|
||||
if (brightness === 0) power = 'off';
|
||||
const prev = state;
|
||||
if (
|
||||
power === prev.power &&
|
||||
red === prev.red &&
|
||||
green === prev.green &&
|
||||
blue === prev.blue &&
|
||||
brightness === prev.brightness
|
||||
) return;
|
||||
|
||||
setState({ ...newState, power });
|
||||
setLoading(true);
|
||||
setSuccess(false);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
const payload = { power, red, green, blue, brightness };
|
||||
authFetch(`${API_URL}/lighting/state`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
// Only update state if all fields are present, otherwise preserve previous
|
||||
setState(prev => ({
|
||||
power: typeof data.power === 'string' ? data.power : prev.power,
|
||||
red: typeof data.red === 'number' ? data.red : prev.red,
|
||||
green: typeof data.green === 'number' ? data.green : prev.green,
|
||||
blue: typeof data.blue === 'number' ? data.blue : prev.blue,
|
||||
brightness: typeof data.brightness === 'number' ? data.brightness : prev.brightness,
|
||||
}));
|
||||
setLoading(false);
|
||||
setSuccess(true);
|
||||
})
|
||||
.catch(() => {
|
||||
setError('Failed to update lighting state');
|
||||
setLoading(false);
|
||||
});
|
||||
}, 400); // 400ms debounce
|
||||
};
|
||||
|
||||
const handlePowerToggle = () => {
|
||||
const newPower = state.power === 'on' ? 'off' : 'on';
|
||||
updateLighting({ ...state, power: newPower });
|
||||
};
|
||||
|
||||
const handleColorChange = hex => {
|
||||
const rgb = hexToRgb(hex);
|
||||
updateLighting({
|
||||
power: state.power, // always preserve current power state
|
||||
red: rgb.red,
|
||||
green: rgb.green,
|
||||
blue: rgb.blue
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = e => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setSuccess(false);
|
||||
authFetch(`${API_URL}/lighting/state`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(state),
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setState(data);
|
||||
setLoading(false);
|
||||
setSuccess(true);
|
||||
})
|
||||
.catch(() => {
|
||||
setError('Failed to update lighting state');
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
// 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>
|
||||
<HexColorPicker
|
||||
color={rgbToHex(state.red ?? 0, state.green ?? 0, state.blue ?? 0)}
|
||||
onChange={handleColorChange}
|
||||
style={{ width: '180px', height: '180px', borderRadius: '1rem', boxShadow: '0 2px 16px 0 #0002', marginBottom: '1rem' }}
|
||||
/>
|
||||
</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}
|
||||
onChange={e => updateLighting({ ...state, brightness: Number(e.target.value) })}
|
||||
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('')
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,8 @@ export const metaData = {
|
||||
};
|
||||
|
||||
export const API_URL = "https://api.codey.lol";
|
||||
export const RADIO_API_URL = "https://radio-api.codey.lol";
|
||||
|
||||
export const socialLinks = {
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ const navItems = [
|
||||
{ label: "Home", href: "/" },
|
||||
{ label: "Radio", href: "/radio" },
|
||||
{ label: "Memes", href: "/memes" },
|
||||
{ label: "Lighting", href: "/lighting", auth: true },
|
||||
{ label: "TRip", href: "/TRip", auth: true },
|
||||
{ label: "Status", href: "https://status.boatson.boats", icon: ExitToApp },
|
||||
// { label: "Git", href: "https://kode.boatson.boats", icon: ExitToApp },
|
||||
|
||||
20
src/pages/lighting.astro
Normal file
20
src/pages/lighting.astro
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
import Base from "@/layouts/Base.astro";
|
||||
import Root from "@/components/AppLayout.jsx";
|
||||
import { requireAuthHook } from "@/hooks/requireAuthHook";
|
||||
|
||||
const user = await requireAuthHook(Astro);
|
||||
|
||||
if (!user) {
|
||||
return Astro.redirect('/login');
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
<Base>
|
||||
<section>
|
||||
<div class="prose prose-neutral dark:prose-invert">
|
||||
<Root child="Lighting" user={user} client:only="react" />
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
Reference in New Issue
Block a user