Initial commit

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

View File

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