u
This commit is contained in:
130
uiserver/src/App.js
Normal file
130
uiserver/src/App.js
Normal file
@@ -0,0 +1,130 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Link, Navigate, useNavigate } from 'react-router-dom';
|
||||
import { ThemeProvider, CssBaseline, AppBar, Toolbar, Typography, Button, Box, IconButton, Menu, MenuItem } from '@mui/material';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import ShowChartIcon from '@mui/icons-material/ShowChart';
|
||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||
import AccountCircle from '@mui/icons-material/AccountCircle';
|
||||
import theme from './theme';
|
||||
import Settings from './components/Settings';
|
||||
import Chart from './components/Chart';
|
||||
import Login from './components/Login';
|
||||
import ViewManager from './components/ViewManager';
|
||||
import ViewDisplay from './components/ViewDisplay';
|
||||
|
||||
function NavBar({ user, onLogout }) {
|
||||
const navigate = useNavigate();
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
|
||||
const handleMenu = (event) => setAnchorEl(event.currentTarget);
|
||||
const handleClose = () => setAnchorEl(null);
|
||||
const handleLogout = () => {
|
||||
handleClose();
|
||||
onLogout();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<AppBar position="static">
|
||||
<Toolbar>
|
||||
<Typography variant="h6" component="div" sx={{ flexGrow: 1, cursor: 'pointer' }} onClick={() => navigate('/')}>
|
||||
TischlerCtrl
|
||||
</Typography>
|
||||
|
||||
<Button color="inherit" startIcon={<DashboardIcon />} onClick={() => navigate('/')}>
|
||||
Views
|
||||
</Button>
|
||||
<Button color="inherit" startIcon={<ShowChartIcon />} onClick={() => navigate('/live')}>
|
||||
Live
|
||||
</Button>
|
||||
<Button color="inherit" startIcon={<SettingsIcon />} onClick={() => navigate('/settings')}>
|
||||
Settings
|
||||
</Button>
|
||||
|
||||
{user ? (
|
||||
<div>
|
||||
<IconButton
|
||||
size="large"
|
||||
onClick={handleMenu}
|
||||
color="inherit"
|
||||
>
|
||||
<AccountCircle />
|
||||
</IconButton>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<MenuItem disabled>{user.username} ({user.role})</MenuItem>
|
||||
<MenuItem onClick={handleLogout}>Logout</MenuItem>
|
||||
</Menu>
|
||||
</div>
|
||||
) : (
|
||||
<Button color="inherit" onClick={() => navigate('/login')}>
|
||||
Login
|
||||
</Button>
|
||||
)}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [user, setUser] = useState(null);
|
||||
const [selectedChannels, setSelectedChannels] = useState([]);
|
||||
|
||||
// Load persistence (User + Settings)
|
||||
useEffect(() => {
|
||||
try {
|
||||
const savedSettings = localStorage.getItem('selectedChannels');
|
||||
if (savedSettings) setSelectedChannels(JSON.parse(savedSettings));
|
||||
|
||||
const savedUser = localStorage.getItem('user');
|
||||
if (savedUser) setUser(JSON.parse(savedUser));
|
||||
} catch (e) {
|
||||
console.error("Failed to load persistence", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLogin = (userData) => {
|
||||
setUser(userData);
|
||||
localStorage.setItem('user', JSON.stringify(userData));
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
setUser(null);
|
||||
localStorage.removeItem('user');
|
||||
};
|
||||
|
||||
const handleToggleChannel = (id) => {
|
||||
setSelectedChannels(prev => {
|
||||
const newSelection = prev.includes(id)
|
||||
? prev.filter(c => c !== id)
|
||||
: [...prev, id];
|
||||
localStorage.setItem('selectedChannels', JSON.stringify(newSelection));
|
||||
return newSelection;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<BrowserRouter>
|
||||
<Box sx={{ flexGrow: 1, height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
<NavBar user={user} onLogout={handleLogout} />
|
||||
|
||||
<Box component="main" sx={{ flexGrow: 1, overflow: 'auto' }}>
|
||||
<Routes>
|
||||
<Route path="/" element={<ViewManager user={user} />} />
|
||||
<Route path="/live" element={<Chart selectedChannels={selectedChannels} />} />
|
||||
<Route path="/settings" element={<Settings selectedChannels={selectedChannels} onToggleChannel={handleToggleChannel} />} />
|
||||
<Route path="/login" element={<Login onLogin={handleLogin} />} />
|
||||
|
||||
<Route path="/views/:id" element={<ViewDisplay />} />
|
||||
</Routes>
|
||||
</Box>
|
||||
</Box>
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
109
uiserver/src/components/Chart.js
vendored
Normal file
109
uiserver/src/components/Chart.js
vendored
Normal file
@@ -0,0 +1,109 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Paper, Typography, Box, CircularProgress } from '@mui/material';
|
||||
import { LineChart } from '@mui/x-charts/LineChart';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
|
||||
export default function Chart({ selectedChannels = [], channelConfig = null }) {
|
||||
const [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const theme = useTheme();
|
||||
|
||||
// Determine effective channels list
|
||||
const effectiveChannels = channelConfig
|
||||
? channelConfig.map(c => c.id)
|
||||
: selectedChannels;
|
||||
|
||||
const fetchData = () => {
|
||||
// Only fetch if selection exists
|
||||
if (effectiveChannels.length === 0) {
|
||||
setData([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectionStr = effectiveChannels.join(',');
|
||||
const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
fetch(`/api/readings?selection=${encodeURIComponent(selectionStr)}&since=${since}`)
|
||||
.then(res => res.json())
|
||||
.then(rows => {
|
||||
const timeMap = new Map();
|
||||
|
||||
rows.forEach(row => {
|
||||
const id = `${row.device}:${row.channel}`;
|
||||
if (!effectiveChannels.includes(id)) return;
|
||||
|
||||
const time = new Date(row.timestamp).getTime();
|
||||
if (!timeMap.has(time)) {
|
||||
timeMap.set(time, { time: new Date(row.timestamp) });
|
||||
}
|
||||
const entry = timeMap.get(time);
|
||||
|
||||
let val = row.value;
|
||||
if (row.data_type === 'json' && !val) {
|
||||
val = null;
|
||||
}
|
||||
entry[id] = val;
|
||||
});
|
||||
|
||||
const sortedData = Array.from(timeMap.values()).sort((a, b) => a.time - b.time);
|
||||
setData(sortedData);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Failed to fetch data", err);
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, [selectedChannels, channelConfig]);
|
||||
|
||||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
|
||||
if (effectiveChannels.length === 0) return <Box sx={{ p: 4 }}><Typography>No channels selected.</Typography></Box>;
|
||||
|
||||
const series = effectiveChannels.map(id => {
|
||||
// Find alias if config exists
|
||||
let label = id;
|
||||
if (channelConfig) {
|
||||
const item = channelConfig.find(c => c.id === id);
|
||||
if (item && item.alias) label = item.alias;
|
||||
}
|
||||
|
||||
return {
|
||||
dataKey: id,
|
||||
label: label,
|
||||
connectNulls: true,
|
||||
showMark: false,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', height: '80vh', p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>Last 24 Hours</Typography>
|
||||
<Paper sx={{ p: 2, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<LineChart
|
||||
dataset={data}
|
||||
series={series}
|
||||
xAxis={[{
|
||||
dataKey: 'time',
|
||||
scaleType: 'time',
|
||||
valueFormatter: (date) => date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}]}
|
||||
slotProps={{
|
||||
legend: {
|
||||
direction: 'row',
|
||||
position: { vertical: 'top', horizontal: 'middle' },
|
||||
padding: 0,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
64
uiserver/src/components/Login.js
Normal file
64
uiserver/src/components/Login.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Paper, TextField, Button, Typography, Container, Alert
|
||||
} from '@mui/material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export default function Login({ onLogin }) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Login failed');
|
||||
}
|
||||
|
||||
onLogin(data); // { token, role, username }
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="xs" sx={{ mt: 8 }}>
|
||||
<Paper sx={{ p: 4, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Typography variant="h5" align="center">Login</Typography>
|
||||
{error && <Alert severity="error">{error}</Alert>}
|
||||
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<TextField
|
||||
label="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
<Button type="submit" variant="contained" color="primary" fullWidth>
|
||||
Sign In
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
71
uiserver/src/components/Settings.js
Normal file
71
uiserver/src/components/Settings.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
List, ListItem, ListItemIcon, ListItemText, ListItemSecondaryAction,
|
||||
Switch, Paper, Typography, CircularProgress, Container
|
||||
} from '@mui/material';
|
||||
import SensorsIcon from '@mui/icons-material/Sensors';
|
||||
|
||||
export default function Settings({ selectedChannels, onToggleChannel }) {
|
||||
const [devices, setDevices] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/devices')
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error('Failed to load devices');
|
||||
return res.json();
|
||||
})
|
||||
.then(data => {
|
||||
setDevices(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (loading) return <Container sx={{ mt: 4, textAlign: 'center' }}><CircularProgress /></Container>;
|
||||
if (error) return <Container sx={{ mt: 4 }}><Typography color="error">Error: {error}</Typography></Container>;
|
||||
|
||||
return (
|
||||
<Container maxWidth="md" sx={{ mt: 4 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Channel Selection
|
||||
</Typography>
|
||||
<Paper>
|
||||
<List>
|
||||
{devices.map((device, index) => {
|
||||
const id = `${device.device}:${device.channel}`;
|
||||
const isSelected = selectedChannels.includes(id);
|
||||
|
||||
return (
|
||||
<ListItem key={id} divider={index !== devices.length - 1}>
|
||||
<ListItemIcon>
|
||||
<SensorsIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={device.channel}
|
||||
secondary={`Device: ${device.device}`}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Switch
|
||||
edge="end"
|
||||
checked={isSelected}
|
||||
onChange={() => onToggleChannel(id)}
|
||||
/>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
{devices.length === 0 && (
|
||||
<ListItem>
|
||||
<ListItemText primary="No devices found in database" />
|
||||
</ListItem>
|
||||
)}
|
||||
</List>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
45
uiserver/src/components/ViewDisplay.js
Normal file
45
uiserver/src/components/ViewDisplay.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Box, Typography, Container, CircularProgress } from '@mui/material';
|
||||
import Chart from './Chart';
|
||||
|
||||
export default function ViewDisplay() {
|
||||
const { id } = useParams();
|
||||
const [view, setView] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/views/${id}`)
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error('View not found');
|
||||
return res.json();
|
||||
})
|
||||
.then(data => {
|
||||
setView(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <Container sx={{ mt: 4, textAlign: 'center' }}><CircularProgress /></Container>;
|
||||
if (error) return <Container sx={{ mt: 4 }}><Typography color="error">{error}</Typography></Container>;
|
||||
|
||||
// Map view config to Chart format with aliases
|
||||
const channelConfig = view.config.map(item => ({
|
||||
id: `${item.device}:${item.channel}`,
|
||||
alias: item.alias
|
||||
}));
|
||||
|
||||
return (
|
||||
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Typography variant="h5">{view.name}</Typography>
|
||||
</Box>
|
||||
<Chart channelConfig={channelConfig} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
167
uiserver/src/components/ViewManager.js
Normal file
167
uiserver/src/components/ViewManager.js
Normal file
@@ -0,0 +1,167 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Container, Typography, List, ListItem, ListItemText, ListItemIcon,
|
||||
Button, TextField, Dialog, DialogTitle, DialogContent, DialogActions,
|
||||
FormControl, InputLabel, Select, MenuItem, Box, Chip
|
||||
} from '@mui/material';
|
||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export default function ViewManager({ user }) {
|
||||
const [views, setViews] = useState([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [newViewName, setNewViewName] = useState('');
|
||||
const [availableDevices, setAvailableDevices] = useState([]);
|
||||
const [viewConfig, setViewConfig] = useState([]); // [{ device, channel, alias }]
|
||||
|
||||
// Selection state for new item
|
||||
const [selDevice, setSelDevice] = useState('');
|
||||
const [selChannel, setSelChannel] = useState('');
|
||||
const [alias, setAlias] = useState('');
|
||||
|
||||
const navigate = useNavigate();
|
||||
const isAdmin = user && user.role === 'admin';
|
||||
|
||||
useEffect(() => {
|
||||
refreshViews();
|
||||
if (isAdmin) {
|
||||
fetch('/api/devices')
|
||||
.then(res => res.json())
|
||||
.then(setAvailableDevices)
|
||||
.catch(console.error);
|
||||
}
|
||||
}, [isAdmin]);
|
||||
|
||||
const refreshViews = () => {
|
||||
fetch('/api/views')
|
||||
.then(res => res.json())
|
||||
.then(setViews)
|
||||
.catch(console.error);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newViewName || viewConfig.length === 0) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/views', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${user.token}`
|
||||
},
|
||||
body: JSON.stringify({ name: newViewName, config: viewConfig })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setOpen(false);
|
||||
setNewViewName('');
|
||||
setViewConfig([]);
|
||||
refreshViews();
|
||||
} else {
|
||||
alert('Failed to create view');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const addConfigItem = () => {
|
||||
if (selDevice && selChannel) {
|
||||
setViewConfig([...viewConfig, { device: selDevice, channel: selChannel, alias: alias || `${selDevice}:${selChannel}` }]);
|
||||
setAlias('');
|
||||
}
|
||||
};
|
||||
|
||||
// Derived state for channels
|
||||
const channelsForDevice = availableDevices.filter(d => d.device === selDevice).map(d => d.channel);
|
||||
const uniqueDevices = [...new Set(availableDevices.map(d => d.device))];
|
||||
|
||||
return (
|
||||
<Container maxWidth="md" sx={{ mt: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="h4">Views</Typography>
|
||||
{isAdmin && (
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setOpen(true)}>
|
||||
Create View
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<List>
|
||||
{views.map(view => (
|
||||
<ListItem
|
||||
button
|
||||
key={view.id}
|
||||
divider
|
||||
onClick={() => navigate(`/views/${view.id}`)}
|
||||
>
|
||||
<ListItemIcon><DashboardIcon /></ListItemIcon>
|
||||
<ListItemText
|
||||
primary={view.name}
|
||||
secondary={`Created: ${new Date(view.created_at).toLocaleDateString()}`}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
{views.length === 0 && <Typography>No views available.</Typography>}
|
||||
</List>
|
||||
|
||||
{/* Create Dialog */}
|
||||
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Create New View</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label="View Name"
|
||||
fullWidth
|
||||
value={newViewName}
|
||||
onChange={(e) => setNewViewName(e.target.value)}
|
||||
/>
|
||||
|
||||
<Box sx={{ mt: 2, p: 2, border: '1px solid #444', borderRadius: 1 }}>
|
||||
<Typography variant="subtitle2">Add Channels</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 1 }}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Device</InputLabel>
|
||||
<Select value={selDevice} label="Device" onChange={(e) => setSelDevice(e.target.value)}>
|
||||
{uniqueDevices.map(d => <MenuItem key={d} value={d}>{d}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Channel</InputLabel>
|
||||
<Select value={selChannel} label="Channel" onChange={(e) => setSelChannel(e.target.value)}>
|
||||
{channelsForDevice.map(c => <MenuItem key={c} value={c}>{c}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 1 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Alias (Optional)"
|
||||
fullWidth
|
||||
value={alias}
|
||||
onChange={(e) => setAlias(e.target.value)}
|
||||
/>
|
||||
<Button variant="outlined" onClick={addConfigItem}>Add</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 2, display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{viewConfig.map((item, idx) => (
|
||||
<Chip
|
||||
key={idx}
|
||||
label={`${item.alias} (${item.device}:${item.channel})`}
|
||||
onDelete={() => setViewConfig(viewConfig.filter((_, i) => i !== idx))}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleCreate} color="primary">Save</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
7
uiserver/src/index.js
Normal file
7
uiserver/src/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
const container = document.getElementById('root');
|
||||
const root = createRoot(container);
|
||||
root.render(<App />);
|
||||
74
uiserver/src/theme.js
Normal file
74
uiserver/src/theme.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import { createTheme } from '@mui/material/styles';
|
||||
|
||||
const gruvbox = {
|
||||
bg: '#282828',
|
||||
bg1: '#3c3836',
|
||||
bg2: '#504945',
|
||||
fg: '#ebdbb2',
|
||||
red: '#cc241d',
|
||||
green: '#98971a',
|
||||
yellow: '#d79921',
|
||||
blue: '#458588',
|
||||
purple: '#b16286',
|
||||
aqua: '#689d6a',
|
||||
orange: '#d65d0e',
|
||||
gray: '#928374',
|
||||
};
|
||||
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
background: {
|
||||
default: gruvbox.bg,
|
||||
paper: gruvbox.bg1,
|
||||
},
|
||||
primary: {
|
||||
main: gruvbox.orange,
|
||||
},
|
||||
secondary: {
|
||||
main: gruvbox.blue,
|
||||
},
|
||||
text: {
|
||||
primary: gruvbox.fg,
|
||||
secondary: gruvbox.gray,
|
||||
},
|
||||
error: {
|
||||
main: gruvbox.red,
|
||||
},
|
||||
success: {
|
||||
main: gruvbox.green,
|
||||
},
|
||||
warning: {
|
||||
main: gruvbox.yellow,
|
||||
},
|
||||
info: {
|
||||
main: gruvbox.blue,
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
|
||||
h1: { fontSize: '2rem', fontWeight: 600, color: gruvbox.fg },
|
||||
h2: { fontSize: '1.5rem', fontWeight: 500, color: gruvbox.fg },
|
||||
body1: { color: gruvbox.fg },
|
||||
},
|
||||
components: {
|
||||
MuiAppBar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: gruvbox.bg2,
|
||||
color: gruvbox.fg,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: gruvbox.bg1,
|
||||
backgroundImage: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default theme;
|
||||
Reference in New Issue
Block a user