mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 21:47:39 +02:00
Added multi language support
This commit is contained in:
@@ -154,6 +154,7 @@ export interface AppSettings {
|
||||
energy_cost_per_kwh: number;
|
||||
energy_tracking_mode: 'print' | 'total';
|
||||
check_updates: boolean;
|
||||
notification_language: string;
|
||||
}
|
||||
|
||||
export type AppSettingsUpdate = Partial<AppSettings>;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useEffect } from 'react';
|
||||
import { X, Keyboard } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent } from './Card';
|
||||
|
||||
interface NavItem {
|
||||
id: string;
|
||||
to: string;
|
||||
label: string;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
interface KeyboardShortcutsModalProps {
|
||||
@@ -13,11 +14,11 @@ interface KeyboardShortcutsModalProps {
|
||||
navItems?: NavItem[];
|
||||
}
|
||||
|
||||
function getShortcuts(navItems?: NavItem[]) {
|
||||
function getShortcuts(navItems: NavItem[] | undefined, t: (key: string) => string) {
|
||||
const navShortcuts = navItems
|
||||
? navItems.map((item, index) => ({
|
||||
keys: [String(index + 1)],
|
||||
description: `Go to ${item.label}`,
|
||||
description: `Go to ${t(item.labelKey)}`,
|
||||
}))
|
||||
: [
|
||||
{ keys: ['1'], description: 'Go to Printers' },
|
||||
@@ -51,7 +52,8 @@ function KeyBadge({ children }: { children: string }) {
|
||||
}
|
||||
|
||||
export function KeyboardShortcutsModal({ onClose, navItems }: KeyboardShortcutsModalProps) {
|
||||
const shortcuts = getShortcuts(navItems);
|
||||
const { t } = useTranslation();
|
||||
const shortcuts = getShortcuts(navItems, t);
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, type LucideIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
@@ -10,17 +11,17 @@ interface NavItem {
|
||||
id: string;
|
||||
to: string;
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
labelKey: string; // Translation key
|
||||
}
|
||||
|
||||
export const defaultNavItems: NavItem[] = [
|
||||
{ id: 'printers', to: '/', icon: Printer, label: 'Printers' },
|
||||
{ id: 'archives', to: '/archives', icon: Archive, label: 'Archives' },
|
||||
{ id: 'queue', to: '/queue', icon: Calendar, label: 'Queue' },
|
||||
{ id: 'stats', to: '/stats', icon: BarChart3, label: 'Statistics' },
|
||||
{ id: 'profiles', to: '/profiles', icon: Cloud, label: 'Profiles' },
|
||||
{ id: 'maintenance', to: '/maintenance', icon: Wrench, label: 'Maintenance' },
|
||||
{ id: 'settings', to: '/settings', icon: Settings, label: 'Settings' },
|
||||
{ id: 'printers', to: '/', icon: Printer, labelKey: 'nav.printers' },
|
||||
{ id: 'archives', to: '/archives', icon: Archive, labelKey: 'nav.archives' },
|
||||
{ id: 'queue', to: '/queue', icon: Calendar, labelKey: 'nav.queue' },
|
||||
{ id: 'stats', to: '/stats', icon: BarChart3, labelKey: 'nav.stats' },
|
||||
{ id: 'profiles', to: '/profiles', icon: Cloud, labelKey: 'nav.profiles' },
|
||||
{ id: 'maintenance', to: '/maintenance', icon: Wrench, labelKey: 'nav.maintenance' },
|
||||
{ id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' },
|
||||
];
|
||||
|
||||
// Get ordered nav items from localStorage
|
||||
@@ -69,6 +70,7 @@ export function Layout() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const [sidebarExpanded, setSidebarExpanded] = useState(() => {
|
||||
const stored = localStorage.getItem('sidebarExpanded');
|
||||
return stored !== 'false';
|
||||
@@ -207,7 +209,7 @@ export function Layout() {
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 p-2">
|
||||
<ul className="space-y-2">
|
||||
{navItems.map(({ id, to, icon: Icon, label }, index) => (
|
||||
{navItems.map(({ id, to, icon: Icon, labelKey }, index) => (
|
||||
<li
|
||||
key={id}
|
||||
draggable
|
||||
@@ -233,13 +235,13 @@ export function Layout() {
|
||||
: 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
|
||||
}`
|
||||
}
|
||||
title={!sidebarExpanded ? label : undefined}
|
||||
title={!sidebarExpanded ? t(labelKey) : undefined}
|
||||
>
|
||||
{sidebarExpanded && (
|
||||
<GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
|
||||
)}
|
||||
<Icon className="w-5 h-5 flex-shrink-0" />
|
||||
{sidebarExpanded && <span>{label}</span>}
|
||||
{sidebarExpanded && <span>{t(labelKey)}</span>}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
@@ -250,7 +252,7 @@ export function Layout() {
|
||||
<button
|
||||
onClick={() => setSidebarExpanded(!sidebarExpanded)}
|
||||
className="p-2 mx-2 mb-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white flex items-center justify-center"
|
||||
title={sidebarExpanded ? 'Collapse sidebar' : 'Expand sidebar'}
|
||||
title={sidebarExpanded ? t('nav.collapseSidebar') : t('nav.expandSidebar')}
|
||||
>
|
||||
{sidebarExpanded ? (
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
@@ -269,10 +271,10 @@ export function Layout() {
|
||||
<button
|
||||
onClick={() => navigate('/settings')}
|
||||
className="flex items-center gap-1 text-xs text-bambu-green hover:text-bambu-green/80 transition-colors"
|
||||
title={`Update available: v${updateCheck.latest_version}`}
|
||||
title={t('nav.updateAvailable', { version: updateCheck.latest_version })}
|
||||
>
|
||||
<ArrowUpCircle className="w-4 h-4" />
|
||||
<span>Update</span>
|
||||
<span>{t('nav.update')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -282,21 +284,21 @@ export function Layout() {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
|
||||
title="View on GitHub"
|
||||
title={t('nav.viewOnGithub')}
|
||||
>
|
||||
<Github className="w-5 h-5" />
|
||||
</a>
|
||||
<button
|
||||
onClick={() => setShowShortcuts(true)}
|
||||
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
|
||||
title="Keyboard shortcuts (?)"
|
||||
title={t('nav.keyboardShortcuts')}
|
||||
>
|
||||
<Keyboard className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
|
||||
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
title={theme === 'dark' ? t('nav.switchToLight') : t('nav.switchToDark')}
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
||||
</button>
|
||||
@@ -308,7 +310,7 @@ export function Layout() {
|
||||
<button
|
||||
onClick={() => navigate('/settings')}
|
||||
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-green hover:text-bambu-green/80"
|
||||
title={`Update available: v${updateCheck.latest_version}`}
|
||||
title={t('nav.updateAvailable', { version: updateCheck.latest_version })}
|
||||
>
|
||||
<ArrowUpCircle className="w-5 h-5" />
|
||||
</button>
|
||||
@@ -318,21 +320,21 @@ export function Layout() {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
|
||||
title="View on GitHub"
|
||||
title={t('nav.viewOnGithub')}
|
||||
>
|
||||
<Github className="w-5 h-5" />
|
||||
</a>
|
||||
<button
|
||||
onClick={() => setShowShortcuts(true)}
|
||||
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
|
||||
title="Keyboard shortcuts (?)"
|
||||
title={t('nav.keyboardShortcuts')}
|
||||
>
|
||||
<Keyboard className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
|
||||
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
title={theme === 'dark' ? t('nav.switchToLight') : t('nav.switchToDark')}
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
|
||||
// Import translations directly for bundling
|
||||
import en from './locales/en';
|
||||
import de from './locales/de';
|
||||
|
||||
const resources = {
|
||||
en: { translation: en },
|
||||
de: { translation: de },
|
||||
};
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources,
|
||||
fallbackLng: 'en',
|
||||
supportedLngs: ['en', 'de'],
|
||||
|
||||
detection: {
|
||||
// Order of detection methods
|
||||
order: ['localStorage', 'navigator', 'htmlTag'],
|
||||
// Key to use in localStorage
|
||||
lookupLocalStorage: 'bambutrack_language',
|
||||
// Cache user language
|
||||
caches: ['localStorage'],
|
||||
},
|
||||
|
||||
interpolation: {
|
||||
escapeValue: false, // React already escapes
|
||||
},
|
||||
|
||||
react: {
|
||||
useSuspense: false,
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
|
||||
// Helper to get available languages
|
||||
export const availableLanguages = [
|
||||
{ code: 'en', name: 'English', nativeName: 'English' },
|
||||
{ code: 'de', name: 'German', nativeName: 'Deutsch' },
|
||||
];
|
||||
@@ -0,0 +1,358 @@
|
||||
export default {
|
||||
// Navigation
|
||||
nav: {
|
||||
printers: 'Drucker',
|
||||
archives: 'Archiv',
|
||||
queue: 'Warteschlange',
|
||||
stats: 'Statistiken',
|
||||
profiles: 'Profile',
|
||||
maintenance: 'Wartung',
|
||||
settings: 'Einstellungen',
|
||||
collapseSidebar: 'Seitenleiste einklappen',
|
||||
expandSidebar: 'Seitenleiste ausklappen',
|
||||
update: 'Update',
|
||||
updateAvailable: 'Update verfügbar: v{{version}}',
|
||||
viewOnGithub: 'Auf GitHub ansehen',
|
||||
keyboardShortcuts: 'Tastaturkürzel (?)',
|
||||
switchToLight: 'Zum hellen Modus wechseln',
|
||||
switchToDark: 'Zum dunklen Modus wechseln',
|
||||
},
|
||||
|
||||
// Common
|
||||
common: {
|
||||
save: 'Speichern',
|
||||
cancel: 'Abbrechen',
|
||||
delete: 'Löschen',
|
||||
edit: 'Bearbeiten',
|
||||
add: 'Hinzufügen',
|
||||
close: 'Schließen',
|
||||
confirm: 'Bestätigen',
|
||||
loading: 'Lädt...',
|
||||
error: 'Fehler',
|
||||
success: 'Erfolg',
|
||||
warning: 'Warnung',
|
||||
enabled: 'Aktiviert',
|
||||
disabled: 'Deaktiviert',
|
||||
yes: 'Ja',
|
||||
no: 'Nein',
|
||||
on: 'An',
|
||||
off: 'Aus',
|
||||
all: 'Alle',
|
||||
none: 'Keine',
|
||||
search: 'Suchen',
|
||||
filter: 'Filtern',
|
||||
sort: 'Sortieren',
|
||||
refresh: 'Aktualisieren',
|
||||
download: 'Herunterladen',
|
||||
upload: 'Hochladen',
|
||||
actions: 'Aktionen',
|
||||
status: 'Status',
|
||||
name: 'Name',
|
||||
description: 'Beschreibung',
|
||||
date: 'Datum',
|
||||
time: 'Zeit',
|
||||
hours: 'Stunden',
|
||||
minutes: 'Minuten',
|
||||
seconds: 'Sekunden',
|
||||
noPrinters: 'Keine Drucker konfiguriert',
|
||||
noData: 'Keine Daten verfügbar',
|
||||
required: 'Erforderlich',
|
||||
optional: 'Optional',
|
||||
},
|
||||
|
||||
// Printers page
|
||||
printers: {
|
||||
title: 'Drucker',
|
||||
addPrinter: 'Drucker hinzufügen',
|
||||
editPrinter: 'Drucker bearbeiten',
|
||||
deletePrinter: 'Drucker löschen',
|
||||
printerName: 'Druckername',
|
||||
serialNumber: 'Seriennummer',
|
||||
ipAddress: 'IP-Adresse',
|
||||
accessCode: 'Zugangscode',
|
||||
model: 'Modell',
|
||||
nozzleCount: 'Düsenanzahl',
|
||||
autoArchive: 'Automatische Archivierung',
|
||||
status: {
|
||||
idle: 'Bereit',
|
||||
printing: 'Druckt',
|
||||
paused: 'Pausiert',
|
||||
offline: 'Offline',
|
||||
error: 'Fehler',
|
||||
finished: 'Fertig',
|
||||
unknown: 'Unbekannt',
|
||||
},
|
||||
temperatures: {
|
||||
nozzle: 'Düse',
|
||||
bed: 'Druckbett',
|
||||
chamber: 'Kammer',
|
||||
},
|
||||
progress: '{{percent}}% abgeschlossen',
|
||||
timeRemaining: 'Noch {{time}}',
|
||||
deleteConfirm: 'Möchten Sie "{{name}}" wirklich löschen?',
|
||||
maintenanceOk: 'Wartung OK',
|
||||
maintenanceWarning: '{{count}} Warnung',
|
||||
maintenanceWarning_plural: '{{count}} Warnungen',
|
||||
maintenanceDue: '{{count}} fällig',
|
||||
maintenanceDue_plural: '{{count}} fällig',
|
||||
},
|
||||
|
||||
// Archives page
|
||||
archives: {
|
||||
title: 'Druckarchiv',
|
||||
searchPlaceholder: 'Archiv durchsuchen...',
|
||||
filterByPrinter: 'Nach Drucker filtern',
|
||||
filterByStatus: 'Nach Status filtern',
|
||||
sortBy: 'Sortieren nach',
|
||||
sortNewest: 'Neueste zuerst',
|
||||
sortOldest: 'Älteste zuerst',
|
||||
sortName: 'Name',
|
||||
sortDuration: 'Dauer',
|
||||
noArchives: 'Keine Archive gefunden',
|
||||
printTime: 'Druckzeit',
|
||||
filamentUsed: 'Verbrauchtes Filament',
|
||||
cost: 'Kosten',
|
||||
reprint: 'Erneut drucken',
|
||||
preview: 'Vorschau',
|
||||
deleteArchive: 'Archiv löschen',
|
||||
deleteConfirm: 'Möchten Sie dieses Archiv wirklich löschen?',
|
||||
favorite: 'Favorit',
|
||||
unfavorite: 'Aus Favoriten entfernen',
|
||||
viewDetails: 'Details anzeigen',
|
||||
status: {
|
||||
completed: 'Abgeschlossen',
|
||||
failed: 'Fehlgeschlagen',
|
||||
stopped: 'Gestoppt',
|
||||
},
|
||||
},
|
||||
|
||||
// Queue page
|
||||
queue: {
|
||||
title: 'Druckwarteschlange',
|
||||
addToQueue: 'Zur Warteschlange hinzufügen',
|
||||
clearQueue: 'Warteschlange leeren',
|
||||
emptyQueue: 'Warteschlange ist leer',
|
||||
position: 'Position',
|
||||
scheduledTime: 'Geplante Zeit',
|
||||
moveUp: 'Nach oben',
|
||||
moveDown: 'Nach unten',
|
||||
remove: 'Entfernen',
|
||||
startNow: 'Jetzt starten',
|
||||
status: {
|
||||
pending: 'Ausstehend',
|
||||
printing: 'Druckt',
|
||||
completed: 'Abgeschlossen',
|
||||
failed: 'Fehlgeschlagen',
|
||||
cancelled: 'Abgebrochen',
|
||||
},
|
||||
},
|
||||
|
||||
// Statistics page
|
||||
stats: {
|
||||
title: 'Statistiken',
|
||||
overview: 'Übersicht',
|
||||
totalPrints: 'Gesamtdrucke',
|
||||
successRate: 'Erfolgsrate',
|
||||
totalPrintTime: 'Gesamtdruckzeit',
|
||||
totalFilament: 'Gesamtverbrauch Filament',
|
||||
totalCost: 'Gesamtkosten',
|
||||
averagePrintTime: 'Durchschnittliche Druckzeit',
|
||||
printsPerDay: 'Drucke pro Tag',
|
||||
byPrinter: 'Nach Drucker',
|
||||
byMaterial: 'Nach Material',
|
||||
byMonth: 'Nach Monat',
|
||||
last7Days: 'Letzte 7 Tage',
|
||||
last30Days: 'Letzte 30 Tage',
|
||||
last90Days: 'Letzte 90 Tage',
|
||||
allTime: 'Gesamt',
|
||||
},
|
||||
|
||||
// Profiles page
|
||||
profiles: {
|
||||
title: 'Filament-Profile',
|
||||
addProfile: 'Profil hinzufügen',
|
||||
editProfile: 'Profil bearbeiten',
|
||||
deleteProfile: 'Profil löschen',
|
||||
material: 'Material',
|
||||
brand: 'Marke',
|
||||
color: 'Farbe',
|
||||
diameter: 'Durchmesser',
|
||||
density: 'Dichte',
|
||||
costPerKg: 'Kosten pro kg',
|
||||
spoolWeight: 'Spulengewicht',
|
||||
noProfiles: 'Keine Profile konfiguriert',
|
||||
deleteConfirm: 'Möchten Sie dieses Profil wirklich löschen?',
|
||||
},
|
||||
|
||||
// Maintenance page
|
||||
maintenance: {
|
||||
title: 'Wartung',
|
||||
overview: 'Übersicht',
|
||||
allOk: 'Alle Wartungen aktuell',
|
||||
dueCount: '{{count}} Aufgabe fällig',
|
||||
dueCount_plural: '{{count}} Aufgaben fällig',
|
||||
warningCount: '{{count}} Warnung',
|
||||
warningCount_plural: '{{count}} Warnungen',
|
||||
totalPrintTime: 'Gesamtdruckzeit',
|
||||
nextMaintenance: 'Nächste Wartung',
|
||||
nothingDue: 'Nichts fällig',
|
||||
tasks: 'Aufgaben',
|
||||
lastPerformed: 'Zuletzt durchgeführt',
|
||||
interval: 'Intervall',
|
||||
hoursRemaining: '{{hours}}h verbleibend',
|
||||
hoursOverdue: '{{hours}}h überfällig',
|
||||
markDone: 'Als erledigt markieren',
|
||||
performMaintenance: 'Wartung durchführen',
|
||||
history: 'Verlauf',
|
||||
noHistory: 'Kein Wartungsverlauf',
|
||||
editPrintHours: 'Druckstunden bearbeiten',
|
||||
currentHours: 'Aktuelle Stunden',
|
||||
types: {
|
||||
lubricateRails: 'Linearschienen schmieren',
|
||||
cleanNozzle: 'Düse/Hotend reinigen',
|
||||
checkBelts: 'Riemenspannung prüfen',
|
||||
cleanBuildPlate: 'Druckbett reinigen',
|
||||
checkExtruder: 'Extruderzahnräder prüfen',
|
||||
checkCooling: 'Kühlungslüfter prüfen',
|
||||
generalInspection: 'Allgemeine Inspektion',
|
||||
},
|
||||
},
|
||||
|
||||
// Settings page
|
||||
settings: {
|
||||
title: 'Einstellungen',
|
||||
general: 'Allgemein',
|
||||
appearance: 'Erscheinungsbild',
|
||||
notifications: 'Benachrichtigungen',
|
||||
smartPlugs: 'Smart Plugs',
|
||||
spoolman: 'Spoolman',
|
||||
updates: 'Updates',
|
||||
language: 'Sprache',
|
||||
languageDescription: 'Wählen Sie Ihre bevorzugte Sprache',
|
||||
theme: 'Design',
|
||||
themeLight: 'Hell',
|
||||
themeDark: 'Dunkel',
|
||||
themeSystem: 'System',
|
||||
defaultView: 'Standardansicht',
|
||||
defaultViewDescription: 'Seite, die beim Öffnen der App angezeigt wird',
|
||||
checkForUpdates: 'Nach Updates suchen',
|
||||
autoUpdate: 'Automatische Updates',
|
||||
currentVersion: 'Aktuelle Version',
|
||||
latestVersion: 'Neueste Version',
|
||||
upToDate: 'Sie sind auf dem neuesten Stand',
|
||||
updateAvailable: 'Update verfügbar',
|
||||
// Notifications
|
||||
notificationLanguage: 'Benachrichtigungssprache',
|
||||
notificationLanguageDescription: 'Sprache für Push-Benachrichtigungen',
|
||||
notificationProviders: 'Benachrichtigungsanbieter',
|
||||
addProvider: 'Anbieter hinzufügen',
|
||||
editProvider: 'Anbieter bearbeiten',
|
||||
providerType: 'Anbietertyp',
|
||||
testNotification: 'Testbenachrichtigung',
|
||||
testSuccess: 'Testbenachrichtigung erfolgreich gesendet',
|
||||
testFailed: 'Testbenachrichtigung konnte nicht gesendet werden',
|
||||
quietHours: 'Ruhezeiten',
|
||||
quietHoursDescription: 'Keine Störungen während dieser Zeiten',
|
||||
quietHoursStart: 'Beginn',
|
||||
quietHoursEnd: 'Ende',
|
||||
events: {
|
||||
title: 'Benachrichtigungsereignisse',
|
||||
printStart: 'Druck gestartet',
|
||||
printComplete: 'Druck abgeschlossen',
|
||||
printFailed: 'Druck fehlgeschlagen',
|
||||
printStopped: 'Druck gestoppt',
|
||||
printProgress: 'Fortschrittsmeldungen',
|
||||
printProgressDescription: 'Bei 25%, 50%, 75% benachrichtigen',
|
||||
printerOffline: 'Drucker offline',
|
||||
printerError: 'Druckerfehler',
|
||||
filamentLow: 'Filament niedrig',
|
||||
maintenanceDue: 'Wartung fällig',
|
||||
maintenanceDueDescription: 'Benachrichtigen, wenn Wartung erforderlich',
|
||||
},
|
||||
// Smart Plugs
|
||||
smartPlug: {
|
||||
title: 'Smart Plugs',
|
||||
add: 'Smart Plug hinzufügen',
|
||||
edit: 'Smart Plug bearbeiten',
|
||||
name: 'Name',
|
||||
ipAddress: 'IP-Adresse',
|
||||
linkedPrinter: 'Verknüpfter Drucker',
|
||||
autoOn: 'Automatisch einschalten',
|
||||
autoOnDescription: 'Einschalten beim Druckstart',
|
||||
autoOff: 'Automatisch ausschalten',
|
||||
autoOffDescription: 'Ausschalten nach Druckende',
|
||||
offDelay: 'Ausschaltverzögerung',
|
||||
offDelayMinutes: 'Minuten nach Druck',
|
||||
offDelayTemp: 'Wenn Düse unter Temperatur',
|
||||
currentState: 'Aktueller Status',
|
||||
turnOn: 'Einschalten',
|
||||
turnOff: 'Ausschalten',
|
||||
},
|
||||
// Spoolman
|
||||
spoolmanEnabled: 'Spoolman-Integration aktivieren',
|
||||
spoolmanUrl: 'Spoolman URL',
|
||||
spoolmanConnected: 'Verbunden',
|
||||
spoolmanDisconnected: 'Nicht verbunden',
|
||||
},
|
||||
|
||||
// Notifications (for push notifications)
|
||||
notification: {
|
||||
printStarted: {
|
||||
title: 'Druck gestartet',
|
||||
body: '{{printer}}: {{filename}} wird gedruckt',
|
||||
},
|
||||
printCompleted: {
|
||||
title: 'Druck abgeschlossen',
|
||||
body: '{{printer}}: {{filename}} erfolgreich abgeschlossen',
|
||||
},
|
||||
printFailed: {
|
||||
title: 'Druck fehlgeschlagen',
|
||||
body: '{{printer}}: {{filename}} ist fehlgeschlagen',
|
||||
},
|
||||
printStopped: {
|
||||
title: 'Druck gestoppt',
|
||||
body: '{{printer}}: {{filename}} wurde gestoppt',
|
||||
},
|
||||
printProgress: {
|
||||
title: 'Druckfortschritt',
|
||||
body: '{{printer}}: {{filename}} ist zu {{percent}}% abgeschlossen',
|
||||
},
|
||||
printerOffline: {
|
||||
title: 'Drucker offline',
|
||||
body: '{{printer}} ist offline',
|
||||
},
|
||||
printerError: {
|
||||
title: 'Druckerfehler',
|
||||
body: '{{printer}}: {{error}}',
|
||||
},
|
||||
filamentLow: {
|
||||
title: 'Filament niedrig',
|
||||
body: '{{printer}}: Filament geht zur Neige',
|
||||
},
|
||||
maintenanceDue: {
|
||||
title: 'Wartung fällig',
|
||||
body: '{{printer}}: {{items}} benötigen Aufmerksamkeit',
|
||||
},
|
||||
},
|
||||
|
||||
// Errors
|
||||
errors: {
|
||||
generic: 'Etwas ist schiefgelaufen',
|
||||
networkError: 'Netzwerkfehler. Bitte überprüfen Sie Ihre Verbindung.',
|
||||
notFound: 'Nicht gefunden',
|
||||
unauthorized: 'Nicht autorisiert',
|
||||
serverError: 'Serverfehler',
|
||||
validationError: 'Bitte überprüfen Sie Ihre Eingabe',
|
||||
printerConnectionFailed: 'Verbindung zum Drucker fehlgeschlagen',
|
||||
saveFailed: 'Speichern fehlgeschlagen',
|
||||
deleteFailed: 'Löschen fehlgeschlagen',
|
||||
loadFailed: 'Laden der Daten fehlgeschlagen',
|
||||
},
|
||||
|
||||
// Confirmations
|
||||
confirm: {
|
||||
delete: 'Möchten Sie dies wirklich löschen?',
|
||||
unsavedChanges: 'Sie haben ungespeicherte Änderungen. Möchten Sie wirklich verlassen?',
|
||||
clearQueue: 'Möchten Sie die Warteschlange wirklich leeren?',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,358 @@
|
||||
export default {
|
||||
// Navigation
|
||||
nav: {
|
||||
printers: 'Printers',
|
||||
archives: 'Archives',
|
||||
queue: 'Queue',
|
||||
stats: 'Statistics',
|
||||
profiles: 'Profiles',
|
||||
maintenance: 'Maintenance',
|
||||
settings: 'Settings',
|
||||
collapseSidebar: 'Collapse sidebar',
|
||||
expandSidebar: 'Expand sidebar',
|
||||
update: 'Update',
|
||||
updateAvailable: 'Update available: v{{version}}',
|
||||
viewOnGithub: 'View on GitHub',
|
||||
keyboardShortcuts: 'Keyboard shortcuts (?)',
|
||||
switchToLight: 'Switch to light mode',
|
||||
switchToDark: 'Switch to dark mode',
|
||||
},
|
||||
|
||||
// Common
|
||||
common: {
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
delete: 'Delete',
|
||||
edit: 'Edit',
|
||||
add: 'Add',
|
||||
close: 'Close',
|
||||
confirm: 'Confirm',
|
||||
loading: 'Loading...',
|
||||
error: 'Error',
|
||||
success: 'Success',
|
||||
warning: 'Warning',
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled',
|
||||
yes: 'Yes',
|
||||
no: 'No',
|
||||
on: 'On',
|
||||
off: 'Off',
|
||||
all: 'All',
|
||||
none: 'None',
|
||||
search: 'Search',
|
||||
filter: 'Filter',
|
||||
sort: 'Sort',
|
||||
refresh: 'Refresh',
|
||||
download: 'Download',
|
||||
upload: 'Upload',
|
||||
actions: 'Actions',
|
||||
status: 'Status',
|
||||
name: 'Name',
|
||||
description: 'Description',
|
||||
date: 'Date',
|
||||
time: 'Time',
|
||||
hours: 'hours',
|
||||
minutes: 'minutes',
|
||||
seconds: 'seconds',
|
||||
noPrinters: 'No printers configured',
|
||||
noData: 'No data available',
|
||||
required: 'Required',
|
||||
optional: 'Optional',
|
||||
},
|
||||
|
||||
// Printers page
|
||||
printers: {
|
||||
title: 'Printers',
|
||||
addPrinter: 'Add Printer',
|
||||
editPrinter: 'Edit Printer',
|
||||
deletePrinter: 'Delete Printer',
|
||||
printerName: 'Printer Name',
|
||||
serialNumber: 'Serial Number',
|
||||
ipAddress: 'IP Address',
|
||||
accessCode: 'Access Code',
|
||||
model: 'Model',
|
||||
nozzleCount: 'Nozzle Count',
|
||||
autoArchive: 'Auto Archive',
|
||||
status: {
|
||||
idle: 'Idle',
|
||||
printing: 'Printing',
|
||||
paused: 'Paused',
|
||||
offline: 'Offline',
|
||||
error: 'Error',
|
||||
finished: 'Finished',
|
||||
unknown: 'Unknown',
|
||||
},
|
||||
temperatures: {
|
||||
nozzle: 'Nozzle',
|
||||
bed: 'Bed',
|
||||
chamber: 'Chamber',
|
||||
},
|
||||
progress: '{{percent}}% complete',
|
||||
timeRemaining: '{{time}} remaining',
|
||||
deleteConfirm: 'Are you sure you want to delete "{{name}}"?',
|
||||
maintenanceOk: 'Maintenance OK',
|
||||
maintenanceWarning: '{{count}} warning',
|
||||
maintenanceWarning_plural: '{{count}} warnings',
|
||||
maintenanceDue: '{{count}} due',
|
||||
maintenanceDue_plural: '{{count}} due',
|
||||
},
|
||||
|
||||
// Archives page
|
||||
archives: {
|
||||
title: 'Print Archives',
|
||||
searchPlaceholder: 'Search archives...',
|
||||
filterByPrinter: 'Filter by printer',
|
||||
filterByStatus: 'Filter by status',
|
||||
sortBy: 'Sort by',
|
||||
sortNewest: 'Newest first',
|
||||
sortOldest: 'Oldest first',
|
||||
sortName: 'Name',
|
||||
sortDuration: 'Duration',
|
||||
noArchives: 'No archives found',
|
||||
printTime: 'Print Time',
|
||||
filamentUsed: 'Filament Used',
|
||||
cost: 'Cost',
|
||||
reprint: 'Reprint',
|
||||
preview: 'Preview',
|
||||
deleteArchive: 'Delete Archive',
|
||||
deleteConfirm: 'Are you sure you want to delete this archive?',
|
||||
favorite: 'Favorite',
|
||||
unfavorite: 'Remove from favorites',
|
||||
viewDetails: 'View Details',
|
||||
status: {
|
||||
completed: 'Completed',
|
||||
failed: 'Failed',
|
||||
stopped: 'Stopped',
|
||||
},
|
||||
},
|
||||
|
||||
// Queue page
|
||||
queue: {
|
||||
title: 'Print Queue',
|
||||
addToQueue: 'Add to Queue',
|
||||
clearQueue: 'Clear Queue',
|
||||
emptyQueue: 'Queue is empty',
|
||||
position: 'Position',
|
||||
scheduledTime: 'Scheduled Time',
|
||||
moveUp: 'Move Up',
|
||||
moveDown: 'Move Down',
|
||||
remove: 'Remove',
|
||||
startNow: 'Start Now',
|
||||
status: {
|
||||
pending: 'Pending',
|
||||
printing: 'Printing',
|
||||
completed: 'Completed',
|
||||
failed: 'Failed',
|
||||
cancelled: 'Cancelled',
|
||||
},
|
||||
},
|
||||
|
||||
// Statistics page
|
||||
stats: {
|
||||
title: 'Statistics',
|
||||
overview: 'Overview',
|
||||
totalPrints: 'Total Prints',
|
||||
successRate: 'Success Rate',
|
||||
totalPrintTime: 'Total Print Time',
|
||||
totalFilament: 'Total Filament Used',
|
||||
totalCost: 'Total Cost',
|
||||
averagePrintTime: 'Average Print Time',
|
||||
printsPerDay: 'Prints per Day',
|
||||
byPrinter: 'By Printer',
|
||||
byMaterial: 'By Material',
|
||||
byMonth: 'By Month',
|
||||
last7Days: 'Last 7 Days',
|
||||
last30Days: 'Last 30 Days',
|
||||
last90Days: 'Last 90 Days',
|
||||
allTime: 'All Time',
|
||||
},
|
||||
|
||||
// Profiles page
|
||||
profiles: {
|
||||
title: 'Filament Profiles',
|
||||
addProfile: 'Add Profile',
|
||||
editProfile: 'Edit Profile',
|
||||
deleteProfile: 'Delete Profile',
|
||||
material: 'Material',
|
||||
brand: 'Brand',
|
||||
color: 'Color',
|
||||
diameter: 'Diameter',
|
||||
density: 'Density',
|
||||
costPerKg: 'Cost per kg',
|
||||
spoolWeight: 'Spool Weight',
|
||||
noProfiles: 'No profiles configured',
|
||||
deleteConfirm: 'Are you sure you want to delete this profile?',
|
||||
},
|
||||
|
||||
// Maintenance page
|
||||
maintenance: {
|
||||
title: 'Maintenance',
|
||||
overview: 'Overview',
|
||||
allOk: 'All maintenance up to date',
|
||||
dueCount: '{{count}} item due',
|
||||
dueCount_plural: '{{count}} items due',
|
||||
warningCount: '{{count}} warning',
|
||||
warningCount_plural: '{{count}} warnings',
|
||||
totalPrintTime: 'Total Print Time',
|
||||
nextMaintenance: 'Next Maintenance',
|
||||
nothingDue: 'Nothing due',
|
||||
tasks: 'Tasks',
|
||||
lastPerformed: 'Last performed',
|
||||
interval: 'Interval',
|
||||
hoursRemaining: '{{hours}}h remaining',
|
||||
hoursOverdue: '{{hours}}h overdue',
|
||||
markDone: 'Mark as Done',
|
||||
performMaintenance: 'Perform Maintenance',
|
||||
history: 'History',
|
||||
noHistory: 'No maintenance history',
|
||||
editPrintHours: 'Edit Print Hours',
|
||||
currentHours: 'Current Hours',
|
||||
types: {
|
||||
lubricateRails: 'Lubricate Linear Rails',
|
||||
cleanNozzle: 'Clean Nozzle/Hotend',
|
||||
checkBelts: 'Check Belt Tension',
|
||||
cleanBuildPlate: 'Clean Build Plate',
|
||||
checkExtruder: 'Check Extruder Gears',
|
||||
checkCooling: 'Check Cooling Fans',
|
||||
generalInspection: 'General Inspection',
|
||||
},
|
||||
},
|
||||
|
||||
// Settings page
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
general: 'General',
|
||||
appearance: 'Appearance',
|
||||
notifications: 'Notifications',
|
||||
smartPlugs: 'Smart Plugs',
|
||||
spoolman: 'Spoolman',
|
||||
updates: 'Updates',
|
||||
language: 'Language',
|
||||
languageDescription: 'Select your preferred language',
|
||||
theme: 'Theme',
|
||||
themeLight: 'Light',
|
||||
themeDark: 'Dark',
|
||||
themeSystem: 'System',
|
||||
defaultView: 'Default View',
|
||||
defaultViewDescription: 'Page to show when opening the app',
|
||||
checkForUpdates: 'Check for Updates',
|
||||
autoUpdate: 'Auto Update',
|
||||
currentVersion: 'Current Version',
|
||||
latestVersion: 'Latest Version',
|
||||
upToDate: 'You are up to date',
|
||||
updateAvailable: 'Update available',
|
||||
// Notifications
|
||||
notificationLanguage: 'Notification Language',
|
||||
notificationLanguageDescription: 'Language for push notifications',
|
||||
notificationProviders: 'Notification Providers',
|
||||
addProvider: 'Add Provider',
|
||||
editProvider: 'Edit Provider',
|
||||
providerType: 'Provider Type',
|
||||
testNotification: 'Test Notification',
|
||||
testSuccess: 'Test notification sent successfully',
|
||||
testFailed: 'Failed to send test notification',
|
||||
quietHours: 'Quiet Hours',
|
||||
quietHoursDescription: 'Do not disturb during these hours',
|
||||
quietHoursStart: 'Start',
|
||||
quietHoursEnd: 'End',
|
||||
events: {
|
||||
title: 'Notification Events',
|
||||
printStart: 'Print Started',
|
||||
printComplete: 'Print Completed',
|
||||
printFailed: 'Print Failed',
|
||||
printStopped: 'Print Stopped',
|
||||
printProgress: 'Progress Milestones',
|
||||
printProgressDescription: 'Notify at 25%, 50%, 75%',
|
||||
printerOffline: 'Printer Offline',
|
||||
printerError: 'Printer Error',
|
||||
filamentLow: 'Low Filament',
|
||||
maintenanceDue: 'Maintenance Due',
|
||||
maintenanceDueDescription: 'Notify when maintenance is needed',
|
||||
},
|
||||
// Smart Plugs
|
||||
smartPlug: {
|
||||
title: 'Smart Plugs',
|
||||
add: 'Add Smart Plug',
|
||||
edit: 'Edit Smart Plug',
|
||||
name: 'Name',
|
||||
ipAddress: 'IP Address',
|
||||
linkedPrinter: 'Linked Printer',
|
||||
autoOn: 'Auto Power On',
|
||||
autoOnDescription: 'Turn on when print starts',
|
||||
autoOff: 'Auto Power Off',
|
||||
autoOffDescription: 'Turn off after print completes',
|
||||
offDelay: 'Off Delay',
|
||||
offDelayMinutes: 'Minutes after print',
|
||||
offDelayTemp: 'When nozzle below temperature',
|
||||
currentState: 'Current State',
|
||||
turnOn: 'Turn On',
|
||||
turnOff: 'Turn Off',
|
||||
},
|
||||
// Spoolman
|
||||
spoolmanEnabled: 'Enable Spoolman Integration',
|
||||
spoolmanUrl: 'Spoolman URL',
|
||||
spoolmanConnected: 'Connected',
|
||||
spoolmanDisconnected: 'Disconnected',
|
||||
},
|
||||
|
||||
// Notifications (for push notifications)
|
||||
notification: {
|
||||
printStarted: {
|
||||
title: 'Print Started',
|
||||
body: '{{printer}}: {{filename}} has started printing',
|
||||
},
|
||||
printCompleted: {
|
||||
title: 'Print Completed',
|
||||
body: '{{printer}}: {{filename}} completed successfully',
|
||||
},
|
||||
printFailed: {
|
||||
title: 'Print Failed',
|
||||
body: '{{printer}}: {{filename}} has failed',
|
||||
},
|
||||
printStopped: {
|
||||
title: 'Print Stopped',
|
||||
body: '{{printer}}: {{filename}} was stopped',
|
||||
},
|
||||
printProgress: {
|
||||
title: 'Print Progress',
|
||||
body: '{{printer}}: {{filename}} is {{percent}}% complete',
|
||||
},
|
||||
printerOffline: {
|
||||
title: 'Printer Offline',
|
||||
body: '{{printer}} is offline',
|
||||
},
|
||||
printerError: {
|
||||
title: 'Printer Error',
|
||||
body: '{{printer}}: {{error}}',
|
||||
},
|
||||
filamentLow: {
|
||||
title: 'Low Filament',
|
||||
body: '{{printer}}: Filament is running low',
|
||||
},
|
||||
maintenanceDue: {
|
||||
title: 'Maintenance Due',
|
||||
body: '{{printer}}: {{items}} need attention',
|
||||
},
|
||||
},
|
||||
|
||||
// Errors
|
||||
errors: {
|
||||
generic: 'Something went wrong',
|
||||
networkError: 'Network error. Please check your connection.',
|
||||
notFound: 'Not found',
|
||||
unauthorized: 'Unauthorized',
|
||||
serverError: 'Server error',
|
||||
validationError: 'Please check your input',
|
||||
printerConnectionFailed: 'Failed to connect to printer',
|
||||
saveFailed: 'Failed to save changes',
|
||||
deleteFailed: 'Failed to delete',
|
||||
loadFailed: 'Failed to load data',
|
||||
},
|
||||
|
||||
// Confirmations
|
||||
confirm: {
|
||||
delete: 'Are you sure you want to delete this?',
|
||||
unsavedChanges: 'You have unsaved changes. Are you sure you want to leave?',
|
||||
clearQueue: 'Are you sure you want to clear the queue?',
|
||||
},
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import './i18n' // Initialize i18n
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Save, Loader2, Check, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink } from 'lucide-react';
|
||||
import { Save, Loader2, Check, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { api } from '../api/client';
|
||||
import type { AppSettings, SmartPlug, NotificationProvider, UpdateStatus } from '../api/client';
|
||||
import { Card, CardContent, CardHeader } from '../components/Card';
|
||||
@@ -10,10 +11,12 @@ import { NotificationProviderCard } from '../components/NotificationProviderCard
|
||||
import { AddNotificationModal } from '../components/AddNotificationModal';
|
||||
import { SpoolmanSettings } from '../components/SpoolmanSettings';
|
||||
import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
|
||||
import { availableLanguages } from '../i18n';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function SettingsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t, i18n } = useTranslation();
|
||||
const [localSettings, setLocalSettings] = useState<AppSettings | null>(null);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [showSaved, setShowSaved] = useState(false);
|
||||
@@ -102,7 +105,8 @@ export function SettingsPage() {
|
||||
settings.currency !== localSettings.currency ||
|
||||
settings.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh ||
|
||||
settings.energy_tracking_mode !== localSettings.energy_tracking_mode ||
|
||||
settings.check_updates !== localSettings.check_updates;
|
||||
settings.check_updates !== localSettings.check_updates ||
|
||||
settings.notification_language !== localSettings.notification_language;
|
||||
setHasChanges(changed);
|
||||
}
|
||||
}, [settings, localSettings]);
|
||||
@@ -318,12 +322,32 @@ export function SettingsPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold text-white">Interface</h2>
|
||||
<h2 className="text-lg font-semibold text-white">{t('settings.general')}</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">
|
||||
Default view on startup
|
||||
<Globe className="w-4 h-4 inline mr-1" />
|
||||
{t('settings.language')}
|
||||
</label>
|
||||
<select
|
||||
value={i18n.language}
|
||||
onChange={(e) => i18n.changeLanguage(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
>
|
||||
{availableLanguages.map((lang) => (
|
||||
<option key={lang.code} value={lang.code}>
|
||||
{lang.nativeName} ({lang.name})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-bambu-gray mt-1">
|
||||
{t('settings.languageDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">
|
||||
{t('settings.defaultView')}
|
||||
</label>
|
||||
<select
|
||||
value={defaultView}
|
||||
@@ -332,12 +356,12 @@ export function SettingsPage() {
|
||||
>
|
||||
{defaultNavItems.map((item) => (
|
||||
<option key={item.id} value={item.to}>
|
||||
{item.label}
|
||||
{t(item.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-bambu-gray mt-1">
|
||||
Page to show when opening the app
|
||||
{t('settings.defaultViewDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -565,6 +589,26 @@ export function SettingsPage() {
|
||||
<p className="text-sm text-bambu-gray mb-4">
|
||||
Get notified about print events via WhatsApp, Telegram, Email, and more.
|
||||
</p>
|
||||
|
||||
{/* Notification Language */}
|
||||
<div className="flex items-center justify-between py-3 border-b border-bambu-dark-tertiary mb-4">
|
||||
<div>
|
||||
<p className="text-white">{t('settings.notificationLanguage')}</p>
|
||||
<p className="text-sm text-bambu-gray">{t('settings.notificationLanguageDescription')}</p>
|
||||
</div>
|
||||
<select
|
||||
value={localSettings.notification_language || 'en'}
|
||||
onChange={(e) => updateSetting('notification_language', e.target.value)}
|
||||
className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-bambu-green"
|
||||
>
|
||||
{availableLanguages.map((lang) => (
|
||||
<option key={lang.code} value={lang.code}>
|
||||
{lang.nativeName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{providersLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
|
||||
|
||||
Reference in New Issue
Block a user