This commit is contained in:
sebseb7
2025-12-21 03:46:50 +01:00
parent eab4241e6e
commit 5febdf29c8
11 changed files with 398 additions and 65 deletions

View File

@@ -4,6 +4,8 @@ import Dashboard from './Dashboard';
import RuleManager from './RuleManager'; import RuleManager from './RuleManager';
import LoginDialog from './LoginDialog'; import LoginDialog from './LoginDialog';
import { AuthProvider, useAuth } from './AuthContext'; import { AuthProvider, useAuth } from './AuthContext';
import { I18nProvider, useI18n } from './I18nContext';
import LanguageSwitcher from './LanguageSwitcher';
// Gruvbox Dark color palette // Gruvbox Dark color palette
const gruvboxDark = { const gruvboxDark = {
@@ -44,6 +46,7 @@ const darkTheme = createTheme({
function AppContent() { function AppContent() {
const { user, loading, login, logout, isAuthenticated, isAdmin } = useAuth(); const { user, loading, login, logout, isAuthenticated, isAdmin } = useAuth();
const { t } = useI18n();
const [showLogin, setShowLogin] = useState(false); const [showLogin, setShowLogin] = useState(false);
return ( return (
@@ -51,9 +54,10 @@ function AppContent() {
<AppBar position="static"> <AppBar position="static">
<Toolbar> <Toolbar>
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}> <Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
Tischlerei Dashboard {t('app.title')}
</Typography> </Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<LanguageSwitcher />
{isAuthenticated ? ( {isAuthenticated ? (
<> <>
<Chip <Chip
@@ -69,7 +73,7 @@ function AppContent() {
/> />
{isAdmin && ( {isAdmin && (
<Chip <Chip
label="ADMIN" label={t('app.admin')}
size="small" size="small"
sx={{ sx={{
bgcolor: gruvboxDark.purple, bgcolor: gruvboxDark.purple,
@@ -83,7 +87,7 @@ function AppContent() {
onClick={logout} onClick={logout}
size="small" size="small"
> >
Logout {t('app.logout')}
</Button> </Button>
</> </>
) : ( ) : (
@@ -94,7 +98,7 @@ function AppContent() {
size="small" size="small"
sx={{ borderColor: gruvboxDark.aqua }} sx={{ borderColor: gruvboxDark.aqua }}
> >
🔐 Admin Login {t('app.adminLogin')}
</Button> </Button>
)} )}
</Box> </Box>
@@ -121,9 +125,11 @@ function App() {
return ( return (
<ThemeProvider theme={darkTheme}> <ThemeProvider theme={darkTheme}>
<CssBaseline /> <CssBaseline />
<I18nProvider>
<AuthProvider> <AuthProvider>
<AppContent /> <AppContent />
</AuthProvider> </AuthProvider>
</I18nProvider>
</ThemeProvider> </ThemeProvider>
); );
} }

View File

@@ -2,8 +2,10 @@ import React, { useState, useEffect } from 'react';
import { Card, CardHeader, CardContent, Divider, Grid, Box, Typography } from '@mui/material'; import { Card, CardHeader, CardContent, Divider, Grid, Box, Typography } from '@mui/material';
import EnvChart from './EnvChart'; import EnvChart from './EnvChart';
import LevelChart from './LevelChart'; import LevelChart from './LevelChart';
import { useI18n } from './I18nContext';
export default function ControllerCard({ controllerName, ports, range }) { export default function ControllerCard({ controllerName, ports, range }) {
const { t } = useI18n();
const [envData, setEnvData] = useState([]); const [envData, setEnvData] = useState([]);
const [portData, setPortData] = useState({}); const [portData, setPortData] = useState({});
@@ -56,7 +58,7 @@ export default function ControllerCard({ controllerName, ports, range }) {
{/* Environment Chart */} {/* Environment Chart */}
<Box sx={{ height: 350, mb: 6 }}> <Box sx={{ height: 350, mb: 6 }}>
<Typography variant="h6" color="text.secondary" gutterBottom> <Typography variant="h6" color="text.secondary" gutterBottom>
Environment (Temp / Humidity) {t('controller.environment')}
</Typography> </Typography>
<EnvChart data={envData} range={range} /> <EnvChart data={envData} range={range} />
</Box> </Box>
@@ -75,7 +77,7 @@ export default function ControllerCard({ controllerName, ports, range }) {
<Card variant="outlined" sx={{ bgcolor: 'background.paper' }}> <Card variant="outlined" sx={{ bgcolor: 'background.paper' }}>
<CardContent> <CardContent>
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
{port.port_name || `Port ${port.port}`} {port.port_name || `${t('controller.port')} ${port.port}`}
</Typography> </Typography>
<Box sx={{ height: 250 }}> <Box sx={{ height: 250 }}>
<LevelChart data={pData} isLight={isLight} isCO2={isCO2} range={range} /> <LevelChart data={pData} isLight={isLight} isCO2={isCO2} range={range} />

View File

@@ -1,8 +1,10 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { Grid, Typography, Button, ButtonGroup, Box, Alert } from '@mui/material'; import { Grid, Typography, Button, ButtonGroup, Box, Alert } from '@mui/material';
import ControllerCard from './ControllerCard'; import ControllerCard from './ControllerCard';
import { useI18n } from './I18nContext';
export default function Dashboard() { export default function Dashboard() {
const { t } = useI18n();
const [groupedDevices, setGroupedDevices] = useState({}); const [groupedDevices, setGroupedDevices] = useState({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
@@ -44,7 +46,7 @@ export default function Dashboard() {
// Auto-refresh logic (basic rerender trigger could be added here, // Auto-refresh logic (basic rerender trigger could be added here,
// but simpler to let ControllerCard handle data fetching internally based on props) // but simpler to let ControllerCard handle data fetching internally based on props)
if (loading) return <Typography>Loading devices...</Typography>; if (loading) return <Typography>{t('dashboard.loading')}</Typography>;
if (error) return <Alert severity="error">{error}</Alert>; if (error) return <Alert severity="error">{error}</Alert>;
return ( return (
@@ -55,19 +57,19 @@ export default function Dashboard() {
onClick={() => setRange('day')} onClick={() => setRange('day')}
color={range === 'day' ? 'primary' : 'inherit'} color={range === 'day' ? 'primary' : 'inherit'}
> >
24 Hours {t('dashboard.hours24')}
</Button> </Button>
<Button <Button
onClick={() => setRange('week')} onClick={() => setRange('week')}
color={range === 'week' ? 'primary' : 'inherit'} color={range === 'week' ? 'primary' : 'inherit'}
> >
7 Days {t('dashboard.days7')}
</Button> </Button>
<Button <Button
onClick={() => setRange('month')} onClick={() => setRange('month')}
color={range === 'month' ? 'primary' : 'inherit'} color={range === 'month' ? 'primary' : 'inherit'}
> >
30 Days {t('dashboard.days30')}
</Button> </Button>
</ButtonGroup> </ButtonGroup>
</Box> </Box>

82
src/client/I18nContext.js Normal file
View File

@@ -0,0 +1,82 @@
import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
import en from './i18n/en.json';
import de from './i18n/de.json';
const translations = { en, de };
// Cookie helpers
function getCookie(name) {
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
return match ? match[2] : null;
}
function setCookie(name, value, days = 365) {
const expires = new Date(Date.now() + days * 864e5).toUTCString();
document.cookie = `${name}=${value}; expires=${expires}; path=/; SameSite=Lax`;
}
// Get initial language from cookie or browser preference
function getInitialLanguage() {
const cookieLang = getCookie('lang');
if (cookieLang && translations[cookieLang]) {
return cookieLang;
}
// Check browser preference
const browserLang = navigator.language?.slice(0, 2);
if (browserLang === 'de') return 'de';
return 'en';
}
const I18nContext = createContext(null);
export function I18nProvider({ children }) {
const [language, setLanguageState] = useState(getInitialLanguage);
const setLanguage = useCallback((lang) => {
if (translations[lang]) {
setLanguageState(lang);
setCookie('lang', lang);
}
}, []);
// Translation function with nested key support (e.g., 'app.title')
const t = useCallback((key, params = {}) => {
const keys = key.split('.');
let value = translations[language];
for (const k of keys) {
if (value && typeof value === 'object') {
value = value[k];
} else {
return key; // fallback to key if not found
}
}
if (typeof value !== 'string') return key;
// Replace {param} placeholders
return value.replace(/\{(\w+)\}/g, (_, param) =>
params[param] !== undefined ? params[param] : `{${param}}`
);
}, [language]);
const value = useMemo(() => ({
language,
setLanguage,
t
}), [language, setLanguage, t]);
return (
<I18nContext.Provider value={value}>
{children}
</I18nContext.Provider>
);
}
export function useI18n() {
const context = useContext(I18nContext);
if (!context) {
throw new Error('useI18n must be used within an I18nProvider');
}
return context;
}

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { Box, IconButton, Tooltip } from '@mui/material';
import { useI18n } from './I18nContext';
// Flag emojis for language switching
const FLAG_DE = '🇩🇪';
const FLAG_EN = '🇬🇧';
export default function LanguageSwitcher() {
const { language, setLanguage } = useI18n();
return (
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Tooltip title="English">
<IconButton
size="small"
onClick={() => setLanguage('en')}
sx={{
opacity: language === 'en' ? 1 : 0.5,
fontSize: '1.2rem',
'&:hover': { opacity: 1 }
}}
>
{FLAG_EN}
</IconButton>
</Tooltip>
<Tooltip title="Deutsch">
<IconButton
size="small"
onClick={() => setLanguage('de')}
sx={{
opacity: language === 'de' ? 1 : 0.5,
fontSize: '1.2rem',
'&:hover': { opacity: 1 }
}}
>
{FLAG_DE}
</IconButton>
</Tooltip>
</Box>
);
}

View File

@@ -14,6 +14,7 @@ import {
Typography Typography
} from '@mui/material'; } from '@mui/material';
import { useAuth } from './AuthContext'; import { useAuth } from './AuthContext';
import { useI18n } from './I18nContext';
// Simple eye icons using unicode // Simple eye icons using unicode
const VisibilityIcon = () => <span style={{ fontSize: '1.2rem' }}>👁</span>; const VisibilityIcon = () => <span style={{ fontSize: '1.2rem' }}>👁</span>;
@@ -21,6 +22,7 @@ const VisibilityOffIcon = () => <span style={{ fontSize: '1.2rem' }}>👁‍🗨
export default function LoginDialog({ open, onClose }) { export default function LoginDialog({ open, onClose }) {
const { login } = useAuth(); const { login } = useAuth();
const { t } = useI18n();
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
@@ -66,10 +68,10 @@ export default function LoginDialog({ open, onClose }) {
> >
<DialogTitle sx={{ textAlign: 'center', pb: 1 }}> <DialogTitle sx={{ textAlign: 'center', pb: 1 }}>
<Typography variant="h5" component="div" sx={{ fontWeight: 600 }}> <Typography variant="h5" component="div" sx={{ fontWeight: 600 }}>
🔐 Dashboard Login {t('login.title')}
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}> <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
Tischlerei Automation Control {t('login.subtitle')}
</Typography> </Typography>
</DialogTitle> </DialogTitle>
@@ -84,7 +86,7 @@ export default function LoginDialog({ open, onClose }) {
<TextField <TextField
autoFocus autoFocus
margin="dense" margin="dense"
label="Username" label={t('login.username')}
type="text" type="text"
fullWidth fullWidth
variant="outlined" variant="outlined"
@@ -96,7 +98,7 @@ export default function LoginDialog({ open, onClose }) {
<TextField <TextField
margin="dense" margin="dense"
label="Password" label={t('login.password')}
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
fullWidth fullWidth
variant="outlined" variant="outlined"
@@ -137,10 +139,10 @@ export default function LoginDialog({ open, onClose }) {
{loading ? ( {loading ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={20} color="inherit" /> <CircularProgress size={20} color="inherit" />
<span>Signing in...</span> <span>{t('login.signingIn')}</span>
</Box> </Box>
) : ( ) : (
'Sign In' t('login.signIn')
)} )}
</Button> </Button>
</DialogActions> </DialogActions>

View File

@@ -8,15 +8,25 @@ import {
Chip, Chip,
Tooltip Tooltip
} from '@mui/material'; } from '@mui/material';
import { useI18n } from './I18nContext';
// Simple icons using unicode/emoji // Simple icons using unicode/emoji
const EditIcon = () => <span style={{ fontSize: '1rem' }}></span>; const EditIcon = () => <span style={{ fontSize: '1rem' }}></span>;
const DeleteIcon = () => <span style={{ fontSize: '1rem' }}>🗑</span>; const DeleteIcon = () => <span style={{ fontSize: '1rem' }}>🗑</span>;
const dayLabels = { mon: 'M', tue: 'T', wed: 'W', thu: 'T', fri: 'F', sat: 'S', sun: 'S' };
const dayOrder = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']; const dayOrder = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
function TriggerSummary({ trigger }) { function TriggerSummary({ trigger }) {
const { t } = useI18n();
const dayLabels = {
mon: t('days.mon').charAt(0),
tue: t('days.tue').charAt(0),
wed: t('days.wed').charAt(0),
thu: t('days.thu').charAt(0),
fri: t('days.fri').charAt(0),
sat: t('days.sat').charAt(0),
sun: t('days.sun').charAt(0)
};
const parts = []; const parts = [];
// Scheduled time (trigger at exact time) // Scheduled time (trigger at exact time)
@@ -24,14 +34,14 @@ function TriggerSummary({ trigger }) {
const { time, days } = trigger.scheduledTime; const { time, days } = trigger.scheduledTime;
const isEveryDay = days?.length === 7; const isEveryDay = days?.length === 7;
const isWeekdays = days?.length === 5 && ['mon', 'tue', 'wed', 'thu', 'fri'].every(d => days.includes(d)); const isWeekdays = days?.length === 5 && ['mon', 'tue', 'wed', 'thu', 'fri'].every(d => days.includes(d));
let dayText = isEveryDay ? 'daily' : isWeekdays ? 'weekdays' : let dayText = isEveryDay ? t('ruleCard.daily') : isWeekdays ? t('ruleCard.weekdays') :
dayOrder.filter(d => days?.includes(d)).map(d => dayLabels[d]).join(''); dayOrder.filter(d => days?.includes(d)).map(d => dayLabels[d]).join('');
parts.push( parts.push(
<Box key="scheduled" sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}> <Box key="scheduled" sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
<Chip label="🕐" size="small" sx={{ bgcolor: '#d3869b', color: '#282828', fontWeight: 600, minWidth: 32 }} /> <Chip label="🕐" size="small" sx={{ bgcolor: '#d3869b', color: '#282828', fontWeight: 600, minWidth: 32 }} />
<Typography variant="body2"> <Typography variant="body2">
At <strong>{time}</strong> ({dayText}) {t('ruleCard.at')} <strong>{time}</strong> ({dayText})
</Typography> </Typography>
</Box> </Box>
); );
@@ -42,7 +52,7 @@ function TriggerSummary({ trigger }) {
const { start, end, days } = trigger.timeRange; const { start, end, days } = trigger.timeRange;
const isEveryDay = days?.length === 7; const isEveryDay = days?.length === 7;
const isWeekdays = days?.length === 5 && ['mon', 'tue', 'wed', 'thu', 'fri'].every(d => days.includes(d)); const isWeekdays = days?.length === 5 && ['mon', 'tue', 'wed', 'thu', 'fri'].every(d => days.includes(d));
let dayText = isEveryDay ? 'daily' : isWeekdays ? 'weekdays' : let dayText = isEveryDay ? t('ruleCard.daily') : isWeekdays ? t('ruleCard.weekdays') :
dayOrder.filter(d => days?.includes(d)).map(d => dayLabels[d]).join(''); dayOrder.filter(d => days?.includes(d)).map(d => dayLabels[d]).join('');
parts.push( parts.push(
@@ -80,13 +90,13 @@ function TriggerSummary({ trigger }) {
const days = trigger.days || []; const days = trigger.days || [];
const isEveryDay = days.length === 7; const isEveryDay = days.length === 7;
const isWeekdays = days.length === 5 && ['mon', 'tue', 'wed', 'thu', 'fri'].every(d => days.includes(d)); const isWeekdays = days.length === 5 && ['mon', 'tue', 'wed', 'thu', 'fri'].every(d => days.includes(d));
let dayText = isEveryDay ? 'daily' : isWeekdays ? 'weekdays' : let dayText = isEveryDay ? t('ruleCard.daily') : isWeekdays ? t('ruleCard.weekdays') :
dayOrder.filter(d => days.includes(d)).map(d => dayLabels[d]).join(' '); dayOrder.filter(d => days.includes(d)).map(d => dayLabels[d]).join(' ');
parts.push( parts.push(
<Box key="legacy-time" sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}> <Box key="legacy-time" sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Chip label="⏰" size="small" sx={{ bgcolor: '#83a598', color: '#282828', fontWeight: 600 }} /> <Chip label="⏰" size="small" sx={{ bgcolor: '#83a598', color: '#282828', fontWeight: 600 }} />
<Typography variant="body2">At {trigger.time} ({dayText})</Typography> <Typography variant="body2">{t('ruleCard.at')} {trigger.time} ({dayText})</Typography>
</Box> </Box>
); );
} }
@@ -112,6 +122,8 @@ function TriggerSummary({ trigger }) {
} }
function ActionSummary({ action }) { function ActionSummary({ action }) {
const { t } = useI18n();
if (action.type === 'toggle') { if (action.type === 'toggle') {
// Check if it's a level or binary action // Check if it's a level or binary action
const hasLevel = action.level !== undefined; const hasLevel = action.level !== undefined;
@@ -123,7 +135,7 @@ function ActionSummary({ action }) {
sx={{ bgcolor: hasLevel ? '#83a598' : (action.state ? '#b8bb26' : '#fb4934'), color: '#282828', fontWeight: 600, minWidth: 32 }} sx={{ bgcolor: hasLevel ? '#83a598' : (action.state ? '#b8bb26' : '#fb4934'), color: '#282828', fontWeight: 600, minWidth: 32 }}
/> />
<Typography variant="body2"> <Typography variant="body2">
<strong>{action.targetLabel || action.target}</strong> {hasLevel ? `Level ${action.level}` : (action.state ? 'ON' : 'OFF')} <strong>{action.targetLabel || action.target}</strong> {hasLevel ? `${t('ruleCard.level')} ${action.level}` : (action.state ? t('ruleCard.on') : t('ruleCard.off'))}
</Typography> </Typography>
</Box> </Box>
); );
@@ -135,7 +147,7 @@ function ActionSummary({ action }) {
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Chip label="⏱️" size="small" sx={{ bgcolor: '#d3869b', color: '#282828', fontWeight: 600, minWidth: 32 }} /> <Chip label="⏱️" size="small" sx={{ bgcolor: '#d3869b', color: '#282828', fontWeight: 600, minWidth: 32 }} />
<Typography variant="body2"> <Typography variant="body2">
<strong>{action.targetLabel || action.target}</strong> {hasLevel ? `Level ${action.level}` : 'ON'} for {action.duration}m <strong>{action.targetLabel || action.target}</strong> {hasLevel ? `${t('ruleCard.level')} ${action.level}` : t('ruleCard.on')} {t('ruleCard.forMinutes', { duration: action.duration })}
</Typography> </Typography>
</Box> </Box>
); );
@@ -145,6 +157,7 @@ function ActionSummary({ action }) {
} }
export default function RuleCard({ rule, onEdit, onDelete, onToggle, onMoveUp, onMoveDown, colorTags = [], readOnly }) { export default function RuleCard({ rule, onEdit, onDelete, onToggle, onMoveUp, onMoveDown, colorTags = [], readOnly }) {
const { t } = useI18n();
// Get list of tag colors for this rule (handle array or backwards-compat single value) // Get list of tag colors for this rule (handle array or backwards-compat single value)
const ruleTagIds = rule.colorTags || (rule.colorTag ? [rule.colorTag] : []); const ruleTagIds = rule.colorTags || (rule.colorTag ? [rule.colorTag] : []);
const ruleTags = ruleTagIds.map(tagId => colorTags.find(t => t.id === tagId)).filter(Boolean); const ruleTags = ruleTagIds.map(tagId => colorTags.find(t => t.id === tagId)).filter(Boolean);
@@ -184,7 +197,7 @@ export default function RuleCard({ rule, onEdit, onDelete, onToggle, onMoveUp, o
</Box> </Box>
)} )}
{!rule.enabled && ( {!rule.enabled && (
<Chip label="Disabled" size="small" sx={{ bgcolor: '#504945', fontSize: '0.7rem' }} /> <Chip label={t('ruleCard.disabled')} size="small" sx={{ bgcolor: '#504945', fontSize: '0.7rem' }} />
)} )}
</Box> </Box>
@@ -223,13 +236,13 @@ export default function RuleCard({ rule, onEdit, onDelete, onToggle, onMoveUp, o
</span> </span>
</Tooltip> </Tooltip>
</Box> </Box>
<Tooltip title={rule.enabled ? 'Disable' : 'Enable'}> <Tooltip title={rule.enabled ? t('ruleCard.disable') : t('ruleCard.enable')}>
<Switch checked={rule.enabled} onChange={onToggle} color="primary" size="small" /> <Switch checked={rule.enabled} onChange={onToggle} color="primary" size="small" />
</Tooltip> </Tooltip>
<Tooltip title="Edit"> <Tooltip title={t('ruleCard.edit')}>
<IconButton onClick={onEdit} size="small"><EditIcon /></IconButton> <IconButton onClick={onEdit} size="small"><EditIcon /></IconButton>
</Tooltip> </Tooltip>
<Tooltip title="Delete"> <Tooltip title={t('ruleCard.delete')}>
<IconButton onClick={onDelete} size="small" sx={{ '&:hover': { color: '#fb4934' } }}> <IconButton onClick={onDelete} size="small" sx={{ '&:hover': { color: '#fb4934' } }}>
<DeleteIcon /> <DeleteIcon />
</IconButton> </IconButton>

View File

@@ -23,16 +23,9 @@ import {
Paper, Paper,
Chip Chip
} from '@mui/material'; } from '@mui/material';
import { useI18n } from './I18nContext';
const DAYS = [ const DAYS_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
{ key: 'mon', label: 'Mon' },
{ key: 'tue', label: 'Tue' },
{ key: 'wed', label: 'Wed' },
{ key: 'thu', label: 'Thu' },
{ key: 'fri', label: 'Fri' },
{ key: 'sat', label: 'Sat' },
{ key: 'sun', label: 'Sun' }
];
const OPERATORS = [ const OPERATORS = [
{ value: '>', label: '>' }, { value: '>', label: '>' },
@@ -122,6 +115,7 @@ function SensorCondition({ condition, sensors, onChange, onRemove, disabled }) {
} }
export default function RuleEditor({ open, rule, onSave, onClose, sensors = [], outputs = [], colorTags: availableColorTags = [], saving }) { export default function RuleEditor({ open, rule, onSave, onClose, sensors = [], outputs = [], colorTags: availableColorTags = [], saving }) {
const { t } = useI18n();
const [name, setName] = useState(''); const [name, setName] = useState('');
const [selectedTags, setSelectedTags] = useState([]); // array of tag ids const [selectedTags, setSelectedTags] = useState([]); // array of tag ids
@@ -312,18 +306,18 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
}} }}
> >
<DialogTitle> <DialogTitle>
{rule ? '✏️ Edit Rule' : ' Create New Rule'} {rule ? t('ruleEditor.editTitle') : t('ruleEditor.createTitle')}
</DialogTitle> </DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, pt: 1 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, pt: 1 }}>
{/* Rule Name */} {/* Rule Name */}
<TextField <TextField
label="Rule Name" label={t('ruleEditor.ruleName')}
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
fullWidth fullWidth
placeholder="e.g., Daytime High Humidity Fan" placeholder={t('ruleEditor.ruleNamePlaceholder')}
disabled={saving} disabled={saving}
/> />
@@ -379,7 +373,7 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
{/* TRIGGERS SECTION */} {/* TRIGGERS SECTION */}
<Box> <Box>
<Typography variant="subtitle2" color="text.secondary" gutterBottom> <Typography variant="subtitle2" color="text.secondary" gutterBottom>
TRIGGERS (When to activate) {t('ruleEditor.triggersSection')}
</Typography> </Typography>
<Divider sx={{ mb: 2 }} /> <Divider sx={{ mb: 2 }} />
@@ -393,13 +387,13 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
disabled={saving} disabled={saving}
/> />
} }
label={<Typography fontWeight={600}>🕐 Scheduled Time (trigger at exact time)</Typography>} label={<Typography fontWeight={600}>{t('ruleEditor.scheduledTime')}</Typography>}
/> />
{useScheduledTime && ( {useScheduledTime && (
<Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField <TextField
label="Trigger At" label={t('ruleEditor.triggerAt')}
type="time" type="time"
value={scheduledTime} value={scheduledTime}
onChange={(e) => setScheduledTime(e.target.value)} onChange={(e) => setScheduledTime(e.target.value)}
@@ -410,7 +404,7 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
/> />
<Box> <Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Days {t('ruleEditor.days')}
</Typography> </Typography>
<ToggleButtonGroup <ToggleButtonGroup
value={scheduledDays} value={scheduledDays}
@@ -418,10 +412,10 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
size="small" size="small"
disabled={saving} disabled={saving}
> >
{DAYS.map(day => ( {DAYS_KEYS.map(key => (
<ToggleButton <ToggleButton
key={day.key} key={key}
value={day.key} value={key}
sx={{ sx={{
'&.Mui-selected': { '&.Mui-selected': {
bgcolor: '#d3869b', bgcolor: '#d3869b',
@@ -430,7 +424,7 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
} }
}} }}
> >
{day.label} {t(`days.${key}`)}
</ToggleButton> </ToggleButton>
))} ))}
</ToggleButtonGroup> </ToggleButtonGroup>
@@ -477,7 +471,7 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
</Box> </Box>
<Box> <Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Days {t('ruleEditor.days')}
</Typography> </Typography>
<ToggleButtonGroup <ToggleButtonGroup
value={timeRangeDays} value={timeRangeDays}
@@ -485,10 +479,10 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
size="small" size="small"
disabled={saving} disabled={saving}
> >
{DAYS.map(day => ( {DAYS_KEYS.map(key => (
<ToggleButton <ToggleButton
key={day.key} key={key}
value={day.key} value={key}
sx={{ sx={{
'&.Mui-selected': { '&.Mui-selected': {
bgcolor: '#8ec07c', bgcolor: '#8ec07c',
@@ -497,7 +491,7 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
} }
}} }}
> >
{day.label} {t(`days.${key}`)}
</ToggleButton> </ToggleButton>
))} ))}
</ToggleButtonGroup> </ToggleButtonGroup>
@@ -709,7 +703,7 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
<DialogActions sx={{ px: 3, py: 2 }}> <DialogActions sx={{ px: 3, py: 2 }}>
<Button onClick={onClose} color="inherit" disabled={saving}> <Button onClick={onClose} color="inherit" disabled={saving}>
Cancel {t('ruleEditor.cancel')}
</Button> </Button>
<Button <Button
onClick={handleSave} onClick={handleSave}
@@ -725,10 +719,10 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
{saving ? ( {saving ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={16} color="inherit" /> <CircularProgress size={16} color="inherit" />
Saving... {t('ruleEditor.saving')}
</Box> </Box>
) : ( ) : (
rule ? 'Save Changes' : 'Create Rule' rule ? t('ruleEditor.saveChanges') : t('ruleEditor.createRule')
)} )}
</Button> </Button>
</DialogActions> </DialogActions>

View File

@@ -13,6 +13,7 @@ import {
import RuleCard from './RuleCard'; import RuleCard from './RuleCard';
import RuleEditor from './RuleEditor'; import RuleEditor from './RuleEditor';
import { useAuth } from './AuthContext'; import { useAuth } from './AuthContext';
import { useI18n } from './I18nContext';
// 8 color tags // 8 color tags
const COLOR_TAGS = [ const COLOR_TAGS = [
@@ -28,6 +29,7 @@ const COLOR_TAGS = [
export default function RuleManager() { export default function RuleManager() {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const { t } = useI18n();
const [rules, setRules] = useState([]); const [rules, setRules] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
@@ -152,7 +154,7 @@ export default function RuleManager() {
}; };
const handleDeleteRule = async (ruleId) => { const handleDeleteRule = async (ruleId) => {
if (!confirm('Delete this rule?')) return; if (!confirm(t('rules.deleteConfirm'))) return;
setSaving(true); setSaving(true);
try { try {
@@ -262,7 +264,7 @@ export default function RuleManager() {
return ( return (
<Paper sx={{ mt: 4, p: 3, textAlign: 'center' }}> <Paper sx={{ mt: 4, p: 3, textAlign: 'center' }}>
<CircularProgress size={24} /> <CircularProgress size={24} />
<Typography sx={{ mt: 2 }}>Loading rules...</Typography> <Typography sx={{ mt: 2 }}>{t('rules.loading')}</Typography>
</Paper> </Paper>
); );
} }
@@ -279,10 +281,10 @@ export default function RuleManager() {
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Box> <Box>
<Typography variant="h5" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}> <Typography variant="h5" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
Automation Rules {t('rules.title')}
</Typography> </Typography>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{isAdmin ? 'Configure triggers and actions for home automation' : 'View automation rules (read-only)'} {isAdmin ? t('rules.adminDescription') : t('rules.guestDescription')}
</Typography> </Typography>
</Box> </Box>
{isAdmin && ( {isAdmin && (
@@ -297,7 +299,7 @@ export default function RuleManager() {
} }
}} }}
> >
+ Add Rule {t('rules.addRule')}
</Button> </Button>
)} )}
</Box> </Box>
@@ -341,7 +343,7 @@ export default function RuleManager() {
<Box sx={{ textAlign: 'center', py: 4 }}> <Box sx={{ textAlign: 'center', py: 4 }}>
<Typography color="text.secondary"> <Typography color="text.secondary">
{rules.length === 0 {rules.length === 0
? (isAdmin ? 'No rules configured. Click "Add Rule" to create one.' : 'No rules configured.') ? (isAdmin ? t('rules.noRules') + ' ' + t('rules.noRulesAdmin') : t('rules.noRules'))
: 'No rules match the selected filter.' : 'No rules match the selected filter.'
} }
</Typography> </Typography>

94
src/client/i18n/de.json Normal file
View File

@@ -0,0 +1,94 @@
{
"app": {
"title": "Tischlerei Dashboard",
"adminLogin": "🔐 Admin Anmeldung",
"logout": "Abmelden",
"admin": "ADMIN"
},
"dashboard": {
"loading": "Geräte werden geladen...",
"hours24": "24 Stunden",
"days7": "7 Tage",
"days30": "30 Tage"
},
"login": {
"title": "🔐 Dashboard Anmeldung",
"subtitle": "Tischlerei Automatisierungssteuerung",
"username": "Benutzername",
"password": "Passwort",
"signIn": "Anmelden",
"signingIn": "Anmeldung läuft..."
},
"controller": {
"environment": "Umgebung (Temp. / Luftfeuchtigkeit)",
"port": "Anschluss"
},
"rules": {
"title": "⚙️ Automatisierungsregeln",
"adminDescription": "Trigger und Aktionen für die Hausautomatisierung konfigurieren",
"guestDescription": "Automatisierungsregeln ansehen (nur Lesen)",
"addRule": "+ Regel hinzufügen",
"loading": "Regeln werden geladen...",
"noRules": "Keine Regeln konfiguriert.",
"noRulesAdmin": "Klicken Sie auf \"Regel hinzufügen\" um eine zu erstellen.",
"deleteConfirm": "Diese Regel löschen?"
},
"ruleCard": {
"disabled": "Deaktiviert",
"enable": "Aktivieren",
"disable": "Deaktivieren",
"edit": "Bearbeiten",
"delete": "Löschen",
"daily": "täglich",
"weekdays": "werktags",
"at": "Um",
"level": "Stufe",
"on": "AN",
"off": "AUS",
"forMinutes": "für {duration}m",
"isOn": "ist AN",
"isOff": "ist AUS"
},
"ruleEditor": {
"editTitle": "✏️ Regel bearbeiten",
"createTitle": " Neue Regel erstellen",
"ruleName": "Regelname",
"ruleNamePlaceholder": "z.B. Tagsüber Hohe Luftfeuchtigkeit Lüfter",
"triggersSection": "AUSLÖSER (Wann aktivieren)",
"actionSection": "AKTION (Was tun)",
"scheduledTime": "🕐 Geplante Zeit (zu exakter Zeit auslösen)",
"triggerAt": "Auslösen um",
"timeRange": "⏰ Zeitbereich (aktiv während Fenster)",
"from": "Von",
"to": "bis",
"until": "Bis",
"days": "Tage",
"sensorConditions": "📊 Sensorbedingungen",
"noSensors": "(keine Sensoren verfügbar)",
"combineWith": "Bedingungen verknüpfen mit:",
"addCondition": "+ Bedingung hinzufügen",
"sensor": "Sensor",
"actionType": "Aktionstyp",
"toggleOnOff": "🔛 Ein/Aus schalten",
"keepOnMinutes": "⏱️ Für X Minuten eingeschaltet lassen",
"targetOutput": "Zielausgang",
"turnOn": "Einschalten",
"turnOff": "Ausschalten",
"setLevel": "Stufe setzen:",
"duration": "Dauer:",
"minutes": "Minuten",
"cancel": "Abbrechen",
"saveChanges": "Änderungen speichern",
"createRule": "Regel erstellen",
"saving": "Speichern..."
},
"days": {
"mon": "Mo",
"tue": "Di",
"wed": "Mi",
"thu": "Do",
"fri": "Fr",
"sat": "Sa",
"sun": "So"
}
}

94
src/client/i18n/en.json Normal file
View File

@@ -0,0 +1,94 @@
{
"app": {
"title": "Tischlerei Dashboard",
"adminLogin": "🔐 Admin Login",
"logout": "Logout",
"admin": "ADMIN"
},
"dashboard": {
"loading": "Loading devices...",
"hours24": "24 Hours",
"days7": "7 Days",
"days30": "30 Days"
},
"login": {
"title": "🔐 Dashboard Login",
"subtitle": "Tischlerei Automation Control",
"username": "Username",
"password": "Password",
"signIn": "Sign In",
"signingIn": "Signing in..."
},
"controller": {
"environment": "Environment (Temp / Humidity)",
"port": "Port"
},
"rules": {
"title": "⚙️ Automation Rules",
"adminDescription": "Configure triggers and actions for home automation",
"guestDescription": "View automation rules (read-only)",
"addRule": "+ Add Rule",
"loading": "Loading rules...",
"noRules": "No rules configured.",
"noRulesAdmin": "Click \"Add Rule\" to create one.",
"deleteConfirm": "Delete this rule?"
},
"ruleCard": {
"disabled": "Disabled",
"enable": "Enable",
"disable": "Disable",
"edit": "Edit",
"delete": "Delete",
"daily": "daily",
"weekdays": "weekdays",
"at": "At",
"level": "Level",
"on": "ON",
"off": "OFF",
"forMinutes": "for {duration}m",
"isOn": "is ON",
"isOff": "is OFF"
},
"ruleEditor": {
"editTitle": "✏️ Edit Rule",
"createTitle": " Create New Rule",
"ruleName": "Rule Name",
"ruleNamePlaceholder": "e.g., Daytime High Humidity Fan",
"triggersSection": "TRIGGERS (When to activate)",
"actionSection": "ACTION (What to do)",
"scheduledTime": "🕐 Scheduled Time (trigger at exact time)",
"triggerAt": "Trigger At",
"timeRange": "⏰ Time Range (active during window)",
"from": "From",
"to": "to",
"until": "Until",
"days": "Days",
"sensorConditions": "📊 Sensor Conditions",
"noSensors": "(no sensors available)",
"combineWith": "Combine conditions with:",
"addCondition": "+ Add Condition",
"sensor": "Sensor",
"actionType": "Action Type",
"toggleOnOff": "🔛 Toggle On/Off",
"keepOnMinutes": "⏱️ Keep On for X Minutes",
"targetOutput": "Target Output",
"turnOn": "Turn ON",
"turnOff": "Turn OFF",
"setLevel": "Set Level:",
"duration": "Duration:",
"minutes": "minutes",
"cancel": "Cancel",
"saveChanges": "Save Changes",
"createRule": "Create Rule",
"saving": "Saving..."
},
"days": {
"mon": "Mon",
"tue": "Tue",
"wed": "Wed",
"thu": "Thu",
"fri": "Fri",
"sat": "Sat",
"sun": "Sun"
}
}