Initial commit

This commit is contained in:
Oli Passey
2025-12-10 18:07:21 +00:00
commit 1fb43156e8
58 changed files with 15656 additions and 0 deletions

1
frontend/.env.example Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:3000/api

15
frontend/.eslintrc.json Normal file
View File

@@ -0,0 +1,15 @@
{
"parser": "@typescript-eslint/parser",
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended"
],
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "module"
},
"rules": {
"@typescript-eslint/no-explicit-any": "warn"
}
}

4
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
.env.local
.DS_Store

View File

@@ -0,0 +1,7 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2
}

28
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
FROM node:20-alpine as builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source
COPY . .
# Build
RUN npm run build
# Production image
FROM nginx:alpine
# Copy built files
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WLED Controller</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

20
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,20 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}

3503
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
frontend/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "wled-controller-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx",
"format": "prettier --write \"src/**/*.{ts,tsx}\""
},
"dependencies": {
"luxon": "^3.4.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.1"
},
"devDependencies": {
"@types/luxon": "^3.3.7",
"@types/react": "^18.2.47",
"@types/react-dom": "^18.2.18",
"@typescript-eslint/eslint-plugin": "^6.18.0",
"@typescript-eslint/parser": "^6.18.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0",
"prettier": "^3.1.1",
"typescript": "^5.3.3",
"vite": "^5.0.11"
}
}

303
frontend/src/App.css Normal file
View File

@@ -0,0 +1,303 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f5f5;
color: #333;
}
.app {
display: flex;
min-height: 100vh;
}
.sidebar {
width: 250px;
background-color: #2c3e50;
color: white;
padding: 20px;
}
.sidebar h1 {
font-size: 24px;
margin-bottom: 30px;
}
.sidebar nav ul {
list-style: none;
}
.sidebar nav li {
margin-bottom: 10px;
}
.sidebar nav a {
color: white;
text-decoration: none;
display: block;
padding: 10px;
border-radius: 4px;
transition: background-color 0.2s;
}
.sidebar nav a:hover,
.sidebar nav a.active {
background-color: #34495e;
}
.main-content {
flex: 1;
padding: 30px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
}
.page-header h2 {
font-size: 28px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: opacity 0.2s;
}
.btn:hover {
opacity: 0.8;
}
.btn-primary {
background-color: #3498db;
color: white;
}
.btn-secondary {
background-color: #95a5a6;
color: white;
}
.btn-danger {
background-color: #e74c3c;
color: white;
}
.btn-small {
padding: 6px 12px;
font-size: 12px;
}
table {
width: 100%;
background-color: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
thead {
background-color: #34495e;
color: white;
}
th, td {
padding: 12px;
text-align: left;
}
tbody tr {
border-bottom: 1px solid #ecf0f1;
}
tbody tr:hover {
background-color: #f8f9fa;
}
.toggle {
position: relative;
display: inline-block;
width: 50px;
height: 24px;
}
.toggle input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: 0.4s;
border-radius: 24px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.4s;
border-radius: 50%;
}
.toggle input:checked + .toggle-slider {
background-color: #2ecc71;
}
.toggle input:checked + .toggle-slider:before {
transform: translateX(26px);
}
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background-color: white;
padding: 30px;
border-radius: 8px;
max-width: 600px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.modal-header h3 {
font-size: 24px;
}
.close-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #95a5a6;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.form-group textarea {
resize: vertical;
min-height: 80px;
}
.form-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 20px;
}
.error-message {
background-color: #fee;
color: #c33;
padding: 10px;
border-radius: 4px;
margin-bottom: 20px;
}
.success-message {
background-color: #efe;
color: #3c3;
padding: 10px;
border-radius: 4px;
margin-bottom: 20px;
}
.loading {
text-align: center;
padding: 40px;
color: #95a5a6;
}
.card {
background-color: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin-bottom: 20px;
}
.card h3 {
margin-bottom: 15px;
}
.checkbox-group {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 200px;
overflow-y: auto;
border: 1px solid #ddd;
padding: 10px;
border-radius: 4px;
}
.checkbox-item {
display: flex;
align-items: center;
gap: 8px;
}
.checkbox-item input[type="checkbox"] {
width: auto;
}

46
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,46 @@
import { BrowserRouter, Routes, Route, Link, Navigate } from 'react-router-dom';
import { DashboardPage } from './pages/DashboardPage';
import { DevicesPage } from './pages/DevicesPage';
import { GroupsPage } from './pages/GroupsPage';
import { SchedulesPage } from './pages/SchedulesPage';
import './App.css';
function App() {
return (
<BrowserRouter>
<div className="app">
<aside className="sidebar">
<h1>WLED Controller</h1>
<nav>
<ul>
<li>
<Link to="/dashboard">Dashboard</Link>
</li>
<li>
<Link to="/devices">Devices</Link>
</li>
<li>
<Link to="/groups">Groups</Link>
</li>
<li>
<Link to="/schedules">Schedules</Link>
</li>
</ul>
</nav>
</aside>
<main className="main-content">
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/devices" element={<DevicesPage />} />
<Route path="/groups" element={<GroupsPage />} />
<Route path="/schedules" element={<SchedulesPage />} />
</Routes>
</main>
</div>
</BrowserRouter>
);
}
export default App;

View File

@@ -0,0 +1,65 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
export class ApiError extends Error {
constructor(
message: string,
public status: number,
public data?: unknown
) {
super(message);
this.name = 'ApiError';
}
}
async function fetchApi<T>(endpoint: string, options?: RequestInit): Promise<T> {
const url = `${API_BASE_URL}${endpoint}`;
try {
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (response.status === 204) {
return undefined as T;
}
const data = await response.json();
if (!response.ok) {
throw new ApiError(
data.message || `HTTP ${response.status}`,
response.status,
data
);
}
return data as T;
} catch (error) {
if (error instanceof ApiError) {
throw error;
}
throw new ApiError('Network error', 0);
}
}
export const apiClient = {
get: <T>(endpoint: string) => fetchApi<T>(endpoint, { method: 'GET' }),
post: <T>(endpoint: string, body?: unknown) =>
fetchApi<T>(endpoint, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
}),
put: <T>(endpoint: string, body?: unknown) =>
fetchApi<T>(endpoint, {
method: 'PUT',
body: body ? JSON.stringify(body) : undefined,
}),
delete: <T>(endpoint: string) => fetchApi<T>(endpoint, { method: 'DELETE' }),
};

View File

@@ -0,0 +1,39 @@
import { apiClient } from './client';
import { Device, PingResult } from './types';
export interface CreateDeviceInput {
name: string;
ipAddress: string;
port?: number;
enabled?: boolean;
}
export interface UpdateDeviceInput {
name?: string;
ipAddress?: string;
port?: number;
enabled?: boolean;
}
export const deviceApi = {
getAll: () => apiClient.get<Device[]>('/devices'),
getById: (id: string) => apiClient.get<Device>(`/devices/${id}`),
create: (data: CreateDeviceInput) => apiClient.post<Device>('/devices', data),
update: (id: string, data: UpdateDeviceInput) =>
apiClient.put<Device>(`/devices/${id}`, data),
delete: (id: string) => apiClient.delete(`/devices/${id}`),
ping: (id: string) => apiClient.post<PingResult>(`/devices/${id}/ping`),
turnOn: (id: string) => apiClient.post(`/devices/${id}/turn-on`),
turnOff: (id: string) => apiClient.post(`/devices/${id}/turn-off`),
turnOnAll: () => apiClient.post('/devices/all/turn-on'),
turnOffAll: () => apiClient.post('/devices/all/turn-off'),
};

View File

@@ -0,0 +1,31 @@
import { apiClient } from './client';
import { Group, GroupActionResult, PlaylistActionPayload } from './types';
export interface CreateGroupInput {
name: string;
deviceIds: string[];
}
export interface UpdateGroupInput {
name?: string;
deviceIds?: string[];
}
export const groupApi = {
getAll: () => apiClient.get<Group[]>('/groups'),
getById: (id: string) => apiClient.get<Group>(`/groups/${id}`),
create: (data: CreateGroupInput) => apiClient.post<Group>('/groups', data),
update: (id: string, data: UpdateGroupInput) =>
apiClient.put<Group>(`/groups/${id}`, data),
delete: (id: string) => apiClient.delete(`/groups/${id}`),
applyPreset: (groupId: string, presetId: number) =>
apiClient.post<GroupActionResult>(`/groups/${groupId}/preset`, { presetId }),
applyPlaylist: (groupId: string, playlist: PlaylistActionPayload) =>
apiClient.post<GroupActionResult>(`/groups/${groupId}/playlist`, playlist),
};

View File

@@ -0,0 +1,37 @@
import { apiClient } from './client';
import { Schedule, PresetActionPayload, PlaylistActionPayload } from './types';
export interface CreateScheduleInput {
name: string;
groupId: string;
type: 'PRESET' | 'PLAYLIST';
cronExpression: string;
endCronExpression?: string;
timezone?: string;
enabled?: boolean;
actionPayload: PresetActionPayload | PlaylistActionPayload;
}
export interface UpdateScheduleInput {
name?: string;
groupId?: string;
type?: 'PRESET' | 'PLAYLIST';
cronExpression?: string;
endCronExpression?: string;
timezone?: string;
enabled?: boolean;
actionPayload?: PresetActionPayload | PlaylistActionPayload;
}
export const scheduleApi = {
getAll: () => apiClient.get<Schedule[]>('/schedules'),
getById: (id: string) => apiClient.get<Schedule>(`/schedules/${id}`),
create: (data: CreateScheduleInput) => apiClient.post<Schedule>('/schedules', data),
update: (id: string, data: UpdateScheduleInput) =>
apiClient.put<Schedule>(`/schedules/${id}`, data),
delete: (id: string) => apiClient.delete(`/schedules/${id}`),
};

63
frontend/src/api/types.ts Normal file
View File

@@ -0,0 +1,63 @@
export interface Device {
id: string;
name: string;
ipAddress: string;
port: number;
enabled: boolean;
lastSeenAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface Group {
id: string;
name: string;
devices: Device[];
createdAt: string;
updatedAt: string;
}
export interface Schedule {
id: string;
name: string;
groupId: string;
type: 'PRESET' | 'PLAYLIST';
cronExpression: string;
endCronExpression?: string;
timezone: string;
enabled: boolean;
actionPayload: PresetActionPayload | PlaylistActionPayload;
createdAt: string;
updatedAt: string;
group: {
id: string;
name: string;
};
}
export interface PresetActionPayload {
presetId: number;
}
export interface PlaylistActionPayload {
ps: number[];
dur?: number[] | number;
transition?: number[] | number;
repeat?: number;
end?: number;
}
export interface GroupActionResult {
status: 'ok';
groupId: string;
results: {
success: string[];
failed: Array<{ deviceId: string; error: string }>;
};
}
export interface PingResult {
status: 'ok' | 'error';
info?: unknown;
error?: string;
}

View File

@@ -0,0 +1,255 @@
import { useState, useEffect } from 'react';
import { timePickerToCron, cronToTimePicker, DAYS_OF_WEEK, TimePickerValue } from '../utils/timePicker';
interface TimePickerProps {
value: string; // cron expression
onChange: (cron: string) => void;
}
export function TimePicker({ value, onChange }: TimePickerProps) {
const [mode, setMode] = useState<'simple' | 'advanced'>('simple');
const [timeValue, setTimeValue] = useState<TimePickerValue>(() => {
const parsed = cronToTimePicker(value);
return parsed || { hour: 18, minute: 30, daysOfWeek: [0, 1, 2, 3, 4, 5, 6] };
});
const handleTimeChange = (newValue: Partial<TimePickerValue>) => {
const updated = { ...timeValue, ...newValue };
setTimeValue(updated);
onChange(timePickerToCron(updated));
};
const toggleDay = (day: number) => {
const days = timeValue.daysOfWeek.includes(day)
? timeValue.daysOfWeek.filter(d => d !== day)
: [...timeValue.daysOfWeek, day].sort((a, b) => a - b);
handleTimeChange({ daysOfWeek: days.length > 0 ? days : [0, 1, 2, 3, 4, 5, 6] });
};
const selectAllDays = () => {
handleTimeChange({ daysOfWeek: [0, 1, 2, 3, 4, 5, 6] });
};
const selectWeekdays = () => {
handleTimeChange({ daysOfWeek: [1, 2, 3, 4, 5] });
};
const selectWeekends = () => {
handleTimeChange({ daysOfWeek: [0, 6] });
};
return (
<div>
<div style={{ marginBottom: '10px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<input
type="radio"
checked={mode === 'simple'}
onChange={() => setMode('simple')}
/>
Simple Time Picker
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<input
type="radio"
checked={mode === 'advanced'}
onChange={() => setMode('advanced')}
/>
Advanced (Cron Expression)
</label>
</div>
{mode === 'simple' ? (
<div>
<div style={{ display: 'flex', gap: '10px', marginBottom: '15px', alignItems: 'center' }}>
<div>
<label style={{ display: 'block', marginBottom: '5px', fontSize: '14px' }}>Hour</label>
<input
type="number"
min="0"
max="23"
value={timeValue.hour}
onChange={(e) => handleTimeChange({ hour: parseInt(e.target.value) })}
style={{ width: '70px', padding: '8px' }}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '5px', fontSize: '14px' }}>Minute</label>
<input
type="number"
min="0"
max="59"
value={timeValue.minute}
onChange={(e) => handleTimeChange({ minute: parseInt(e.target.value) })}
style={{ width: '70px', padding: '8px' }}
/>
</div>
<div style={{ marginLeft: '20px', fontSize: '24px', fontWeight: 'bold' }}>
{String(timeValue.hour).padStart(2, '0')}:{String(timeValue.minute).padStart(2, '0')}
</div>
</div>
<div>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500' }}>Days of Week</label>
<div style={{ display: 'flex', gap: '5px', marginBottom: '10px' }}>
<button
type="button"
className="btn btn-small btn-secondary"
onClick={selectAllDays}
>
All Days
</button>
<button
type="button"
className="btn btn-small btn-secondary"
onClick={selectWeekdays}
>
Weekdays
</button>
<button
type="button"
className="btn btn-small btn-secondary"
onClick={selectWeekends}
>
Weekends
</button>
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{DAYS_OF_WEEK.map((day) => (
<button
key={day.value}
type="button"
onClick={() => toggleDay(day.value)}
style={{
padding: '8px 16px',
border: '2px solid',
borderColor: timeValue.daysOfWeek.includes(day.value) ? '#3498db' : '#ddd',
backgroundColor: timeValue.daysOfWeek.includes(day.value) ? '#3498db' : 'white',
color: timeValue.daysOfWeek.includes(day.value) ? 'white' : '#333',
borderRadius: '4px',
cursor: 'pointer',
fontWeight: '500',
}}
>
{day.label}
</button>
))}
</div>
</div>
<div style={{ marginTop: '10px', fontSize: '12px', color: '#7f8c8d' }}>
Cron: {timePickerToCron(timeValue)}
</div>
</div>
) : (
<div>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="0 18 * * *"
style={{ width: '100%' }}
/>
<div style={{ marginTop: '5px', fontSize: '12px', color: '#7f8c8d' }}>
Format: minute hour day month dayofweek
</div>
</div>
)}
</div>
);
}
interface PresetSelectorProps {
groupId: string;
selectedPresets: number[];
onChange: (presets: number[]) => void;
mode: 'single' | 'multiple';
}
export function PresetSelector({ groupId, selectedPresets, onChange, mode }: PresetSelectorProps) {
const [presets, setPresets] = useState<Record<string, any>>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
if (groupId) {
loadPresets();
}
}, [groupId]);
const loadPresets = async () => {
try {
setLoading(true);
const response = await fetch(`/api/groups/${groupId}/presets`);
const data = await response.json();
setPresets(data);
} catch (error) {
console.error('Failed to load presets:', error);
} finally {
setLoading(false);
}
};
const handleToggle = (presetId: number) => {
if (mode === 'single') {
onChange([presetId]);
} else {
if (selectedPresets.includes(presetId)) {
onChange(selectedPresets.filter(id => id !== presetId));
} else {
onChange([...selectedPresets, presetId]);
}
}
};
if (loading) return <div style={{ padding: '20px', textAlign: 'center' }}>Loading presets...</div>;
const presetEntries = Object.entries(presets)
.filter(([key]) => key !== '0')
.map(([key, value]) => {
const id = parseInt(key);
const name = typeof value === 'object' && value.n ? value.n : `Preset ${id}`;
return { id, name };
})
.sort((a, b) => a.id - b.id);
if (presetEntries.length === 0) {
return <div style={{ padding: '20px', textAlign: 'center', color: '#95a5a6' }}>No presets available</div>;
}
return (
<div style={{
maxHeight: '300px',
overflowY: 'auto',
border: '1px solid #ddd',
borderRadius: '4px',
padding: '10px'
}}>
{presetEntries.map(({ id, name }) => (
<div
key={id}
onClick={() => handleToggle(id)}
style={{
padding: '10px',
marginBottom: '5px',
border: '2px solid',
borderColor: selectedPresets.includes(id) ? '#3498db' : '#ecf0f1',
backgroundColor: selectedPresets.includes(id) ? '#ebf5fb' : 'white',
borderRadius: '4px',
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<span>
<strong>#{id}</strong> {name}
</span>
{mode === 'multiple' && selectedPresets.includes(id) && (
<span style={{ color: '#3498db' }}></span>
)}
</div>
))}
</div>
);
}

9
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,294 @@
import { useState, useEffect } from 'react';
import { formatDateTime } from '../utils/dateTime';
import { deviceApi } from '../api/devices';
interface DeviceStatus {
id: string;
name: string;
ipAddress: string;
port: number;
enabled: boolean;
lastSeenAt: string | null;
currentState?: {
on?: boolean;
bri?: number;
ps?: number;
seg?: Array<{
id: number;
start: number;
stop: number;
len: number;
on: boolean;
bri: number;
n?: string;
}>;
};
info?: {
ver?: string;
leds?: {
count?: number;
maxseg?: number;
};
name?: string;
};
}
export function DashboardPage() {
const [devices, setDevices] = useState<DeviceStatus[]>([]);
const [loading, setLoading] = useState(true);
const [autoRefresh, setAutoRefresh] = useState(true);
useEffect(() => {
loadDevices();
}, []);
useEffect(() => {
if (!autoRefresh) return;
const interval = setInterval(() => {
loadDevices();
}, 5000); // Refresh every 5 seconds
return () => clearInterval(interval);
}, [autoRefresh]);
const loadDevices = async () => {
try {
const response = await fetch('/api/devices/status/all');
const data = await response.json();
setDevices(data);
} catch (error) {
console.error('Failed to load device status:', error);
} finally {
setLoading(false);
}
};
if (loading) return <div className="loading">Loading dashboard...</div>;
const onlineDevices = devices.filter(d => d.enabled && d.currentState).length;
const totalEnabled = devices.filter(d => d.enabled).length;
return (
<div>
<div className="page-header">
<h2>Dashboard</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
/>
Auto-refresh
</label>
<button className="btn btn-secondary" onClick={loadDevices}>
Refresh Now
</button>
<button
className="btn btn-primary"
onClick={async () => {
try {
await deviceApi.turnOnAll();
setTimeout(loadDevices, 500);
} catch (err) {
alert('Failed to turn on all devices');
}
}}
>
Turn All On
</button>
<button
className="btn btn-danger"
onClick={async () => {
try {
await deviceApi.turnOffAll();
setTimeout(loadDevices, 500);
} catch (err) {
alert('Failed to turn off all devices');
}
}}
>
Turn All Off
</button>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: '20px', marginBottom: '30px' }}>
<div className="card">
<h3>Total Devices</h3>
<div style={{ fontSize: '48px', fontWeight: 'bold', color: '#3498db' }}>{devices.length}</div>
</div>
<div className="card">
<h3>Online</h3>
<div style={{ fontSize: '48px', fontWeight: 'bold', color: '#2ecc71' }}>
{onlineDevices} / {totalEnabled}
</div>
</div>
<div className="card">
<h3>Total LEDs</h3>
<div style={{ fontSize: '48px', fontWeight: 'bold', color: '#9b59b6' }}>
{devices.reduce((sum, d) => sum + (d.info?.leds?.count || 0), 0)}
</div>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))', gap: '20px' }}>
{devices.map((device) => (
<DeviceCard key={device.id} device={device} onRefresh={loadDevices} />
))}
</div>
</div>
);
}
interface DeviceCardProps {
device: DeviceStatus;
onRefresh: () => void;
}
function DeviceCard({ device, onRefresh }: DeviceCardProps) {
const isOnline = device.enabled && device.currentState !== undefined;
const isOn = device.currentState?.on ?? false;
const [busy, setBusy] = useState(false);
const handleTurnOn = async () => {
try {
setBusy(true);
await deviceApi.turnOn(device.id);
setTimeout(onRefresh, 500);
} catch (err) {
alert('Failed to turn on device');
} finally {
setBusy(false);
}
};
const handleTurnOff = async () => {
try {
setBusy(true);
await deviceApi.turnOff(device.id);
setTimeout(onRefresh, 500);
} catch (err) {
alert('Failed to turn off device');
} finally {
setBusy(false);
}
};
return (
<div className="card" style={{ position: 'relative' }}>
<div style={{ position: 'absolute', top: '15px', right: '15px' }}>
<div
style={{
width: '12px',
height: '12px',
borderRadius: '50%',
backgroundColor: isOnline ? '#2ecc71' : '#e74c3c',
}}
title={isOnline ? 'Online' : 'Offline'}
/>
</div>
<h3 style={{ marginBottom: '10px', paddingRight: '30px' }}>{device.name}</h3>
<div style={{ fontSize: '12px', color: '#7f8c8d', marginBottom: '15px' }}>
{device.ipAddress}:{device.port}
</div>
{isOnline && (
<div style={{ marginBottom: '15px', display: 'flex', gap: '8px' }}>
<button
className="btn btn-small btn-primary"
onClick={handleTurnOn}
disabled={busy || isOn}
style={{ flex: 1 }}
>
Turn On
</button>
<button
className="btn btn-small btn-danger"
onClick={handleTurnOff}
disabled={busy || !isOn}
style={{ flex: 1 }}
>
Turn Off
</button>
</div>
)}
{isOnline ? (
<>
<div style={{ marginBottom: '10px' }}>
<strong>Status:</strong>{' '}
<span style={{ color: isOn ? '#2ecc71' : '#95a5a6' }}>
{isOn ? 'ON' : 'OFF'}
</span>
</div>
{device.currentState?.bri !== undefined && (
<div style={{ marginBottom: '10px' }}>
<strong>Brightness:</strong> {Math.round((device.currentState.bri / 255) * 100)}%
</div>
)}
{device.currentState?.ps !== undefined && device.currentState.ps > 0 && (
<div style={{ marginBottom: '10px' }}>
<strong>Preset:</strong> #{device.currentState.ps}
</div>
)}
{device.info?.leds?.count !== undefined && (
<div style={{ marginBottom: '10px' }}>
<strong>LEDs:</strong> {device.info.leds.count}
</div>
)}
{device.info?.ver && (
<div style={{ marginBottom: '10px' }}>
<strong>Version:</strong> {device.info.ver}
</div>
)}
{device.currentState?.seg && device.currentState.seg.length > 0 && (
<div style={{ marginTop: '15px' }}>
<strong>Segments ({device.currentState.seg.length}):</strong>
<div style={{ marginTop: '8px' }}>
{device.currentState.seg.map((seg) => (
<div
key={seg.id}
style={{
fontSize: '12px',
padding: '6px',
backgroundColor: '#f8f9fa',
marginBottom: '4px',
borderRadius: '4px',
display: 'flex',
justifyContent: 'space-between',
}}
>
<span>
{seg.n || `Segment ${seg.id}`} ({seg.len} LEDs)
</span>
<span style={{ color: seg.on ? '#2ecc71' : '#95a5a6' }}>
{seg.on ? 'ON' : 'OFF'}
</span>
</div>
))}
</div>
</div>
)}
</>
) : (
<div style={{ color: '#95a5a6', fontStyle: 'italic' }}>
{device.enabled ? 'Device offline or unreachable' : 'Device disabled'}
</div>
)}
{device.lastSeenAt && (
<div style={{ marginTop: '15px', fontSize: '12px', color: '#95a5a6', borderTop: '1px solid #ecf0f1', paddingTop: '10px' }}>
Last seen: {formatDateTime(device.lastSeenAt)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,252 @@
import { useState, useEffect } from 'react';
import { deviceApi } from '../api/devices';
import { Device } from '../api/types';
import { formatDateTime } from '../utils/dateTime';
export function DevicesPage() {
const [devices, setDevices] = useState<Device[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showModal, setShowModal] = useState(false);
const [editingDevice, setEditingDevice] = useState<Device | null>(null);
useEffect(() => {
loadDevices();
}, []);
const loadDevices = async () => {
try {
setLoading(true);
const data = await deviceApi.getAll();
setDevices(data);
setError(null);
} catch (err) {
setError('Failed to load devices');
} finally {
setLoading(false);
}
};
const handlePing = async (id: string) => {
try {
const result = await deviceApi.ping(id);
if (result.status === 'ok') {
alert('Device is reachable!');
loadDevices();
} else {
alert(`Device error: ${result.error}`);
}
} catch (err) {
alert('Failed to ping device');
}
};
const handleToggleEnabled = async (device: Device) => {
try {
await deviceApi.update(device.id, { enabled: !device.enabled });
loadDevices();
} catch (err) {
alert('Failed to update device');
}
};
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this device?')) return;
try {
await deviceApi.delete(id);
loadDevices();
} catch (err) {
alert('Failed to delete device');
}
};
const openCreateModal = () => {
setEditingDevice(null);
setShowModal(true);
};
const openEditModal = (device: Device) => {
setEditingDevice(device);
setShowModal(true);
};
if (loading) return <div className="loading">Loading devices...</div>;
return (
<div>
<div className="page-header">
<h2>Devices</h2>
<button className="btn btn-primary" onClick={openCreateModal}>
Add Device
</button>
</div>
{error && <div className="error-message">{error}</div>}
<table>
<thead>
<tr>
<th>Name</th>
<th>IP Address</th>
<th>Port</th>
<th>Enabled</th>
<th>Last Seen</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{devices.map((device) => (
<tr key={device.id}>
<td>{device.name}</td>
<td>{device.ipAddress}</td>
<td>{device.port}</td>
<td>
<label className="toggle">
<input
type="checkbox"
checked={device.enabled}
onChange={() => handleToggleEnabled(device)}
/>
<span className="toggle-slider"></span>
</label>
</td>
<td>{formatDateTime(device.lastSeenAt)}</td>
<td>
<button
className="btn btn-small btn-secondary"
onClick={() => handlePing(device.id)}
style={{ marginRight: '5px' }}
>
Ping
</button>
<button
className="btn btn-small btn-secondary"
onClick={() => openEditModal(device)}
style={{ marginRight: '5px' }}
>
Edit
</button>
<button
className="btn btn-small btn-danger"
onClick={() => handleDelete(device.id)}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
{showModal && (
<DeviceModal
device={editingDevice}
onClose={() => setShowModal(false)}
onSave={() => {
setShowModal(false);
loadDevices();
}}
/>
)}
</div>
);
}
interface DeviceModalProps {
device: Device | null;
onClose: () => void;
onSave: () => void;
}
function DeviceModal({ device, onClose, onSave }: DeviceModalProps) {
const [name, setName] = useState(device?.name || '');
const [ipAddress, setIpAddress] = useState(device?.ipAddress || '');
const [port, setPort] = useState(device?.port || 80);
const [enabled, setEnabled] = useState(device?.enabled ?? true);
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
setSubmitting(true);
if (device) {
await deviceApi.update(device.id, { name, ipAddress, port, enabled });
} else {
await deviceApi.create({ name, ipAddress, port, enabled });
}
onSave();
} catch (err) {
alert('Failed to save device');
} finally {
setSubmitting(false);
}
};
return (
<div className="modal" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3>{device ? 'Edit Device' : 'Add Device'}</h3>
<button className="close-btn" onClick={onClose}>×</button>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Name *</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div className="form-group">
<label>IP Address *</label>
<input
type="text"
value={ipAddress}
onChange={(e) => setIpAddress(e.target.value)}
required
/>
</div>
<div className="form-group">
<label>Port</label>
<input
type="number"
value={port}
onChange={(e) => setPort(parseInt(e.target.value))}
min="1"
max="65535"
/>
</div>
<div className="form-group">
<label>
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
{' '}Enabled
</label>
</div>
<div className="form-actions">
<button type="button" className="btn btn-secondary" onClick={onClose}>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting ? 'Saving...' : 'Save'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,360 @@
import { useState, useEffect } from 'react';
import { groupApi } from '../api/groups';
import { deviceApi } from '../api/devices';
import { Group, Device } from '../api/types';
export function GroupsPage() {
const [groups, setGroups] = useState<Group[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showModal, setShowModal] = useState(false);
const [editingGroup, setEditingGroup] = useState<Group | null>(null);
const [selectedGroup, setSelectedGroup] = useState<Group | null>(null);
useEffect(() => {
loadGroups();
}, []);
const loadGroups = async () => {
try {
setLoading(true);
const data = await groupApi.getAll();
setGroups(data);
setError(null);
} catch (err) {
setError('Failed to load groups');
} finally {
setLoading(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this group?')) return;
try {
await groupApi.delete(id);
loadGroups();
} catch (err) {
alert('Failed to delete group. It may have active schedules.');
}
};
const openCreateModal = () => {
setEditingGroup(null);
setShowModal(true);
};
const openEditModal = (group: Group) => {
setEditingGroup(group);
setShowModal(true);
};
if (loading) return <div className="loading">Loading groups...</div>;
return (
<div>
<div className="page-header">
<h2>Groups</h2>
<button className="btn btn-primary" onClick={openCreateModal}>
Create Group
</button>
</div>
{error && <div className="error-message">{error}</div>}
<table>
<thead>
<tr>
<th>Name</th>
<th>Devices</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{groups.map((group) => (
<tr key={group.id}>
<td>{group.name}</td>
<td>{group.devices.length} device(s)</td>
<td>
<button
className="btn btn-small btn-secondary"
onClick={() => setSelectedGroup(group)}
style={{ marginRight: '5px' }}
>
Control
</button>
<button
className="btn btn-small btn-secondary"
onClick={() => openEditModal(group)}
style={{ marginRight: '5px' }}
>
Edit
</button>
<button
className="btn btn-small btn-danger"
onClick={() => handleDelete(group.id)}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
{showModal && (
<GroupModal
group={editingGroup}
onClose={() => setShowModal(false)}
onSave={() => {
setShowModal(false);
loadGroups();
}}
/>
)}
{selectedGroup && (
<GroupControlModal
group={selectedGroup}
onClose={() => setSelectedGroup(null)}
/>
)}
</div>
);
}
interface GroupModalProps {
group: Group | null;
onClose: () => void;
onSave: () => void;
}
function GroupModal({ group, onClose, onSave }: GroupModalProps) {
const [name, setName] = useState(group?.name || '');
const [devices, setDevices] = useState<Device[]>([]);
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>(
group?.devices.map(d => d.id) || []
);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
loadDevices();
}, []);
const loadDevices = async () => {
const data = await deviceApi.getAll();
setDevices(data);
};
const handleToggleDevice = (deviceId: string) => {
setSelectedDeviceIds(prev =>
prev.includes(deviceId)
? prev.filter(id => id !== deviceId)
: [...prev, deviceId]
);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (selectedDeviceIds.length === 0) {
alert('Please select at least one device');
return;
}
try {
setSubmitting(true);
if (group) {
await groupApi.update(group.id, { name, deviceIds: selectedDeviceIds });
} else {
await groupApi.create({ name, deviceIds: selectedDeviceIds });
}
onSave();
} catch (err) {
alert('Failed to save group');
} finally {
setSubmitting(false);
}
};
return (
<div className="modal" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3>{group ? 'Edit Group' : 'Create Group'}</h3>
<button className="close-btn" onClick={onClose}>×</button>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Name *</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div className="form-group">
<label>Devices *</label>
<div className="checkbox-group">
{devices.map(device => (
<label key={device.id} className="checkbox-item">
<input
type="checkbox"
checked={selectedDeviceIds.includes(device.id)}
onChange={() => handleToggleDevice(device.id)}
/>
{device.name} ({device.ipAddress})
</label>
))}
</div>
</div>
<div className="form-actions">
<button type="button" className="btn btn-secondary" onClick={onClose}>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting ? 'Saving...' : 'Save'}
</button>
</div>
</form>
</div>
</div>
);
}
interface GroupControlModalProps {
group: Group;
onClose: () => void;
}
function GroupControlModal({ group, onClose }: GroupControlModalProps) {
const [presetId, setPresetId] = useState('');
const [playlistPresets, setPlaylistPresets] = useState('');
const [playlistDur, setPlaylistDur] = useState('');
const [playlistTransition, setPlaylistTransition] = useState('');
const [playlistRepeat, setPlaylistRepeat] = useState('0');
const [playlistEnd, setPlaylistEnd] = useState('');
const handleApplyPreset = async () => {
if (!presetId) {
alert('Please enter a preset ID');
return;
}
try {
const result = await groupApi.applyPreset(group.id, parseInt(presetId));
alert(`Success: ${result.results.success.length}, Failed: ${result.results.failed.length}`);
} catch (err) {
alert('Failed to apply preset');
}
};
const handleApplyPlaylist = async () => {
if (!playlistPresets) {
alert('Please enter preset IDs');
return;
}
try {
const ps = playlistPresets.split(',').map(s => parseInt(s.trim()));
const dur = playlistDur ? playlistDur.split(',').map(s => parseInt(s.trim())) : undefined;
const transition = playlistTransition ? playlistTransition.split(',').map(s => parseInt(s.trim())) : undefined;
const result = await groupApi.applyPlaylist(group.id, {
ps,
dur: dur && dur.length === 1 ? dur[0] : dur,
transition: transition && transition.length === 1 ? transition[0] : transition,
repeat: parseInt(playlistRepeat),
end: playlistEnd ? parseInt(playlistEnd) : undefined,
});
alert(`Success: ${result.results.success.length}, Failed: ${result.results.failed.length}`);
} catch (err) {
alert('Failed to apply playlist');
}
};
return (
<div className="modal" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3>Control Group: {group.name}</h3>
<button className="close-btn" onClick={onClose}>×</button>
</div>
<div className="card">
<h3>Apply Preset</h3>
<div className="form-group">
<label>Preset ID</label>
<input
type="number"
value={presetId}
onChange={(e) => setPresetId(e.target.value)}
placeholder="e.g., 1"
/>
</div>
<button className="btn btn-primary" onClick={handleApplyPreset}>
Apply Preset
</button>
</div>
<div className="card">
<h3>Apply Playlist</h3>
<div className="form-group">
<label>Preset IDs (comma-separated) *</label>
<input
type="text"
value={playlistPresets}
onChange={(e) => setPlaylistPresets(e.target.value)}
placeholder="e.g., 1,2,3"
/>
</div>
<div className="form-group">
<label>Duration (tenths of seconds, comma-separated or single value)</label>
<input
type="text"
value={playlistDur}
onChange={(e) => setPlaylistDur(e.target.value)}
placeholder="e.g., 30,30,30 or 30"
/>
</div>
<div className="form-group">
<label>Transition (comma-separated or single value)</label>
<input
type="text"
value={playlistTransition}
onChange={(e) => setPlaylistTransition(e.target.value)}
placeholder="e.g., 0"
/>
</div>
<div className="form-group">
<label>Repeat (0 = infinite)</label>
<input
type="number"
value={playlistRepeat}
onChange={(e) => setPlaylistRepeat(e.target.value)}
/>
</div>
<div className="form-group">
<label>End Preset ID</label>
<input
type="number"
value={playlistEnd}
onChange={(e) => setPlaylistEnd(e.target.value)}
placeholder="Optional"
/>
</div>
<button className="btn btn-primary" onClick={handleApplyPlaylist}>
Apply Playlist
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,395 @@
import { useState, useEffect } from 'react';
import { scheduleApi } from '../api/schedules';
import { groupApi } from '../api/groups';
import { Schedule, Group, PresetActionPayload, PlaylistActionPayload } from '../api/types';
import { TimePicker, PresetSelector } from '../components/ScheduleComponents';
import { timePickerToCron } from '../utils/timePicker';
export function SchedulesPage() {
const [schedules, setSchedules] = useState<Schedule[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showModal, setShowModal] = useState(false);
const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null);
useEffect(() => {
loadSchedules();
}, []);
const loadSchedules = async () => {
try {
setLoading(true);
const data = await scheduleApi.getAll();
setSchedules(data);
setError(null);
} catch (err) {
setError('Failed to load schedules');
} finally {
setLoading(false);
}
};
const handleToggleEnabled = async (schedule: Schedule) => {
try {
await scheduleApi.update(schedule.id, { enabled: !schedule.enabled });
loadSchedules();
} catch (err) {
alert('Failed to update schedule');
}
};
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this schedule?')) return;
try {
await scheduleApi.delete(id);
loadSchedules();
} catch (err) {
alert('Failed to delete schedule');
}
};
const openCreateModal = () => {
setEditingSchedule(null);
setShowModal(true);
};
const openEditModal = (schedule: Schedule) => {
setEditingSchedule(schedule);
setShowModal(true);
};
if (loading) return <div className="loading">Loading schedules...</div>;
return (
<div>
<div className="page-header">
<h2>Schedules</h2>
<button className="btn btn-primary" onClick={openCreateModal}>
Create Schedule
</button>
</div>
{error && <div className="error-message">{error}</div>}
<table>
<thead>
<tr>
<th>Name</th>
<th>Group</th>
<th>Type</th>
<th>Time</th>
<th>Timezone</th>
<th>Enabled</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{schedules.map((schedule) => (
<tr key={schedule.id}>
<td>{schedule.name}</td>
<td>{schedule.group.name}</td>
<td>{schedule.type}</td>
<td style={{ fontFamily: 'monospace', fontSize: '12px' }}>{schedule.cronExpression}</td>
<td>{schedule.timezone}</td>
<td>
<label className="toggle">
<input
type="checkbox"
checked={schedule.enabled}
onChange={() => handleToggleEnabled(schedule)}
/>
<span className="toggle-slider"></span>
</label>
</td>
<td>
<button
className="btn btn-small btn-secondary"
onClick={() => openEditModal(schedule)}
style={{ marginRight: '5px' }}
>
Edit
</button>
<button
className="btn btn-small btn-danger"
onClick={() => handleDelete(schedule.id)}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
{showModal && (
<ScheduleModal
schedule={editingSchedule}
onClose={() => setShowModal(false)}
onSave={() => {
setShowModal(false);
loadSchedules();
}}
/>
)}
</div>
);
}
interface ScheduleModalProps {
schedule: Schedule | null;
onClose: () => void;
onSave: () => void;
}
function ScheduleModal({ schedule, onClose, onSave }: ScheduleModalProps) {
const [name, setName] = useState(schedule?.name || '');
const [groups, setGroups] = useState<Group[]>([]);
const [groupId, setGroupId] = useState(schedule?.groupId || '');
const [type, setType] = useState<'PRESET' | 'PLAYLIST'>(schedule?.type || 'PRESET');
const [cronExpression, setCronExpression] = useState(schedule?.cronExpression || '30 18 * * *');
const [endCronExpression, setEndCronExpression] = useState(schedule?.endCronExpression || '');
const [timezone, setTimezone] = useState(schedule?.timezone || 'Europe/London');
const [enabled, setEnabled] = useState(schedule?.enabled ?? true);
// Preset fields
const [selectedPresets, setSelectedPresets] = useState<number[]>(() => {
if (!schedule) return [];
if (schedule.type === 'PRESET') {
return [(schedule.actionPayload as PresetActionPayload).presetId];
}
if (schedule.type === 'PLAYLIST') {
return (schedule.actionPayload as PlaylistActionPayload).ps;
}
return [];
});
// Playlist fields
const [playlistDur, setPlaylistDur] = useState('30');
const [playlistTransition, setPlaylistTransition] = useState('0');
const [playlistRepeat, setPlaylistRepeat] = useState('0');
const [playlistEnd, setPlaylistEnd] = useState('');
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
loadGroups();
}, []);
const loadGroups = async () => {
const data = await groupApi.getAll();
setGroups(data);
if (data.length > 0 && !groupId) {
setGroupId(data[0].id);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!groupId) {
alert('Please select a group');
return;
}
if (selectedPresets.length === 0) {
alert('Please select at least one preset');
return;
}
let actionPayload: PresetActionPayload | PlaylistActionPayload;
if (type === 'PRESET') {
actionPayload = { presetId: selectedPresets[0] };
} else {
const dur = parseInt(playlistDur);
const transition = parseInt(playlistTransition);
actionPayload = {
ps: selectedPresets,
dur: isNaN(dur) ? 30 : dur,
transition: isNaN(transition) ? 0 : transition,
repeat: parseInt(playlistRepeat) || 0,
end: playlistEnd ? parseInt(playlistEnd) : undefined,
};
}
try {
setSubmitting(true);
if (schedule) {
await scheduleApi.update(schedule.id, {
name,
groupId,
type,
cronExpression,
endCronExpression: endCronExpression || undefined,
timezone,
enabled,
actionPayload,
});
} else {
await scheduleApi.create({
name,
groupId,
type,
cronExpression,
endCronExpression: endCronExpression || undefined,
timezone,
enabled,
actionPayload,
});
}
onSave();
} catch (err) {
alert('Failed to save schedule');
} finally {
setSubmitting(false);
}
};
return (
<div className="modal" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: '700px' }}>
<div className="modal-header">
<h3>{schedule ? 'Edit Schedule' : 'Create Schedule'}</h3>
<button className="close-btn" onClick={onClose}>×</button>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Name *</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div className="form-group">
<label>Group *</label>
<select value={groupId} onChange={(e) => setGroupId(e.target.value)} required>
<option value="">Select a group</option>
{groups.map(group => (
<option key={group.id} value={group.id}>{group.name}</option>
))}
</select>
</div>
<div className="form-group">
<label>Type *</label>
<select value={type} onChange={(e) => setType(e.target.value as 'PRESET' | 'PLAYLIST')} required>
<option value="PRESET">Single Preset</option>
<option value="PLAYLIST">Playlist (Multiple Presets)</option>
</select>
</div>
<div className="form-group">
<label>Schedule Time *</label>
<TimePicker value={cronExpression} onChange={setCronExpression} />
</div>
<div className="form-group">
<label>End Time (Turn Off) - Optional</label>
<TimePicker value={endCronExpression} onChange={setEndCronExpression} />
<div style={{ fontSize: '12px', color: '#7f8c8d', marginTop: '5px' }}>
Leave empty if you don't want lights to automatically turn off
</div>
</div>
<div className="form-group">
<label>Timezone</label>
<select value={timezone} onChange={(e) => setTimezone(e.target.value)}>
<option value="Europe/London">Europe/London</option>
<option value="UTC">UTC</option>
<option value="America/New_York">America/New_York</option>
<option value="America/Los_Angeles">America/Los_Angeles</option>
<option value="Europe/Paris">Europe/Paris</option>
</select>
</div>
<div className="form-group">
<label>
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
{' '}Enabled
</label>
</div>
<div className="form-group">
<label>{type === 'PRESET' ? 'Select Preset *' : 'Select Presets for Playlist *'}</label>
{groupId ? (
<PresetSelector
groupId={groupId}
selectedPresets={selectedPresets}
onChange={setSelectedPresets}
mode={type === 'PRESET' ? 'single' : 'multiple'}
/>
) : (
<div style={{ padding: '20px', textAlign: 'center', color: '#95a5a6' }}>
Please select a group first
</div>
)}
</div>
{type === 'PLAYLIST' && (
<>
<div className="form-group">
<label>Duration (tenths of seconds per preset)</label>
<input
type="number"
value={playlistDur}
onChange={(e) => setPlaylistDur(e.target.value)}
placeholder="30"
/>
<div style={{ fontSize: '12px', color: '#7f8c8d', marginTop: '5px' }}>
Default 30 = 3 seconds per preset
</div>
</div>
<div className="form-group">
<label>Transition</label>
<input
type="number"
value={playlistTransition}
onChange={(e) => setPlaylistTransition(e.target.value)}
placeholder="0"
/>
</div>
<div className="form-group">
<label>Repeat (0 = infinite)</label>
<input
type="number"
value={playlistRepeat}
onChange={(e) => setPlaylistRepeat(e.target.value)}
/>
</div>
<div className="form-group">
<label>End Preset ID (optional)</label>
<input
type="number"
value={playlistEnd}
onChange={(e) => setPlaylistEnd(e.target.value)}
placeholder="Optional"
/>
</div>
</>
)}
<div className="form-actions">
<button type="button" className="btn btn-secondary" onClick={onClose}>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting ? 'Saving...' : 'Save'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { DateTime } from 'luxon';
export function formatDateTime(dateString: string | null): string {
if (!dateString) return '-';
const dt = DateTime.fromISO(dateString, { zone: 'Europe/London' });
return dt.toFormat('dd/MM/yyyy HH:mm');
}
export function formatDateTimeFromDate(date: Date | null): string {
if (!date) return '-';
const dt = DateTime.fromJSDate(date, { zone: 'Europe/London' });
return dt.toFormat('dd/MM/yyyy HH:mm');
}

View File

@@ -0,0 +1,61 @@
export interface TimePickerValue {
hour: number;
minute: number;
daysOfWeek: number[]; // 0-6, 0 = Sunday
}
export function timePickerToCron(value: TimePickerValue): string {
const { hour, minute, daysOfWeek } = value;
if (daysOfWeek.length === 0 || daysOfWeek.length === 7) {
// Every day
return `${minute} ${hour} * * *`;
}
// Specific days
const days = daysOfWeek.sort((a, b) => a - b).join(',');
return `${minute} ${hour} * * ${days}`;
}
export function cronToTimePicker(cron: string): TimePickerValue | null {
try {
const parts = cron.trim().split(/\s+/);
if (parts.length < 5) return null;
const [minutePart, hourPart, , , dayPart] = parts;
const minute = parseInt(minutePart);
const hour = parseInt(hourPart);
if (isNaN(minute) || isNaN(hour)) return null;
let daysOfWeek: number[] = [];
if (dayPart === '*') {
// Every day
daysOfWeek = [0, 1, 2, 3, 4, 5, 6];
} else if (dayPart.includes(',')) {
// Specific days
daysOfWeek = dayPart.split(',').map(d => parseInt(d)).filter(d => !isNaN(d) && d >= 0 && d <= 6);
} else {
const day = parseInt(dayPart);
if (!isNaN(day) && day >= 0 && day <= 6) {
daysOfWeek = [day];
}
}
return { hour, minute, daysOfWeek };
} catch {
return null;
}
}
export const DAYS_OF_WEEK = [
{ value: 0, label: 'Sun' },
{ value: 1, label: 'Mon' },
{ value: 2, label: 'Tue' },
{ value: 3, label: 'Wed' },
{ value: 4, label: 'Thu' },
{ value: 5, label: 'Fri' },
{ value: 6, label: 'Sat' },
];

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

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

21
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"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": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

15
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
});