Initial commit
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user