Added multi language support

This commit is contained in:
Martin Ziegler
2025-12-01 08:58:19 +01:00
parent f126b0a075
commit 1cf5c8e9c3
13 changed files with 1457 additions and 479 deletions
+1
View File
@@ -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(() => {
+23 -21
View File
@@ -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>
+46
View File
@@ -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' },
];
+358
View File
@@ -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?',
},
};
+358
View File
@@ -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
View File
@@ -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(
+50 -6
View File
@@ -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" />