i18n
This commit is contained in:
@@ -4,6 +4,8 @@ import Dashboard from './Dashboard';
|
||||
import RuleManager from './RuleManager';
|
||||
import LoginDialog from './LoginDialog';
|
||||
import { AuthProvider, useAuth } from './AuthContext';
|
||||
import { I18nProvider, useI18n } from './I18nContext';
|
||||
import LanguageSwitcher from './LanguageSwitcher';
|
||||
|
||||
// Gruvbox Dark color palette
|
||||
const gruvboxDark = {
|
||||
@@ -44,6 +46,7 @@ const darkTheme = createTheme({
|
||||
|
||||
function AppContent() {
|
||||
const { user, loading, login, logout, isAuthenticated, isAdmin } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -51,9 +54,10 @@ function AppContent() {
|
||||
<AppBar position="static">
|
||||
<Toolbar>
|
||||
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
||||
Tischlerei Dashboard
|
||||
{t('app.title')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<LanguageSwitcher />
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<Chip
|
||||
@@ -69,7 +73,7 @@ function AppContent() {
|
||||
/>
|
||||
{isAdmin && (
|
||||
<Chip
|
||||
label="ADMIN"
|
||||
label={t('app.admin')}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: gruvboxDark.purple,
|
||||
@@ -83,7 +87,7 @@ function AppContent() {
|
||||
onClick={logout}
|
||||
size="small"
|
||||
>
|
||||
Logout
|
||||
{t('app.logout')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
@@ -94,7 +98,7 @@ function AppContent() {
|
||||
size="small"
|
||||
sx={{ borderColor: gruvboxDark.aqua }}
|
||||
>
|
||||
🔐 Admin Login
|
||||
{t('app.adminLogin')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
@@ -121,9 +125,11 @@ function App() {
|
||||
return (
|
||||
<ThemeProvider theme={darkTheme}>
|
||||
<CssBaseline />
|
||||
<I18nProvider>
|
||||
<AuthProvider>
|
||||
<AppContent />
|
||||
</AuthProvider>
|
||||
</I18nProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardHeader, CardContent, Divider, Grid, Box, Typography } from '@mui/material';
|
||||
import EnvChart from './EnvChart';
|
||||
import LevelChart from './LevelChart';
|
||||
import { useI18n } from './I18nContext';
|
||||
|
||||
export default function ControllerCard({ controllerName, ports, range }) {
|
||||
const { t } = useI18n();
|
||||
const [envData, setEnvData] = useState([]);
|
||||
const [portData, setPortData] = useState({});
|
||||
|
||||
@@ -56,7 +58,7 @@ export default function ControllerCard({ controllerName, ports, range }) {
|
||||
{/* Environment Chart */}
|
||||
<Box sx={{ height: 350, mb: 6 }}>
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
Environment (Temp / Humidity)
|
||||
{t('controller.environment')}
|
||||
</Typography>
|
||||
<EnvChart data={envData} range={range} />
|
||||
</Box>
|
||||
@@ -75,7 +77,7 @@ export default function ControllerCard({ controllerName, ports, range }) {
|
||||
<Card variant="outlined" sx={{ bgcolor: 'background.paper' }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{port.port_name || `Port ${port.port}`}
|
||||
{port.port_name || `${t('controller.port')} ${port.port}`}
|
||||
</Typography>
|
||||
<Box sx={{ height: 250 }}>
|
||||
<LevelChart data={pData} isLight={isLight} isCO2={isCO2} range={range} />
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Grid, Typography, Button, ButtonGroup, Box, Alert } from '@mui/material';
|
||||
import ControllerCard from './ControllerCard';
|
||||
import { useI18n } from './I18nContext';
|
||||
|
||||
export default function Dashboard() {
|
||||
const { t } = useI18n();
|
||||
const [groupedDevices, setGroupedDevices] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
@@ -44,7 +46,7 @@ export default function Dashboard() {
|
||||
// Auto-refresh logic (basic rerender trigger could be added here,
|
||||
// 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>;
|
||||
|
||||
return (
|
||||
@@ -55,19 +57,19 @@ export default function Dashboard() {
|
||||
onClick={() => setRange('day')}
|
||||
color={range === 'day' ? 'primary' : 'inherit'}
|
||||
>
|
||||
24 Hours
|
||||
{t('dashboard.hours24')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setRange('week')}
|
||||
color={range === 'week' ? 'primary' : 'inherit'}
|
||||
>
|
||||
7 Days
|
||||
{t('dashboard.days7')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setRange('month')}
|
||||
color={range === 'month' ? 'primary' : 'inherit'}
|
||||
>
|
||||
30 Days
|
||||
{t('dashboard.days30')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</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
|
||||
} from '@mui/material';
|
||||
import { useAuth } from './AuthContext';
|
||||
import { useI18n } from './I18nContext';
|
||||
|
||||
// Simple eye icons using unicode
|
||||
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 }) {
|
||||
const { login } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
@@ -66,10 +68,10 @@ export default function LoginDialog({ open, onClose }) {
|
||||
>
|
||||
<DialogTitle sx={{ textAlign: 'center', pb: 1 }}>
|
||||
<Typography variant="h5" component="div" sx={{ fontWeight: 600 }}>
|
||||
🔐 Dashboard Login
|
||||
{t('login.title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
Tischlerei Automation Control
|
||||
{t('login.subtitle')}
|
||||
</Typography>
|
||||
</DialogTitle>
|
||||
|
||||
@@ -84,7 +86,7 @@ export default function LoginDialog({ open, onClose }) {
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label="Username"
|
||||
label={t('login.username')}
|
||||
type="text"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
@@ -96,7 +98,7 @@ export default function LoginDialog({ open, onClose }) {
|
||||
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="Password"
|
||||
label={t('login.password')}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
@@ -137,10 +139,10 @@ export default function LoginDialog({ open, onClose }) {
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CircularProgress size={20} color="inherit" />
|
||||
<span>Signing in...</span>
|
||||
<span>{t('login.signingIn')}</span>
|
||||
</Box>
|
||||
) : (
|
||||
'Sign In'
|
||||
t('login.signIn')
|
||||
)}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
|
||||
@@ -8,15 +8,25 @@ import {
|
||||
Chip,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import { useI18n } from './I18nContext';
|
||||
|
||||
// Simple icons using unicode/emoji
|
||||
const EditIcon = () => <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'];
|
||||
|
||||
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 = [];
|
||||
|
||||
// Scheduled time (trigger at exact time)
|
||||
@@ -24,14 +34,14 @@ function TriggerSummary({ trigger }) {
|
||||
const { time, days } = trigger.scheduledTime;
|
||||
const isEveryDay = days?.length === 7;
|
||||
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('');
|
||||
|
||||
parts.push(
|
||||
<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 }} />
|
||||
<Typography variant="body2">
|
||||
At <strong>{time}</strong> ({dayText})
|
||||
{t('ruleCard.at')} <strong>{time}</strong> ({dayText})
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
@@ -42,7 +52,7 @@ function TriggerSummary({ trigger }) {
|
||||
const { start, end, days } = trigger.timeRange;
|
||||
const isEveryDay = days?.length === 7;
|
||||
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('');
|
||||
|
||||
parts.push(
|
||||
@@ -80,13 +90,13 @@ function TriggerSummary({ trigger }) {
|
||||
const days = trigger.days || [];
|
||||
const isEveryDay = days.length === 7;
|
||||
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(' ');
|
||||
|
||||
parts.push(
|
||||
<Box key="legacy-time" sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -112,6 +122,8 @@ function TriggerSummary({ trigger }) {
|
||||
}
|
||||
|
||||
function ActionSummary({ action }) {
|
||||
const { t } = useI18n();
|
||||
|
||||
if (action.type === 'toggle') {
|
||||
// Check if it's a level or binary action
|
||||
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 }}
|
||||
/>
|
||||
<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>
|
||||
</Box>
|
||||
);
|
||||
@@ -135,7 +147,7 @@ function ActionSummary({ action }) {
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Chip label="⏱️" size="small" sx={{ bgcolor: '#d3869b', color: '#282828', fontWeight: 600, minWidth: 32 }} />
|
||||
<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>
|
||||
</Box>
|
||||
);
|
||||
@@ -145,6 +157,7 @@ function ActionSummary({ action }) {
|
||||
}
|
||||
|
||||
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)
|
||||
const ruleTagIds = rule.colorTags || (rule.colorTag ? [rule.colorTag] : []);
|
||||
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>
|
||||
)}
|
||||
{!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>
|
||||
|
||||
@@ -223,13 +236,13 @@ export default function RuleCard({ rule, onEdit, onDelete, onToggle, onMoveUp, o
|
||||
</span>
|
||||
</Tooltip>
|
||||
</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" />
|
||||
</Tooltip>
|
||||
<Tooltip title="Edit">
|
||||
<Tooltip title={t('ruleCard.edit')}>
|
||||
<IconButton onClick={onEdit} size="small"><EditIcon /></IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete">
|
||||
<Tooltip title={t('ruleCard.delete')}>
|
||||
<IconButton onClick={onDelete} size="small" sx={{ '&:hover': { color: '#fb4934' } }}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
|
||||
@@ -23,16 +23,9 @@ import {
|
||||
Paper,
|
||||
Chip
|
||||
} from '@mui/material';
|
||||
import { useI18n } from './I18nContext';
|
||||
|
||||
const DAYS = [
|
||||
{ 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 DAYS_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
|
||||
|
||||
const OPERATORS = [
|
||||
{ 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 }) {
|
||||
const { t } = useI18n();
|
||||
const [name, setName] = useState('');
|
||||
const [selectedTags, setSelectedTags] = useState([]); // array of tag ids
|
||||
|
||||
@@ -312,18 +306,18 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
||||
}}
|
||||
>
|
||||
<DialogTitle>
|
||||
{rule ? '✏️ Edit Rule' : '➕ Create New Rule'}
|
||||
{rule ? t('ruleEditor.editTitle') : t('ruleEditor.createTitle')}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent dividers>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, pt: 1 }}>
|
||||
{/* Rule Name */}
|
||||
<TextField
|
||||
label="Rule Name"
|
||||
label={t('ruleEditor.ruleName')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
fullWidth
|
||||
placeholder="e.g., Daytime High Humidity Fan"
|
||||
placeholder={t('ruleEditor.ruleNamePlaceholder')}
|
||||
disabled={saving}
|
||||
/>
|
||||
|
||||
@@ -379,7 +373,7 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
||||
{/* TRIGGERS SECTION */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||
TRIGGERS (When to activate)
|
||||
{t('ruleEditor.triggersSection')}
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
@@ -393,13 +387,13 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
||||
disabled={saving}
|
||||
/>
|
||||
}
|
||||
label={<Typography fontWeight={600}>🕐 Scheduled Time (trigger at exact time)</Typography>}
|
||||
label={<Typography fontWeight={600}>{t('ruleEditor.scheduledTime')}</Typography>}
|
||||
/>
|
||||
|
||||
{useScheduledTime && (
|
||||
<Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="Trigger At"
|
||||
label={t('ruleEditor.triggerAt')}
|
||||
type="time"
|
||||
value={scheduledTime}
|
||||
onChange={(e) => setScheduledTime(e.target.value)}
|
||||
@@ -410,7 +404,7 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
||||
/>
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Days
|
||||
{t('ruleEditor.days')}
|
||||
</Typography>
|
||||
<ToggleButtonGroup
|
||||
value={scheduledDays}
|
||||
@@ -418,10 +412,10 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
||||
size="small"
|
||||
disabled={saving}
|
||||
>
|
||||
{DAYS.map(day => (
|
||||
{DAYS_KEYS.map(key => (
|
||||
<ToggleButton
|
||||
key={day.key}
|
||||
value={day.key}
|
||||
key={key}
|
||||
value={key}
|
||||
sx={{
|
||||
'&.Mui-selected': {
|
||||
bgcolor: '#d3869b',
|
||||
@@ -430,7 +424,7 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
||||
}
|
||||
}}
|
||||
>
|
||||
{day.label}
|
||||
{t(`days.${key}`)}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
@@ -477,7 +471,7 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Days
|
||||
{t('ruleEditor.days')}
|
||||
</Typography>
|
||||
<ToggleButtonGroup
|
||||
value={timeRangeDays}
|
||||
@@ -485,10 +479,10 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
||||
size="small"
|
||||
disabled={saving}
|
||||
>
|
||||
{DAYS.map(day => (
|
||||
{DAYS_KEYS.map(key => (
|
||||
<ToggleButton
|
||||
key={day.key}
|
||||
value={day.key}
|
||||
key={key}
|
||||
value={key}
|
||||
sx={{
|
||||
'&.Mui-selected': {
|
||||
bgcolor: '#8ec07c',
|
||||
@@ -497,7 +491,7 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
||||
}
|
||||
}}
|
||||
>
|
||||
{day.label}
|
||||
{t(`days.${key}`)}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
@@ -709,7 +703,7 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
||||
|
||||
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||
<Button onClick={onClose} color="inherit" disabled={saving}>
|
||||
Cancel
|
||||
{t('ruleEditor.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
@@ -725,10 +719,10 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [],
|
||||
{saving ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CircularProgress size={16} color="inherit" />
|
||||
Saving...
|
||||
{t('ruleEditor.saving')}
|
||||
</Box>
|
||||
) : (
|
||||
rule ? 'Save Changes' : 'Create Rule'
|
||||
rule ? t('ruleEditor.saveChanges') : t('ruleEditor.createRule')
|
||||
)}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import RuleCard from './RuleCard';
|
||||
import RuleEditor from './RuleEditor';
|
||||
import { useAuth } from './AuthContext';
|
||||
import { useI18n } from './I18nContext';
|
||||
|
||||
// 8 color tags
|
||||
const COLOR_TAGS = [
|
||||
@@ -28,6 +29,7 @@ const COLOR_TAGS = [
|
||||
|
||||
export default function RuleManager() {
|
||||
const { isAdmin } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const [rules, setRules] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
@@ -152,7 +154,7 @@ export default function RuleManager() {
|
||||
};
|
||||
|
||||
const handleDeleteRule = async (ruleId) => {
|
||||
if (!confirm('Delete this rule?')) return;
|
||||
if (!confirm(t('rules.deleteConfirm'))) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -262,7 +264,7 @@ export default function RuleManager() {
|
||||
return (
|
||||
<Paper sx={{ mt: 4, p: 3, textAlign: 'center' }}>
|
||||
<CircularProgress size={24} />
|
||||
<Typography sx={{ mt: 2 }}>Loading rules...</Typography>
|
||||
<Typography sx={{ mt: 2 }}>{t('rules.loading')}</Typography>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -279,10 +281,10 @@ export default function RuleManager() {
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
⚙️ Automation Rules
|
||||
{t('rules.title')}
|
||||
</Typography>
|
||||
<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>
|
||||
</Box>
|
||||
{isAdmin && (
|
||||
@@ -297,7 +299,7 @@ export default function RuleManager() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
+ Add Rule
|
||||
{t('rules.addRule')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
@@ -341,7 +343,7 @@ export default function RuleManager() {
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Typography color="text.secondary">
|
||||
{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.'
|
||||
}
|
||||
</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