proxy backend
This commit is contained in:
@@ -3,6 +3,7 @@ import { DashboardPage } from './pages/DashboardPage';
|
||||
import { DevicesPage } from './pages/DevicesPage';
|
||||
import { GroupsPage } from './pages/GroupsPage';
|
||||
import { SchedulesPage } from './pages/SchedulesPage';
|
||||
import { QuickActionsPage } from './pages/QuickActionsPage';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
@@ -25,6 +26,9 @@ function App() {
|
||||
<li>
|
||||
<Link to="/schedules">Schedules</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/quick-actions">Quick Actions</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
@@ -36,6 +40,7 @@ function App() {
|
||||
<Route path="/devices" element={<DevicesPage />} />
|
||||
<Route path="/groups" element={<GroupsPage />} />
|
||||
<Route path="/schedules" element={<SchedulesPage />} />
|
||||
<Route path="/quick-actions" element={<QuickActionsPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
|
||||
// Use relative path for API calls - works for both local and network access
|
||||
// When accessed from network, API calls will go to same host as frontend
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || (
|
||||
import.meta.env.DEV
|
||||
? '/api' // In dev mode, use Vite proxy
|
||||
: '/api' // In production, use relative path (backend serves frontend)
|
||||
);
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
|
||||
@@ -15,6 +15,25 @@ export interface UpdateDeviceInput {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface DiscoveredDevice {
|
||||
name: string;
|
||||
ipAddress: string;
|
||||
port: number;
|
||||
host: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface DiscoveryResult {
|
||||
devices: DiscoveredDevice[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface VerifyResult {
|
||||
found: boolean;
|
||||
device?: DiscoveredDevice;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const deviceApi = {
|
||||
getAll: () => apiClient.get<Device[]>('/devices'),
|
||||
|
||||
@@ -36,4 +55,19 @@ export const deviceApi = {
|
||||
turnOnAll: () => apiClient.post('/devices/all/turn-on'),
|
||||
|
||||
turnOffAll: () => apiClient.post('/devices/all/turn-off'),
|
||||
|
||||
syncState: (sourceId: string, targetIds: string[]) =>
|
||||
apiClient.post(`/devices/${sourceId}/sync`, { targetIds }),
|
||||
|
||||
copyConfig: (sourceId: string, targetId: string) =>
|
||||
apiClient.post(`/devices/${sourceId}/copy-to/${targetId}`),
|
||||
|
||||
discoverMdns: (timeout?: number) =>
|
||||
apiClient.post<DiscoveryResult>('/devices/discover/mdns', { timeout }),
|
||||
|
||||
scanIpRange: (baseIp: string, startRange?: number, endRange?: number) =>
|
||||
apiClient.post<DiscoveryResult>('/devices/discover/scan', { baseIp, startRange, endRange }),
|
||||
|
||||
verifyDevice: (ipAddress: string, port?: number) =>
|
||||
apiClient.post<VerifyResult>('/devices/discover/verify', { ipAddress, port }),
|
||||
};
|
||||
|
||||
@@ -28,4 +28,7 @@ export const groupApi = {
|
||||
|
||||
applyPlaylist: (groupId: string, playlist: PlaylistActionPayload) =>
|
||||
apiClient.post<GroupActionResult>(`/groups/${groupId}/playlist`, playlist),
|
||||
|
||||
getPresets: (groupId: string) =>
|
||||
apiClient.get<Record<string, any>>(`/groups/${groupId}/presets`),
|
||||
};
|
||||
|
||||
51
frontend/src/api/quickActions.ts
Normal file
51
frontend/src/api/quickActions.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { apiClient } from './client';
|
||||
|
||||
export type QuickActionType = 'PRESET' | 'PLAYLIST' | 'TURN_ON' | 'TURN_OFF' | 'BRIGHTNESS';
|
||||
|
||||
export interface QuickAction {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
groupId?: string;
|
||||
deviceId?: string;
|
||||
actionType: QuickActionType;
|
||||
actionPayload: unknown;
|
||||
order: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateQuickActionInput {
|
||||
name: string;
|
||||
icon?: string;
|
||||
groupId?: string;
|
||||
deviceId?: string;
|
||||
actionType: QuickActionType;
|
||||
actionPayload: unknown;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface UpdateQuickActionInput {
|
||||
name?: string;
|
||||
icon?: string;
|
||||
groupId?: string;
|
||||
deviceId?: string;
|
||||
actionType?: QuickActionType;
|
||||
actionPayload?: unknown;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export const quickActionApi = {
|
||||
getAll: () => apiClient.get<QuickAction[]>('/quick-actions'),
|
||||
|
||||
getById: (id: string) => apiClient.get<QuickAction>(`/quick-actions/${id}`),
|
||||
|
||||
create: (data: CreateQuickActionInput) => apiClient.post<QuickAction>('/quick-actions', data),
|
||||
|
||||
update: (id: string, data: UpdateQuickActionInput) =>
|
||||
apiClient.put<QuickAction>(`/quick-actions/${id}`, data),
|
||||
|
||||
delete: (id: string) => apiClient.delete(`/quick-actions/${id}`),
|
||||
|
||||
execute: (id: string) => apiClient.post(`/quick-actions/${id}/execute`),
|
||||
};
|
||||
132
frontend/src/components/QuickActionsPanel.tsx
Normal file
132
frontend/src/components/QuickActionsPanel.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { quickActionApi, QuickAction } from '../api/quickActions';
|
||||
|
||||
export function QuickActionsPanel() {
|
||||
const [actions, setActions] = useState<QuickAction[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [executing, setExecuting] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadActions();
|
||||
}, []);
|
||||
|
||||
const loadActions = async () => {
|
||||
try {
|
||||
const data = await quickActionApi.getAll();
|
||||
setActions(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load quick actions:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const executeAction = async (id: string) => {
|
||||
try {
|
||||
setExecuting(id);
|
||||
await quickActionApi.execute(id);
|
||||
} catch (error) {
|
||||
alert('Failed to execute action');
|
||||
} finally {
|
||||
setExecuting(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div>Loading quick actions...</div>;
|
||||
|
||||
if (actions.length === 0) {
|
||||
return (
|
||||
<div className="card" style={{ textAlign: 'center', padding: '40px', color: '#95a5a6' }}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '10px' }}>⚡</div>
|
||||
<div>No quick actions configured</div>
|
||||
<div style={{ fontSize: '14px', marginTop: '5px' }}>
|
||||
Quick actions let you trigger presets, playlists, or control devices with one click
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: '15px' }}>
|
||||
{actions.map((action) => (
|
||||
<button
|
||||
key={action.id}
|
||||
className="btn btn-large"
|
||||
onClick={() => executeAction(action.id)}
|
||||
disabled={executing === action.id}
|
||||
style={{
|
||||
height: '100px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
backgroundColor: getActionColor(action.actionType),
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
position: 'relative',
|
||||
}}
|
||||
title={getActionDescription(action)}
|
||||
>
|
||||
<div style={{ fontSize: '32px' }}>
|
||||
{action.icon || getDefaultIcon(action.actionType)}
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', lineHeight: '1.2' }}>
|
||||
{action.name}
|
||||
</div>
|
||||
{executing === action.id && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '5px',
|
||||
right: '5px',
|
||||
fontSize: '16px',
|
||||
}}>
|
||||
⏳
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getActionColor(actionType: string): string {
|
||||
switch (actionType) {
|
||||
case 'TURN_ON':
|
||||
return '#2ecc71';
|
||||
case 'TURN_OFF':
|
||||
return '#e74c3c';
|
||||
case 'PRESET':
|
||||
return '#3498db';
|
||||
case 'PLAYLIST':
|
||||
return '#9b59b6';
|
||||
case 'BRIGHTNESS':
|
||||
return '#f39c12';
|
||||
default:
|
||||
return '#95a5a6';
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultIcon(actionType: string): string {
|
||||
switch (actionType) {
|
||||
case 'TURN_ON':
|
||||
return '💡';
|
||||
case 'TURN_OFF':
|
||||
return '🌙';
|
||||
case 'PRESET':
|
||||
return '🎨';
|
||||
case 'PLAYLIST':
|
||||
return '🎬';
|
||||
case 'BRIGHTNESS':
|
||||
return '☀️';
|
||||
default:
|
||||
return '⚡';
|
||||
}
|
||||
}
|
||||
|
||||
function getActionDescription(action: QuickAction): string {
|
||||
const target = action.groupId ? 'Group' : action.deviceId ? 'Device' : 'All';
|
||||
return `${action.actionType} - ${target}`;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { timePickerToCron, cronToTimePicker, DAYS_OF_WEEK, TimePickerValue } from '../utils/timePicker';
|
||||
import { groupApi } from '../api/groups';
|
||||
|
||||
interface TimePickerProps {
|
||||
value: string; // cron expression
|
||||
@@ -170,6 +171,7 @@ interface PresetSelectorProps {
|
||||
export function PresetSelector({ groupId, selectedPresets, onChange, mode }: PresetSelectorProps) {
|
||||
const [presets, setPresets] = useState<Record<string, any>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (groupId) {
|
||||
@@ -180,11 +182,13 @@ export function PresetSelector({ groupId, selectedPresets, onChange, mode }: Pre
|
||||
const loadPresets = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`/api/groups/${groupId}/presets`);
|
||||
const data = await response.json();
|
||||
setError(null);
|
||||
const data = await groupApi.getPresets(groupId);
|
||||
setPresets(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load presets:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to load presets');
|
||||
setPresets({});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -204,6 +208,21 @@ export function PresetSelector({ groupId, selectedPresets, onChange, mode }: Pre
|
||||
|
||||
if (loading) return <div style={{ padding: '20px', textAlign: 'center' }}>Loading presets...</div>;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: '#e74c3c' }}>
|
||||
<div>Error loading presets: {error}</div>
|
||||
<button
|
||||
onClick={loadPresets}
|
||||
className="btn btn-small btn-secondary"
|
||||
style={{ marginTop: '10px' }}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const presetEntries = Object.entries(presets)
|
||||
.filter(([key]) => key !== '0')
|
||||
.map(([key, value]) => {
|
||||
@@ -214,7 +233,14 @@ export function PresetSelector({ groupId, selectedPresets, onChange, mode }: Pre
|
||||
.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={{ padding: '20px', textAlign: 'center', color: '#95a5a6' }}>
|
||||
<div>No presets available</div>
|
||||
<div style={{ fontSize: '12px', marginTop: '5px' }}>
|
||||
Make sure the group has devices and at least one device has presets configured
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { formatDateTime } from '../utils/dateTime';
|
||||
import { deviceApi } from '../api/devices';
|
||||
import { QuickActionsPanel } from '../components/QuickActionsPanel';
|
||||
|
||||
interface DeviceStatus {
|
||||
id: string;
|
||||
@@ -33,10 +34,16 @@ interface DeviceStatus {
|
||||
};
|
||||
}
|
||||
|
||||
interface DeviceWithStatus extends DeviceStatus {
|
||||
connectionStatus: 'online' | 'warning' | 'offline';
|
||||
lastSuccessfulPing?: number;
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const [devices, setDevices] = useState<DeviceStatus[]>([]);
|
||||
const [devices, setDevices] = useState<DeviceWithStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [deviceHistory, setDeviceHistory] = useState<Map<string, number>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
loadDevices();
|
||||
@@ -55,8 +62,42 @@ export function DashboardPage() {
|
||||
const loadDevices = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/devices/status/all');
|
||||
const data = await response.json();
|
||||
setDevices(data);
|
||||
const data = await response.json() as DeviceStatus[];
|
||||
|
||||
const now = Date.now();
|
||||
const updatedHistory = new Map(deviceHistory);
|
||||
|
||||
// Update devices with connection status based on current state and history
|
||||
const devicesWithStatus: DeviceWithStatus[] = data.map(device => {
|
||||
const hasCurrentState = device.enabled && device.currentState !== undefined;
|
||||
const lastSuccess = updatedHistory.get(device.id);
|
||||
|
||||
// Update last successful ping time
|
||||
if (hasCurrentState) {
|
||||
updatedHistory.set(device.id, now);
|
||||
}
|
||||
|
||||
// Determine connection status
|
||||
let connectionStatus: 'online' | 'warning' | 'offline';
|
||||
if (hasCurrentState) {
|
||||
connectionStatus = 'online';
|
||||
} else if (lastSuccess && (now - lastSuccess) < 60000) {
|
||||
// Device was online within the last 60 seconds - show as warning
|
||||
connectionStatus = 'warning';
|
||||
} else {
|
||||
// Device hasn't responded in over 60 seconds - show as offline
|
||||
connectionStatus = 'offline';
|
||||
}
|
||||
|
||||
return {
|
||||
...device,
|
||||
connectionStatus,
|
||||
lastSuccessfulPing: updatedHistory.get(device.id)
|
||||
};
|
||||
});
|
||||
|
||||
setDeviceHistory(updatedHistory);
|
||||
setDevices(devicesWithStatus);
|
||||
} catch (error) {
|
||||
console.error('Failed to load device status:', error);
|
||||
} finally {
|
||||
@@ -66,7 +107,7 @@ export function DashboardPage() {
|
||||
|
||||
if (loading) return <div className="loading">Loading dashboard...</div>;
|
||||
|
||||
const onlineDevices = devices.filter(d => d.enabled && d.currentState).length;
|
||||
const onlineDevices = devices.filter(d => d.enabled && d.connectionStatus === 'online').length;
|
||||
const totalEnabled = devices.filter(d => d.enabled).length;
|
||||
|
||||
return (
|
||||
@@ -114,6 +155,11 @@ export function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '30px' }}>
|
||||
<h3 style={{ marginBottom: '15px' }}>Quick Actions</h3>
|
||||
<QuickActionsPanel />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: '20px', marginBottom: '30px' }}>
|
||||
<div className="card">
|
||||
<h3>Total Devices</h3>
|
||||
@@ -133,9 +179,10 @@ export function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginBottom: '15px' }}>Devices</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))', gap: '20px' }}>
|
||||
{devices.map((device) => (
|
||||
<DeviceCard key={device.id} device={device} onRefresh={loadDevices} />
|
||||
<DeviceCard key={device.id} device={device} onRefresh={loadDevices} allDevices={devices} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -143,14 +190,24 @@ export function DashboardPage() {
|
||||
}
|
||||
|
||||
interface DeviceCardProps {
|
||||
device: DeviceStatus;
|
||||
device: DeviceWithStatus;
|
||||
onRefresh: () => void;
|
||||
allDevices: DeviceWithStatus[];
|
||||
}
|
||||
|
||||
function DeviceCard({ device, onRefresh }: DeviceCardProps) {
|
||||
const isOnline = device.enabled && device.currentState !== undefined;
|
||||
function DeviceCard({ device, onRefresh, allDevices }: DeviceCardProps) {
|
||||
const isOnline = device.connectionStatus === 'online';
|
||||
const isWarning = device.connectionStatus === 'warning';
|
||||
const isOn = device.currentState?.on ?? false;
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [showSync, setShowSync] = useState(false);
|
||||
|
||||
// Get status color
|
||||
const getStatusColor = () => {
|
||||
if (device.connectionStatus === 'online') return '#2ecc71'; // Green
|
||||
if (device.connectionStatus === 'warning') return '#f39c12'; // Orange
|
||||
return '#e74c3c'; // Red
|
||||
};
|
||||
|
||||
const handleTurnOn = async () => {
|
||||
try {
|
||||
@@ -177,47 +234,105 @@ function DeviceCard({ device, onRefresh }: DeviceCardProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card" style={{ position: 'relative' }}>
|
||||
<div className="card" style={{ position: 'relative', opacity: device.connectionStatus === 'offline' ? 0.6 : 1 }}>
|
||||
<div style={{ position: 'absolute', top: '15px', right: '15px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: isOnline ? '#2ecc71' : '#e74c3c',
|
||||
backgroundColor: getStatusColor(),
|
||||
}}
|
||||
title={isOnline ? 'Online' : 'Offline'}
|
||||
title={
|
||||
device.connectionStatus === 'online'
|
||||
? 'Online'
|
||||
: device.connectionStatus === 'warning'
|
||||
? 'Connection unstable'
|
||||
: 'Offline'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginBottom: '10px', paddingRight: '30px' }}>{device.name}</h3>
|
||||
<div style={{ fontSize: '12px', color: '#7f8c8d', marginBottom: '15px' }}>
|
||||
{device.ipAddress}:{device.port}
|
||||
<div style={{ fontSize: '12px', marginBottom: '15px' }}>
|
||||
<a
|
||||
href={`http://${device.ipAddress}:${device.port}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#3498db', textDecoration: 'none' }}
|
||||
onMouseOver={(e) => e.currentTarget.style.textDecoration = 'underline'}
|
||||
onMouseOut={(e) => e.currentTarget.style.textDecoration = 'none'}
|
||||
>
|
||||
{device.ipAddress}:{device.port}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{isOnline && (
|
||||
<div style={{ marginBottom: '15px', display: 'flex', gap: '8px' }}>
|
||||
{/* Always show controls for enabled devices, even if temporarily offline */}
|
||||
{device.enabled && (
|
||||
<>
|
||||
<div style={{ marginBottom: '15px', display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
className="btn btn-small btn-primary"
|
||||
onClick={handleTurnOn}
|
||||
disabled={busy || (isOnline && isOn)}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
Turn On
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-small btn-danger"
|
||||
onClick={handleTurnOff}
|
||||
disabled={busy || (isOnline && !isOn)}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
Turn Off
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-small btn-primary"
|
||||
onClick={handleTurnOn}
|
||||
disabled={busy || isOn}
|
||||
style={{ flex: 1 }}
|
||||
className="btn btn-small btn-secondary"
|
||||
onClick={() => setShowSync(!showSync)}
|
||||
style={{ width: '100%', marginBottom: '15px' }}
|
||||
disabled={!isOnline}
|
||||
>
|
||||
Turn On
|
||||
{showSync ? 'Hide' : 'Sync to Other Devices'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-small btn-danger"
|
||||
onClick={handleTurnOff}
|
||||
disabled={busy || !isOn}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
Turn Off
|
||||
</button>
|
||||
</div>
|
||||
{showSync && isOnline && (
|
||||
<SyncPanel
|
||||
sourceDevice={device}
|
||||
allDevices={allDevices.filter(d => d.id !== device.id && d.enabled)}
|
||||
onSync={async (targetIds) => {
|
||||
try {
|
||||
setBusy(true);
|
||||
await deviceApi.syncState(device.id, targetIds);
|
||||
setTimeout(onRefresh, 500);
|
||||
alert('State synced successfully!');
|
||||
} catch (err) {
|
||||
alert('Failed to sync state');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isOnline ? (
|
||||
{/* Show device info if we have current state OR recent successful ping */}
|
||||
{(isOnline || isWarning) && device.currentState ? (
|
||||
<>
|
||||
{isWarning && (
|
||||
<div style={{
|
||||
backgroundColor: '#fff3cd',
|
||||
color: '#856404',
|
||||
padding: '8px',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '10px',
|
||||
fontSize: '12px'
|
||||
}}>
|
||||
⚠️ Connection unstable - retrying...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
<strong>Status:</strong>{' '}
|
||||
<span style={{ color: isOn ? '#2ecc71' : '#95a5a6' }}>
|
||||
@@ -292,3 +407,101 @@ function DeviceCard({ device, onRefresh }: DeviceCardProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SyncPanelProps {
|
||||
sourceDevice: DeviceStatus;
|
||||
allDevices: DeviceStatus[];
|
||||
onSync: (targetIds: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
function SyncPanel({ sourceDevice, allDevices, onSync }: SyncPanelProps) {
|
||||
const [selectedDevices, setSelectedDevices] = useState<string[]>([]);
|
||||
|
||||
const toggleDevice = (deviceId: string) => {
|
||||
setSelectedDevices(prev =>
|
||||
prev.includes(deviceId)
|
||||
? prev.filter(id => id !== deviceId)
|
||||
: [...prev, deviceId]
|
||||
);
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
setSelectedDevices(allDevices.map(d => d.id));
|
||||
};
|
||||
|
||||
const selectNone = () => {
|
||||
setSelectedDevices([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '10px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '15px'
|
||||
}}>
|
||||
<div style={{ fontSize: '12px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||
Copy current state from "{sourceDevice.name}" to:
|
||||
</div>
|
||||
<div style={{ marginBottom: '8px', display: 'flex', gap: '5px' }}>
|
||||
<button
|
||||
className="btn btn-small btn-secondary"
|
||||
onClick={selectAll}
|
||||
style={{ fontSize: '11px', padding: '3px 8px' }}
|
||||
>
|
||||
Select All
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-small btn-secondary"
|
||||
onClick={selectNone}
|
||||
style={{ fontSize: '11px', padding: '3px 8px' }}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ maxHeight: '150px', overflowY: 'auto', marginBottom: '8px' }}>
|
||||
{allDevices.length === 0 ? (
|
||||
<div style={{ fontSize: '12px', color: '#95a5a6', padding: '5px' }}>
|
||||
No other devices available
|
||||
</div>
|
||||
) : (
|
||||
allDevices.map(device => (
|
||||
<label
|
||||
key={device.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '4px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDevices.includes(device.id)}
|
||||
onChange={() => toggleDevice(device.id)}
|
||||
/>
|
||||
<span>{device.name}</span>
|
||||
<span style={{
|
||||
marginLeft: 'auto',
|
||||
fontSize: '10px',
|
||||
color: device.currentState ? '#2ecc71' : '#e74c3c'
|
||||
}}>
|
||||
{device.currentState ? '●' : '○'}
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-small btn-primary"
|
||||
onClick={() => onSync(selectedDevices)}
|
||||
disabled={selectedDevices.length === 0}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
Sync to {selectedDevices.length} Device{selectedDevices.length !== 1 ? 's' : ''}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { deviceApi } from '../api/devices';
|
||||
import { deviceApi, DiscoveredDevice } from '../api/devices';
|
||||
import { Device } from '../api/types';
|
||||
import { formatDateTime } from '../utils/dateTime';
|
||||
|
||||
@@ -9,6 +9,8 @@ export function DevicesPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingDevice, setEditingDevice] = useState<Device | null>(null);
|
||||
const [filter, setFilter] = useState<'all' | 'enabled' | 'disabled'>('all');
|
||||
const [showDiscovery, setShowDiscovery] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadDevices();
|
||||
@@ -73,13 +75,33 @@ export function DevicesPage() {
|
||||
|
||||
if (loading) return <div className="loading">Loading devices...</div>;
|
||||
|
||||
const filteredDevices = devices.filter(device => {
|
||||
if (filter === 'enabled') return device.enabled;
|
||||
if (filter === 'disabled') return !device.enabled;
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h2>Devices</h2>
|
||||
<button className="btn btn-primary" onClick={openCreateModal}>
|
||||
Add Device
|
||||
</button>
|
||||
<h2>Devices ({devices.length})</h2>
|
||||
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value as 'all' | 'enabled' | 'disabled')}
|
||||
style={{ padding: '8px' }}
|
||||
>
|
||||
<option value="all">All Devices ({devices.length})</option>
|
||||
<option value="enabled">Enabled ({devices.filter(d => d.enabled).length})</option>
|
||||
<option value="disabled">Disabled ({devices.filter(d => !d.enabled).length})</option>
|
||||
</select>
|
||||
<button className="btn btn-secondary" onClick={() => setShowDiscovery(true)}>
|
||||
🔍 Discover Devices
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={openCreateModal}>
|
||||
Add Device
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
@@ -96,7 +118,7 @@ export function DevicesPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{devices.map((device) => (
|
||||
{filteredDevices.map((device) => (
|
||||
<tr key={device.id}>
|
||||
<td>{device.name}</td>
|
||||
<td>{device.ipAddress}</td>
|
||||
@@ -149,6 +171,223 @@ export function DevicesPage() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDiscovery && (
|
||||
<DiscoveryModal
|
||||
onClose={() => setShowDiscovery(false)}
|
||||
onDeviceAdded={() => {
|
||||
setShowDiscovery(false);
|
||||
loadDevices();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DiscoveryModalProps {
|
||||
onClose: () => void;
|
||||
onDeviceAdded: () => void;
|
||||
}
|
||||
|
||||
function DiscoveryModal({ onClose, onDeviceAdded }: DiscoveryModalProps) {
|
||||
const [discoveryMethod, setDiscoveryMethod] = useState<'mdns' | 'scan'>('mdns');
|
||||
const [discovering, setDiscovering] = useState(false);
|
||||
const [discoveredDevices, setDiscoveredDevices] = useState<DiscoveredDevice[]>([]);
|
||||
const [baseIp, setBaseIp] = useState('192.168.1');
|
||||
const [startRange, setStartRange] = useState(1);
|
||||
const [endRange, setEndRange] = useState(254);
|
||||
const [selectedDevices, setSelectedDevices] = useState<Set<string>>(new Set());
|
||||
|
||||
const handleDiscover = async () => {
|
||||
try {
|
||||
setDiscovering(true);
|
||||
setDiscoveredDevices([]);
|
||||
setSelectedDevices(new Set());
|
||||
|
||||
let result;
|
||||
if (discoveryMethod === 'mdns') {
|
||||
result = await deviceApi.discoverMdns(5000);
|
||||
} else {
|
||||
result = await deviceApi.scanIpRange(baseIp, startRange, endRange);
|
||||
}
|
||||
|
||||
setDiscoveredDevices(result.devices);
|
||||
|
||||
if (result.devices.length === 0) {
|
||||
alert('No WLED devices found');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Failed to discover devices');
|
||||
} finally {
|
||||
setDiscovering(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDeviceSelection = (ipAddress: string) => {
|
||||
const newSelection = new Set(selectedDevices);
|
||||
if (newSelection.has(ipAddress)) {
|
||||
newSelection.delete(ipAddress);
|
||||
} else {
|
||||
newSelection.add(ipAddress);
|
||||
}
|
||||
setSelectedDevices(newSelection);
|
||||
};
|
||||
|
||||
const handleAddSelected = async () => {
|
||||
const devicesToAdd = discoveredDevices.filter(d => selectedDevices.has(d.ipAddress));
|
||||
|
||||
if (devicesToAdd.length === 0) {
|
||||
alert('Please select at least one device to add');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
for (const device of devicesToAdd) {
|
||||
await deviceApi.create({
|
||||
name: device.name,
|
||||
ipAddress: device.ipAddress,
|
||||
port: device.port,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
alert(`Successfully added ${devicesToAdd.length} device(s)`);
|
||||
onDeviceAdded();
|
||||
} catch (err) {
|
||||
alert('Failed to add devices');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal" onClick={onClose}>
|
||||
<div className="modal-content" style={{ maxWidth: '700px' }} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>Discover WLED Devices</h3>
|
||||
<button className="close-btn" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div className="form-group">
|
||||
<label>Discovery Method</label>
|
||||
<select
|
||||
value={discoveryMethod}
|
||||
onChange={(e) => setDiscoveryMethod(e.target.value as 'mdns' | 'scan')}
|
||||
disabled={discovering}
|
||||
>
|
||||
<option value="mdns">mDNS (Automatic - Recommended)</option>
|
||||
<option value="scan">IP Range Scan</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{discoveryMethod === 'scan' && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label>Base IP Address (e.g., 192.168.1)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={baseIp}
|
||||
onChange={(e) => setBaseIp(e.target.value)}
|
||||
placeholder="192.168.1"
|
||||
disabled={discovering}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
|
||||
<div className="form-group">
|
||||
<label>Start Range</label>
|
||||
<input
|
||||
type="number"
|
||||
value={startRange}
|
||||
onChange={(e) => setStartRange(parseInt(e.target.value))}
|
||||
min="1"
|
||||
max="254"
|
||||
disabled={discovering}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>End Range</label>
|
||||
<input
|
||||
type="number"
|
||||
value={endRange}
|
||||
onChange={(e) => setEndRange(parseInt(e.target.value))}
|
||||
min="1"
|
||||
max="254"
|
||||
disabled={discovering}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleDiscover}
|
||||
disabled={discovering}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{discovering ? 'Discovering...' : '🔍 Start Discovery'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{discoveredDevices.length > 0 && (
|
||||
<>
|
||||
<h4>Found {discoveredDevices.length} device(s)</h4>
|
||||
<div style={{ maxHeight: '300px', overflowY: 'auto', marginBottom: '20px' }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '40px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDevices.size === discoveredDevices.length}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedDevices(new Set(discoveredDevices.map(d => d.ipAddress)));
|
||||
} else {
|
||||
setSelectedDevices(new Set());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</th>
|
||||
<th>Name</th>
|
||||
<th>IP Address</th>
|
||||
<th>Port</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{discoveredDevices.map((device) => (
|
||||
<tr key={device.ipAddress}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDevices.has(device.ipAddress)}
|
||||
onChange={() => toggleDeviceSelection(device.ipAddress)}
|
||||
/>
|
||||
</td>
|
||||
<td>{device.name}</td>
|
||||
<td>{device.ipAddress}</td>
|
||||
<td>{device.port}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button className="btn btn-secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleAddSelected}
|
||||
disabled={selectedDevices.size === 0}
|
||||
>
|
||||
Add Selected ({selectedDevices.size})
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
367
frontend/src/pages/QuickActionsPage.tsx
Normal file
367
frontend/src/pages/QuickActionsPage.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { quickActionApi, QuickAction, CreateQuickActionInput } from '../api/quickActions';
|
||||
import { groupApi } from '../api/groups';
|
||||
import { deviceApi } from '../api/devices';
|
||||
import { Group, Device } from '../api/types';
|
||||
|
||||
export function QuickActionsPage() {
|
||||
const [actions, setActions] = useState<QuickAction[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingAction, setEditingAction] = useState<QuickAction | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadActions();
|
||||
}, []);
|
||||
|
||||
const loadActions = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await quickActionApi.getAll();
|
||||
setActions(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to load quick actions');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this quick action?')) return;
|
||||
|
||||
try {
|
||||
await quickActionApi.delete(id);
|
||||
loadActions();
|
||||
} catch (err) {
|
||||
alert('Failed to delete quick action');
|
||||
}
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditingAction(null);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const openEditModal = (action: QuickAction) => {
|
||||
setEditingAction(action);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
if (loading) return <div className="loading">Loading quick actions...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h2>Quick Actions ({actions.length})</h2>
|
||||
<button className="btn btn-primary" onClick={openCreateModal}>
|
||||
Create Quick Action
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
{actions.length === 0 ? (
|
||||
<div className="card" style={{ textAlign: 'center', padding: '40px', color: '#95a5a6' }}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '10px' }}>⚡</div>
|
||||
<div style={{ marginBottom: '10px' }}>No quick actions configured</div>
|
||||
<div style={{ fontSize: '14px', marginBottom: '20px' }}>
|
||||
Quick actions let you trigger presets, playlists, or control devices with one click from the dashboard
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={openCreateModal}>
|
||||
Create Your First Quick Action
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Order</th>
|
||||
<th>Name</th>
|
||||
<th>Icon</th>
|
||||
<th>Type</th>
|
||||
<th>Target</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{actions.map((action) => (
|
||||
<tr key={action.id}>
|
||||
<td>{action.order}</td>
|
||||
<td>{action.name}</td>
|
||||
<td style={{ fontSize: '24px' }}>{action.icon || '⚡'}</td>
|
||||
<td>
|
||||
<span style={{
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
backgroundColor: getActionColor(action.actionType),
|
||||
color: 'white'
|
||||
}}>
|
||||
{action.actionType}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{action.groupId ? 'Group' : action.deviceId ? 'Device' : 'All Devices'}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-small btn-secondary"
|
||||
onClick={() => openEditModal(action)}
|
||||
style={{ marginRight: '5px' }}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-small btn-danger"
|
||||
onClick={() => handleDelete(action.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{showModal && (
|
||||
<QuickActionModal
|
||||
action={editingAction}
|
||||
onClose={() => setShowModal(false)}
|
||||
onSave={() => {
|
||||
setShowModal(false);
|
||||
loadActions();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface QuickActionModalProps {
|
||||
action: QuickAction | null;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
function QuickActionModal({ action, onClose, onSave }: QuickActionModalProps) {
|
||||
const [name, setName] = useState(action?.name || '');
|
||||
const [icon, setIcon] = useState(action?.icon || '');
|
||||
const [actionType, setActionType] = useState<'PRESET' | 'PLAYLIST' | 'TURN_ON' | 'TURN_OFF' | 'BRIGHTNESS'>(
|
||||
action?.actionType || 'TURN_ON'
|
||||
);
|
||||
const [targetType, setTargetType] = useState<'group' | 'device' | 'all'>(
|
||||
action?.groupId ? 'group' : action?.deviceId ? 'device' : 'all'
|
||||
);
|
||||
const [groupId, setGroupId] = useState(action?.groupId || '');
|
||||
const [deviceId, setDeviceId] = useState(action?.deviceId || '');
|
||||
const [presetId, setPresetId] = useState('1');
|
||||
const [brightness, setBrightness] = useState('128');
|
||||
const [order, setOrder] = useState(action?.order?.toString() || '0');
|
||||
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
const [devices, setDevices] = useState<Device[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadGroupsAndDevices();
|
||||
if (action?.actionPayload) {
|
||||
const payload = action.actionPayload as any;
|
||||
if (payload.presetId) setPresetId(payload.presetId.toString());
|
||||
if (payload.brightness) setBrightness(payload.brightness.toString());
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadGroupsAndDevices = async () => {
|
||||
const [groupsData, devicesData] = await Promise.all([
|
||||
groupApi.getAll(),
|
||||
deviceApi.getAll(),
|
||||
]);
|
||||
setGroups(groupsData);
|
||||
setDevices(devicesData);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
let actionPayload: any = {};
|
||||
|
||||
if (actionType === 'PRESET') {
|
||||
actionPayload = { presetId: parseInt(presetId) };
|
||||
} else if (actionType === 'BRIGHTNESS') {
|
||||
actionPayload = { brightness: parseInt(brightness) };
|
||||
}
|
||||
|
||||
const data: CreateQuickActionInput = {
|
||||
name,
|
||||
icon: icon || undefined,
|
||||
actionType,
|
||||
groupId: targetType === 'group' ? groupId : undefined,
|
||||
deviceId: targetType === 'device' ? deviceId : undefined,
|
||||
actionPayload,
|
||||
order: parseInt(order),
|
||||
};
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
|
||||
if (action) {
|
||||
await quickActionApi.update(action.id, data);
|
||||
} else {
|
||||
await quickActionApi.create(data);
|
||||
}
|
||||
|
||||
onSave();
|
||||
} catch (err) {
|
||||
alert('Failed to save quick action');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: '600px' }}>
|
||||
<div className="modal-header">
|
||||
<h3>{action ? 'Edit Quick Action' : 'Create Quick Action'}</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)}
|
||||
placeholder="e.g., Bedtime Lights"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Icon (Emoji)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={icon}
|
||||
onChange={(e) => setIcon(e.target.value)}
|
||||
placeholder="e.g., 🌙 💡 🎨"
|
||||
maxLength={2}
|
||||
/>
|
||||
<div style={{ fontSize: '12px', color: '#7f8c8d', marginTop: '5px' }}>
|
||||
Leave empty for default icon
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Action Type *</label>
|
||||
<select value={actionType} onChange={(e) => setActionType(e.target.value as any)} required>
|
||||
<option value="TURN_ON">Turn On</option>
|
||||
<option value="TURN_OFF">Turn Off</option>
|
||||
<option value="PRESET">Apply Preset</option>
|
||||
<option value="BRIGHTNESS">Set Brightness</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{actionType === 'PRESET' && (
|
||||
<div className="form-group">
|
||||
<label>Preset ID *</label>
|
||||
<input
|
||||
type="number"
|
||||
value={presetId}
|
||||
onChange={(e) => setPresetId(e.target.value)}
|
||||
min="1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionType === 'BRIGHTNESS' && (
|
||||
<div className="form-group">
|
||||
<label>Brightness (0-255) *</label>
|
||||
<input
|
||||
type="number"
|
||||
value={brightness}
|
||||
onChange={(e) => setBrightness(e.target.value)}
|
||||
min="0"
|
||||
max="255"
|
||||
required
|
||||
/>
|
||||
<div style={{ fontSize: '12px', color: '#7f8c8d', marginTop: '5px' }}>
|
||||
{Math.round((parseInt(brightness) / 255) * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label>Target *</label>
|
||||
<select value={targetType} onChange={(e) => setTargetType(e.target.value as any)} required>
|
||||
<option value="all">All Devices</option>
|
||||
<option value="group">Specific Group</option>
|
||||
<option value="device">Specific Device</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{targetType === 'group' && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{targetType === 'device' && (
|
||||
<div className="form-group">
|
||||
<label>Device *</label>
|
||||
<select value={deviceId} onChange={(e) => setDeviceId(e.target.value)} required>
|
||||
<option value="">Select a device</option>
|
||||
{devices.map(device => (
|
||||
<option key={device.id} value={device.id}>{device.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label>Display Order</label>
|
||||
<input
|
||||
type="number"
|
||||
value={order}
|
||||
onChange={(e) => setOrder(e.target.value)}
|
||||
min="0"
|
||||
/>
|
||||
<div style={{ fontSize: '12px', color: '#7f8c8d', marginTop: '5px' }}>
|
||||
Lower numbers appear first
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
function getActionColor(actionType: string): string {
|
||||
switch (actionType) {
|
||||
case 'TURN_ON': return '#2ecc71';
|
||||
case 'TURN_OFF': return '#e74c3c';
|
||||
case 'PRESET': return '#3498db';
|
||||
case 'PLAYLIST': return '#9b59b6';
|
||||
case 'BRIGHTNESS': return '#f39c12';
|
||||
default: return '#95a5a6';
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ 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[]>([]);
|
||||
|
||||
@@ -4,6 +4,7 @@ import react from '@vitejs/plugin-react';
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
|
||||
Reference in New Issue
Block a user