i18n
This commit is contained in:
@@ -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 />
|
||||||
<AuthProvider>
|
<I18nProvider>
|
||||||
<AppContent />
|
<AuthProvider>
|
||||||
</AuthProvider>
|
<AppContent />
|
||||||
|
</AuthProvider>
|
||||||
|
</I18nProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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
82
src/client/I18nContext.js
Normal 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;
|
||||||
|
}
|
||||||
42
src/client/LanguageSwitcher.js
Normal file
42
src/client/LanguageSwitcher.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
94
src/client/i18n/de.json
Normal 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
94
src/client/i18n/en.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user