Files
wled-controller/frontend/src/pages/GroupsPage.tsx
2025-12-10 18:07:21 +00:00

361 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}