Initial commit
This commit is contained in:
1
frontend/.env.example
Normal file
1
frontend/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=http://localhost:3000/api
|
||||
15
frontend/.eslintrc.json
Normal file
15
frontend/.eslintrc.json
Normal 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
4
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env.local
|
||||
.DS_Store
|
||||
7
frontend/.prettierrc.json
Normal file
7
frontend/.prettierrc.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2
|
||||
}
|
||||
28
frontend/Dockerfile
Normal file
28
frontend/Dockerfile
Normal 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
12
frontend/index.html
Normal 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
20
frontend/nginx.conf
Normal 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
3503
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
frontend/package.json
Normal file
31
frontend/package.json
Normal 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
303
frontend/src/App.css
Normal 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
46
frontend/src/App.tsx
Normal 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;
|
||||
65
frontend/src/api/client.ts
Normal file
65
frontend/src/api/client.ts
Normal 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' }),
|
||||
};
|
||||
39
frontend/src/api/devices.ts
Normal file
39
frontend/src/api/devices.ts
Normal 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'),
|
||||
};
|
||||
31
frontend/src/api/groups.ts
Normal file
31
frontend/src/api/groups.ts
Normal 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),
|
||||
};
|
||||
37
frontend/src/api/schedules.ts
Normal file
37
frontend/src/api/schedules.ts
Normal 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
63
frontend/src/api/types.ts
Normal 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;
|
||||
}
|
||||
255
frontend/src/components/ScheduleComponents.tsx
Normal file
255
frontend/src/components/ScheduleComponents.tsx
Normal 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
9
frontend/src/main.tsx
Normal 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>
|
||||
);
|
||||
294
frontend/src/pages/DashboardPage.tsx
Normal file
294
frontend/src/pages/DashboardPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
252
frontend/src/pages/DevicesPage.tsx
Normal file
252
frontend/src/pages/DevicesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
360
frontend/src/pages/GroupsPage.tsx
Normal file
360
frontend/src/pages/GroupsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
395
frontend/src/pages/SchedulesPage.tsx
Normal file
395
frontend/src/pages/SchedulesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
frontend/src/utils/dateTime.ts
Normal file
15
frontend/src/utils/dateTime.ts
Normal 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');
|
||||
}
|
||||
61
frontend/src/utils/timePicker.ts
Normal file
61
frontend/src/utils/timePicker.ts
Normal 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
9
frontend/src/vite-env.d.ts
vendored
Normal 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
21
frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal 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
15
frontend/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user