From 5febdf29c896797fcb25bb8913a765b2cd236135 Mon Sep 17 00:00:00 2001 From: sebseb7 Date: Sun, 21 Dec 2025 03:46:50 +0100 Subject: [PATCH] i18n --- src/client/App.js | 20 +++++--- src/client/ControllerCard.js | 6 ++- src/client/Dashboard.js | 10 ++-- src/client/I18nContext.js | 82 +++++++++++++++++++++++++++++ src/client/LanguageSwitcher.js | 42 +++++++++++++++ src/client/LoginDialog.js | 14 ++--- src/client/RuleCard.js | 37 ++++++++----- src/client/RuleEditor.js | 50 ++++++++---------- src/client/RuleManager.js | 14 ++--- src/client/i18n/de.json | 94 ++++++++++++++++++++++++++++++++++ src/client/i18n/en.json | 94 ++++++++++++++++++++++++++++++++++ 11 files changed, 398 insertions(+), 65 deletions(-) create mode 100644 src/client/I18nContext.js create mode 100644 src/client/LanguageSwitcher.js create mode 100644 src/client/i18n/de.json create mode 100644 src/client/i18n/en.json diff --git a/src/client/App.js b/src/client/App.js index 5e141ff..e3dab11 100644 --- a/src/client/App.js +++ b/src/client/App.js @@ -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() { - Tischlerei Dashboard + {t('app.title')} + {isAuthenticated ? ( <> {isAdmin && ( - Logout + {t('app.logout')} ) : ( @@ -94,7 +98,7 @@ function AppContent() { size="small" sx={{ borderColor: gruvboxDark.aqua }} > - 🔐 Admin Login + {t('app.adminLogin')} )} @@ -121,9 +125,11 @@ function App() { return ( - - - + + + + + ); } diff --git a/src/client/ControllerCard.js b/src/client/ControllerCard.js index 7b27dba..3c60dce 100644 --- a/src/client/ControllerCard.js +++ b/src/client/ControllerCard.js @@ -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 */} - Environment (Temp / Humidity) + {t('controller.environment')} @@ -75,7 +77,7 @@ export default function ControllerCard({ controllerName, ports, range }) { - {port.port_name || `Port ${port.port}`} + {port.port_name || `${t('controller.port')} ${port.port}`} diff --git a/src/client/Dashboard.js b/src/client/Dashboard.js index 3a52cea..bd3c708 100644 --- a/src/client/Dashboard.js +++ b/src/client/Dashboard.js @@ -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 Loading devices...; + if (loading) return {t('dashboard.loading')}; if (error) return {error}; return ( @@ -55,19 +57,19 @@ export default function Dashboard() { onClick={() => setRange('day')} color={range === 'day' ? 'primary' : 'inherit'} > - 24 Hours + {t('dashboard.hours24')} diff --git a/src/client/I18nContext.js b/src/client/I18nContext.js new file mode 100644 index 0000000..8bfb556 --- /dev/null +++ b/src/client/I18nContext.js @@ -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 ( + + {children} + + ); +} + +export function useI18n() { + const context = useContext(I18nContext); + if (!context) { + throw new Error('useI18n must be used within an I18nProvider'); + } + return context; +} diff --git a/src/client/LanguageSwitcher.js b/src/client/LanguageSwitcher.js new file mode 100644 index 0000000..bcb2b84 --- /dev/null +++ b/src/client/LanguageSwitcher.js @@ -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 ( + + + setLanguage('en')} + sx={{ + opacity: language === 'en' ? 1 : 0.5, + fontSize: '1.2rem', + '&:hover': { opacity: 1 } + }} + > + {FLAG_EN} + + + + setLanguage('de')} + sx={{ + opacity: language === 'de' ? 1 : 0.5, + fontSize: '1.2rem', + '&:hover': { opacity: 1 } + }} + > + {FLAG_DE} + + + + ); +} diff --git a/src/client/LoginDialog.js b/src/client/LoginDialog.js index e0dcaa6..fa9e3ff 100644 --- a/src/client/LoginDialog.js +++ b/src/client/LoginDialog.js @@ -14,6 +14,7 @@ import { Typography } from '@mui/material'; import { useAuth } from './AuthContext'; +import { useI18n } from './I18nContext'; // Simple eye icons using unicode const VisibilityIcon = () => 👁; @@ -21,6 +22,7 @@ const VisibilityOffIcon = () => 👁‍🗨 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 }) { > - 🔐 Dashboard Login + {t('login.title')} - Tischlerei Automation Control + {t('login.subtitle')} @@ -84,7 +86,7 @@ export default function LoginDialog({ open, onClose }) { - Signing in... + {t('login.signingIn')} ) : ( - 'Sign In' + t('login.signIn') )} diff --git a/src/client/RuleCard.js b/src/client/RuleCard.js index d686836..be7bc68 100644 --- a/src/client/RuleCard.js +++ b/src/client/RuleCard.js @@ -8,15 +8,25 @@ import { Chip, Tooltip } from '@mui/material'; +import { useI18n } from './I18nContext'; // Simple icons using unicode/emoji const EditIcon = () => ✏️; const DeleteIcon = () => 🗑️; -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( - At {time} ({dayText}) + {t('ruleCard.at')} {time} ({dayText}) ); @@ -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( - At {trigger.time} ({dayText}) + {t('ruleCard.at')} {trigger.time} ({dayText}) ); } @@ -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 }} /> - → {action.targetLabel || action.target} {hasLevel ? `Level ${action.level}` : (action.state ? 'ON' : 'OFF')} + → {action.targetLabel || action.target} {hasLevel ? `${t('ruleCard.level')} ${action.level}` : (action.state ? t('ruleCard.on') : t('ruleCard.off'))} ); @@ -135,7 +147,7 @@ function ActionSummary({ action }) { - → {action.targetLabel || action.target} {hasLevel ? `Level ${action.level}` : 'ON'} for {action.duration}m + → {action.targetLabel || action.target} {hasLevel ? `${t('ruleCard.level')} ${action.level}` : t('ruleCard.on')} {t('ruleCard.forMinutes', { duration: action.duration })} ); @@ -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 )} {!rule.enabled && ( - + )} @@ -223,13 +236,13 @@ export default function RuleCard({ rule, onEdit, onDelete, onToggle, onMoveUp, o - + - + - + diff --git a/src/client/RuleEditor.js b/src/client/RuleEditor.js index b96dda2..f55b752 100644 --- a/src/client/RuleEditor.js +++ b/src/client/RuleEditor.js @@ -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 = [], }} > - {rule ? '✏️ Edit Rule' : '➕ Create New Rule'} + {rule ? t('ruleEditor.editTitle') : t('ruleEditor.createTitle')} {/* Rule Name */} 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 */} - TRIGGERS (When to activate) + {t('ruleEditor.triggersSection')} @@ -393,13 +387,13 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [], disabled={saving} /> } - label={🕐 Scheduled Time (trigger at exact time)} + label={{t('ruleEditor.scheduledTime')}} /> {useScheduledTime && ( setScheduledTime(e.target.value)} @@ -410,7 +404,7 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [], /> - Days + {t('ruleEditor.days')} - {DAYS.map(day => ( + {DAYS_KEYS.map(key => ( - {day.label} + {t(`days.${key}`)} ))} @@ -477,7 +471,7 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [], - Days + {t('ruleEditor.days')} - {DAYS.map(day => ( + {DAYS_KEYS.map(key => ( - {day.label} + {t(`days.${key}`)} ))} @@ -709,7 +703,7 @@ export default function RuleEditor({ open, rule, onSave, onClose, sensors = [], diff --git a/src/client/RuleManager.js b/src/client/RuleManager.js index fe08a38..50d9f5a 100644 --- a/src/client/RuleManager.js +++ b/src/client/RuleManager.js @@ -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 ( - Loading rules... + {t('rules.loading')} ); } @@ -279,10 +281,10 @@ export default function RuleManager() { - ⚙️ Automation Rules + {t('rules.title')} - {isAdmin ? 'Configure triggers and actions for home automation' : 'View automation rules (read-only)'} + {isAdmin ? t('rules.adminDescription') : t('rules.guestDescription')} {isAdmin && ( @@ -297,7 +299,7 @@ export default function RuleManager() { } }} > - + Add Rule + {t('rules.addRule')} )} @@ -341,7 +343,7 @@ export default function RuleManager() { {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.' } diff --git a/src/client/i18n/de.json b/src/client/i18n/de.json new file mode 100644 index 0000000..187e390 --- /dev/null +++ b/src/client/i18n/de.json @@ -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" + } +} \ No newline at end of file diff --git a/src/client/i18n/en.json b/src/client/i18n/en.json new file mode 100644 index 0000000..e040fce --- /dev/null +++ b/src/client/i18n/en.json @@ -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" + } +} \ No newline at end of file