Add i18n support for Settings, Smart Plugs, Notifications, and Backup/Restore

Replace all hardcoded English strings with t() translation calls across
  9 components: SettingsPage, SmartPlugCard, AddSmartPlugModal,
  SwitchbarPopover, NotificationProviderCard, AddNotificationModal,
  NotificationTemplateEditor, NotificationLogViewer, GitHubBackupSettings,
  and RestoreModal. Add ~600 new translation keys to all 7 locales
  (en, de, ja, fr, it, pt-BR, zh-CN).
This commit is contained in:
maziggy
2026-03-06 13:23:55 +01:00
parent b16b12bf8d
commit 8c308487c7
20 changed files with 5195 additions and 1017 deletions
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Save, Loader2, Send, CheckCircle, XCircle } from 'lucide-react';
import { api } from '../api/client';
import type { NotificationProvider, NotificationProviderCreate, NotificationProviderUpdate, ProviderType } from '../api/client';
@@ -11,17 +12,10 @@ interface AddNotificationModalProps {
onClose: () => void;
}
const PROVIDER_OPTIONS: { value: ProviderType; label: string; description: string }[] = [
{ value: 'email', label: 'Email', description: 'SMTP email notifications' },
{ value: 'telegram', label: 'Telegram', description: 'Notifications via Telegram bot' },
{ value: 'discord', label: 'Discord', description: 'Send to Discord channel via webhook' },
{ value: 'ntfy', label: 'ntfy', description: 'Free, self-hostable push notifications' },
{ value: 'pushover', label: 'Pushover', description: 'Simple, reliable push notifications' },
{ value: 'callmebot', label: 'CallMeBot/WhatsApp', description: 'Free WhatsApp notifications via CallMeBot' },
{ value: 'webhook', label: 'Webhook', description: 'Generic HTTP POST to any URL' },
];
const PROVIDER_VALUES: ProviderType[] = ['email', 'telegram', 'discord', 'ntfy', 'pushover', 'callmebot', 'webhook'];
export function AddNotificationModal({ provider, onClose }: AddNotificationModalProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const isEditing = !!provider;
@@ -112,7 +106,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
setError(null);
if (!name.trim()) {
setError('Name is required');
setError(t('notifications.nameRequired'));
return;
}
@@ -120,7 +114,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
const requiredFields = getRequiredFields(providerType);
for (const field of requiredFields) {
if (!config[field.key]?.trim()) {
setError(`${field.label} is required`);
setError(t('notifications.fieldRequired', { field: field.label }));
return;
}
}
@@ -239,7 +233,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
<h2 className="text-lg font-semibold text-white">
{isEditing ? 'Edit Notification Provider' : 'Add Notification Provider'}
{isEditing ? t('notifications.editTitle') : t('notifications.addTitle')}
</h2>
<button
onClick={onClose}
@@ -259,19 +253,19 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
{/* Name */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Name *</label>
<label className="block text-sm text-bambu-gray mb-1">{t('notifications.nameLabel')}</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My Notifications"
placeholder={t('notifications.namePlaceholder')}
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"
/>
</div>
{/* Provider Type */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Provider Type *</label>
<label className="block text-sm text-bambu-gray mb-1">{t('notifications.providerTypeLabel')}</label>
<select
value={providerType}
onChange={(e) => {
@@ -282,20 +276,20 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
disabled={isEditing}
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 disabled:opacity-50"
>
{PROVIDER_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
{PROVIDER_VALUES.map((value) => (
<option key={value} value={value}>
{t(`notifications.providerTypes.${value}`, value)}
</option>
))}
</select>
<p className="text-xs text-bambu-gray mt-1">
{PROVIDER_OPTIONS.find(o => o.value === providerType)?.description}
{t(`notifications.providerDescriptions.${providerType}`, '')}
</p>
</div>
{/* Provider-specific configuration */}
<div className="space-y-3">
<p className="text-sm text-bambu-gray">Configuration</p>
<p className="text-sm text-bambu-gray">{t('notifications.configuration')}</p>
{configFields
.filter((field) => !('showIf' in field) || (field as { showIf?: (cfg: Record<string, string>) => boolean }).showIf?.(config) !== false)
.map((field) => (
@@ -351,7 +345,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
) : (
<Send className="w-4 h-4" />
)}
Test Configuration
{t('notifications.testConfiguration')}
</Button>
</div>
@@ -378,13 +372,13 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
{/* Link to Printer */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Printer Filter</label>
<label className="block text-sm text-bambu-gray mb-1">{t('notifications.printerFilter')}</label>
<select
value={printerId ?? ''}
onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
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"
>
<option value="">All printers</option>
<option value="">{t('notifications.allPrinters')}</option>
{printers?.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
@@ -392,14 +386,14 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
))}
</select>
<p className="text-xs text-bambu-gray mt-1">
Only send notifications for events from this printer
{t('notifications.onlyFromPrinter')}
</p>
</div>
{/* Quiet Hours */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm text-white">Quiet Hours (Do Not Disturb)</label>
<label className="text-sm text-white">{t('notifications.quietHoursDnd')}</label>
<Toggle
checked={quietHoursEnabled}
onChange={setQuietHoursEnabled}
@@ -408,7 +402,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
{quietHoursEnabled && (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-bambu-gray mb-1">Start</label>
<label className="block text-xs text-bambu-gray mb-1">{t('notifications.quietStart')}</label>
<input
type="time"
value={quietHoursStart}
@@ -417,7 +411,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
/>
</div>
<div>
<label className="block text-xs text-bambu-gray mb-1">End</label>
<label className="block text-xs text-bambu-gray mb-1">{t('notifications.quietEnd')}</label>
<input
type="time"
value={quietHoursEnd}
@@ -433,8 +427,8 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<label className="text-sm text-white">Daily Digest</label>
<p className="text-xs text-bambu-gray">Batch notifications into a single daily summary</p>
<label className="text-sm text-white">{t('notifications.dailyDigestLabel')}</label>
<p className="text-xs text-bambu-gray">{t('notifications.batchNotifications')}</p>
</div>
<Toggle
checked={dailyDigestEnabled}
@@ -443,7 +437,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
</div>
{dailyDigestEnabled && (
<div>
<label className="block text-xs text-bambu-gray mb-1">Send digest at</label>
<label className="block text-xs text-bambu-gray mb-1">{t('notifications.sendDigestAt')}</label>
<input
type="time"
value={dailyDigestTime}
@@ -451,7 +445,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
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"
/>
<p className="text-xs text-bambu-gray mt-1">
Events will be collected and sent as a single summary at this time
{t('notifications.digestCollected')}
</p>
</div>
)}
@@ -459,39 +453,39 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
{/* Event Toggles */}
<div className="space-y-3">
<p className="text-sm text-bambu-gray">Notification Events</p>
<p className="text-sm text-bambu-gray">{t('notifications.notificationEvents')}</p>
{/* Print Events */}
<div className="space-y-2 p-3 bg-bambu-dark rounded-lg">
<p className="text-xs text-bambu-gray uppercase tracking-wide mb-2">Print Events</p>
<p className="text-xs text-bambu-gray uppercase tracking-wide mb-2">{t('notifications.printEvents')}</p>
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center justify-between">
<span className="text-sm text-white">Start</span>
<span className="text-sm text-white">{t('notifications.start')}</span>
<Toggle checked={onPrintStart} onChange={setOnPrintStart} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-white">Complete</span>
<span className="text-sm text-white">{t('notifications.complete')}</span>
<Toggle checked={onPrintComplete} onChange={setOnPrintComplete} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-white">Failed</span>
<span className="text-sm text-white">{t('notifications.failed')}</span>
<Toggle checked={onPrintFailed} onChange={setOnPrintFailed} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-white">Stopped</span>
<span className="text-sm text-white">{t('notifications.stopped')}</span>
<Toggle checked={onPrintStopped} onChange={setOnPrintStopped} />
</div>
<div className="flex items-center justify-between col-span-2">
<div>
<span className="text-sm text-white">Progress</span>
<span className="text-xs text-bambu-gray ml-1">(25%, 50%, 75%)</span>
<span className="text-sm text-white">{t('notifications.progress')}</span>
<span className="text-xs text-bambu-gray ml-1">{t('notifications.progressPercent')}</span>
</div>
<Toggle checked={onPrintProgress} onChange={setOnPrintProgress} />
</div>
<div className="flex items-center justify-between col-span-2">
<div>
<span className="text-sm text-white">Bed Cooled</span>
<span className="text-xs text-bambu-gray ml-1">(after print completes)</span>
<span className="text-sm text-white">{t('notifications.bedCooled')}</span>
<span className="text-xs text-bambu-gray ml-1">{t('notifications.bedCooledAfterPrint')}</span>
</div>
<Toggle checked={onBedCooled} onChange={setOnBedCooled} />
</div>
@@ -500,22 +494,22 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
{/* Printer Status Events */}
<div className="space-y-2 p-3 bg-bambu-dark rounded-lg">
<p className="text-xs text-bambu-gray uppercase tracking-wide mb-2">Printer Status</p>
<p className="text-xs text-bambu-gray uppercase tracking-wide mb-2">{t('notifications.printerStatus')}</p>
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center justify-between">
<span className="text-sm text-white">Offline</span>
<span className="text-sm text-white">{t('notifications.offline')}</span>
<Toggle checked={onPrinterOffline} onChange={setOnPrinterOffline} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-white">Error</span>
<span className="text-sm text-white">{t('notifications.error')}</span>
<Toggle checked={onPrinterError} onChange={setOnPrinterError} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-white">Low Filament</span>
<span className="text-sm text-white">{t('notifications.lowFilament')}</span>
<Toggle checked={onFilamentLow} onChange={setOnFilamentLow} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-white">Maintenance</span>
<span className="text-sm text-white">{t('notifications.maintenance')}</span>
<Toggle checked={onMaintenanceDue} onChange={setOnMaintenanceDue} />
</div>
</div>
@@ -530,7 +524,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
onClick={onClose}
className="flex-1"
>
Cancel
{t('notifications.cancel')}
</Button>
<Button
type="submit"
@@ -542,7 +536,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
) : (
<Save className="w-4 h-4" />
)}
{isEditing ? 'Save' : 'Add'}
{isEditing ? t('notifications.save') : t('notifications.add')}
</Button>
</div>
</form>
+80 -83
View File
@@ -207,7 +207,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
} catch (err) {
setIsScanning(false);
const errorMsg = err instanceof Error ? err.message : (typeof err === 'string' ? err : JSON.stringify(err));
setError(errorMsg || 'Failed to start scan');
setError(errorMsg || t('smartPlugs.failedToStartScan'));
}
};
@@ -295,7 +295,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
setError(null);
if (!name.trim()) {
setError('Name is required');
setError(t('smartPlugs.nameRequired'));
return;
}
@@ -305,7 +305,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
}
if (plugType === 'homeassistant' && !haEntityId) {
setError('Entity is required for Home Assistant plugs');
setError(t('smartPlugs.entityRequired'));
return;
}
@@ -316,7 +316,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
const hasState = mqttStateTopic.trim();
if (!hasPower && !hasEnergy && !hasState) {
setError('At least one MQTT topic must be configured for power, energy, or state monitoring');
setError(t('smartPlugs.mqttTopicRequired'));
return;
}
}
@@ -379,7 +379,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary flex-shrink-0">
<h2 className="text-lg font-semibold text-white">
{isEditing ? 'Edit Smart Plug' : 'Add Smart Plug'}
{isEditing ? t('smartPlugs.editTitle') : t('smartPlugs.addTitle')}
</h2>
<button
onClick={onClose}
@@ -458,12 +458,12 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
{isScanning ? (
<Button type="button" variant="secondary" onClick={stopScan} className="w-full">
<X className="w-4 h-4" />
Stop Scanning
{t('smartPlugs.stopScanning')}
</Button>
) : (
<Button type="button" variant="primary" onClick={startScan} className="w-full">
<Search className="w-4 h-4" />
Discover Tasmota Devices
{t('smartPlugs.discoverTasmota')}
</Button>
)}
@@ -486,7 +486,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
{/* Discovered devices */}
{discoveredDevices.length > 0 && (
<div className="space-y-2">
<p className="text-xs text-bambu-gray">Found {discoveredDevices.length} device(s) - click to select:</p>
<p className="text-xs text-bambu-gray">{t('smartPlugs.foundDevices', { count: discoveredDevices.length })}</p>
<div className="max-h-40 overflow-y-auto space-y-1">
{discoveredDevices.map((device) => (
<button
@@ -518,7 +518,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
{!isScanning && discoveredDevices.length === 0 && scanProgress.total > 0 && (
<p className="text-xs text-bambu-gray text-center py-2">
No Tasmota devices found on your network
{t('smartPlugs.noDevicesFound')}
</p>
)}
</div>
@@ -531,11 +531,11 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
{!haConfigured && (
<div className="space-y-3">
<div className="p-3 bg-yellow-500/20 border border-yellow-500/50 rounded-lg text-sm text-yellow-400">
Home Assistant is not configured. Set it up in{' '}
<span className="font-medium">Settings → Network → Home Assistant</span>
{t('smartPlugs.haNotConfigured')}{' '}
<span className="font-medium">{t('smartPlugs.haSettingsPath')}</span>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1 opacity-50">Select Entity *</label>
<label className="block text-sm text-bambu-gray mb-1 opacity-50">{t('smartPlugs.selectEntity')}</label>
<select
disabled
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray cursor-not-allowed opacity-50"
@@ -552,13 +552,13 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
{haEntitiesLoading && (
<div className="flex items-center justify-center py-4 text-bambu-gray">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
Loading entities...
{t('smartPlugs.loadingEntities')}
</div>
)}
{haEntitiesError && (
<div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
Failed to load entities: {(haEntitiesError as Error).message}
{t('smartPlugs.failedToLoadEntities', { error: (haEntitiesError as Error).message })}
</div>
)}
@@ -573,7 +573,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
return (
<div ref={entityDropdownRef} className="relative">
<label className="block text-sm text-bambu-gray mb-1">Select Entity *</label>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.selectEntity')}</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
<input
@@ -613,14 +613,14 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
{haEntitiesLoading && (
<div className="px-3 py-2 text-sm text-bambu-gray flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
Loading...
{t('smartPlugs.loading')}
</div>
)}
{!haEntitiesLoading && availableEntities.length === 0 && (
<div className="px-3 py-2 text-sm text-bambu-gray">
{debouncedSearch
? `No entities found matching "${debouncedSearch}"`
: 'No entities available'}
? t('smartPlugs.noEntitiesMatching', { search: debouncedSearch })
: t('smartPlugs.noEntitiesAvailable')}
</div>
)}
{!haEntitiesLoading && availableEntities.map((entity) => (
@@ -652,8 +652,8 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
<p className="text-xs text-bambu-gray mt-1">
{debouncedSearch
? `Searching all entities (${availableEntities.length} found)`
: `Showing switch, light, input_boolean (${availableEntities.length} available)`}
? t('smartPlugs.searchingEntities', { count: availableEntities.length })
: t('smartPlugs.showingEntities', { count: availableEntities.length })}
</p>
</div>
);
@@ -664,9 +664,9 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
{haEntityId && haSensorEntities && haSensorEntities.length > 0 && (
<div className="border-t border-bambu-dark-tertiary pt-4 mt-4 space-y-3">
<div>
<p className="text-white font-medium mb-1">Energy Monitoring (Optional)</p>
<p className="text-white font-medium mb-1">{t('smartPlugs.energyMonitoringOptional')}</p>
<p className="text-xs text-bambu-gray mb-3">
Search and select sensors that provide power/energy data.
{t('smartPlugs.energyMonitoringHint')}
</p>
</div>
@@ -685,7 +685,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
return (
<div ref={powerDropdownRef} className="relative">
<label className="block text-sm text-bambu-gray mb-1">Power Sensor (W)</label>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.powerSensorW')}</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
<input
@@ -726,7 +726,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
}}
className="w-full px-3 py-2 text-left text-sm text-bambu-gray hover:bg-bambu-dark-tertiary"
>
None
{t('smartPlugs.none')}
</button>
{filteredPowerSensors.map((sensor) => (
<button
@@ -746,7 +746,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
</button>
))}
{filteredPowerSensors.length === 0 && (
<div className="px-3 py-2 text-sm text-bambu-gray">No matching sensors</div>
<div className="px-3 py-2 text-sm text-bambu-gray">{t('smartPlugs.noMatchingSensors')}</div>
)}
</div>
)}
@@ -769,7 +769,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
return (
<div ref={energyTodayDropdownRef} className="relative">
<label className="block text-sm text-bambu-gray mb-1">Energy Today (kWh)</label>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.energyTodayKwh')}</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
<input
@@ -810,7 +810,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
}}
className="w-full px-3 py-2 text-left text-sm text-bambu-gray hover:bg-bambu-dark-tertiary"
>
None
{t('smartPlugs.none')}
</button>
{filteredEnergySensors.map((sensor) => (
<button
@@ -830,7 +830,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
</button>
))}
{filteredEnergySensors.length === 0 && (
<div className="px-3 py-2 text-sm text-bambu-gray">No matching sensors</div>
<div className="px-3 py-2 text-sm text-bambu-gray">{t('smartPlugs.noMatchingSensors')}</div>
)}
</div>
)}
@@ -853,7 +853,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
return (
<div ref={energyTotalDropdownRef} className="relative">
<label className="block text-sm text-bambu-gray mb-1">Total Energy (kWh)</label>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.totalEnergyKwh')}</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
<input
@@ -894,7 +894,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
}}
className="w-full px-3 py-2 text-left text-sm text-bambu-gray hover:bg-bambu-dark-tertiary"
>
None
{t('smartPlugs.none')}
</button>
{filteredEnergySensors.map((sensor) => (
<button
@@ -914,7 +914,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
</button>
))}
{filteredEnergySensors.length === 0 && (
<div className="px-3 py-2 text-sm text-bambu-gray">No matching sensors</div>
<div className="px-3 py-2 text-sm text-bambu-gray">{t('smartPlugs.noMatchingSensors')}</div>
)}
</div>
)}
@@ -934,9 +934,9 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
{/* MQTT broker not configured */}
{!settings?.mqtt_broker && (
<div className="p-3 bg-yellow-500/20 border border-yellow-500/50 rounded-lg text-sm text-yellow-400">
MQTT broker not configured. Set broker address in{' '}
<span className="font-medium">Settings → Network → MQTT Publishing</span>
{' '}(you don't need to enable publishing, just fill in the broker details).
{t('smartPlugs.mqttNotConfigured')}{' '}
<span className="font-medium">{t('smartPlugs.mqttSettingsPath')}</span>
{' '}{t('smartPlugs.mqttNotConfiguredSuffix')}
</div>
)}
@@ -944,17 +944,17 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
{settings?.mqtt_broker && (
<>
<div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg text-sm text-blue-300">
<p className="font-medium mb-1">Monitor Only</p>
<p className="font-medium mb-1">{t('smartPlugs.monitorOnly')}</p>
<p className="text-xs opacity-80">
MQTT plugs receive power/energy data via MQTT subscription. On/off control is not available - use your MQTT broker or home automation system.
{t('smartPlugs.mqttMonitorOnlyDescription')}
</p>
</div>
{/* Power Section */}
<div className="space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
<p className="text-white font-medium text-sm">Power Monitoring</p>
<p className="text-white font-medium text-sm">{t('smartPlugs.powerMonitoring')}</p>
<div>
<label className="block text-sm text-bambu-gray mb-1">Topic</label>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.topic')}</label>
<input
type="text"
value={mqttPowerTopic}
@@ -965,7 +965,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-bambu-gray mb-1">JSON Path</label>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.jsonPath')}</label>
<input
type="text"
value={mqttPowerPath}
@@ -975,7 +975,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">Multiplier</label>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.multiplier')}</label>
<input
type="text"
value={mqttPowerMultiplier}
@@ -985,17 +985,16 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
/>
</div>
</div>
<p className="text-xs text-bambu-gray">
JSON path extracts value from JSON payload (e.g., "power_l1"). Leave empty if topic publishes raw numeric values.<br/>
Use multiplier 0.001 for mW→W, 1000 for kW→W.
<p className="text-xs text-bambu-gray" style={{ whiteSpace: 'pre-line' }}>
{t('smartPlugs.mqttPowerHint')}
</p>
</div>
{/* Energy Section */}
<div className="space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
<p className="text-white font-medium text-sm">Energy Monitoring <span className="text-bambu-gray font-normal">(optional)</span></p>
<p className="text-white font-medium text-sm">{t('smartPlugs.energyMonitoring')} <span className="text-bambu-gray font-normal">({t('smartPlugs.optional')})</span></p>
<div>
<label className="block text-sm text-bambu-gray mb-1">Topic</label>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.topic')}</label>
<input
type="text"
value={mqttEnergyTopic}
@@ -1006,7 +1005,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-bambu-gray mb-1">JSON Path</label>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.jsonPath')}</label>
<input
type="text"
value={mqttEnergyPath}
@@ -1016,7 +1015,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">Multiplier</label>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.multiplier')}</label>
<input
type="text"
value={mqttEnergyMultiplier}
@@ -1026,17 +1025,16 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
/>
</div>
</div>
<p className="text-xs text-bambu-gray">
JSON path extracts value from JSON payload. Leave empty for raw values.<br/>
Use multiplier 0.001 for Wh→kWh, 1000 for MWh→kWh.
<p className="text-xs text-bambu-gray" style={{ whiteSpace: 'pre-line' }}>
{t('smartPlugs.mqttEnergyHint')}
</p>
</div>
{/* State Section */}
<div className="space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
<p className="text-white font-medium text-sm">State Monitoring <span className="text-bambu-gray font-normal">(optional)</span></p>
<p className="text-white font-medium text-sm">{t('smartPlugs.stateMonitoring')} <span className="text-bambu-gray font-normal">({t('smartPlugs.optional')})</span></p>
<div>
<label className="block text-sm text-bambu-gray mb-1">Topic</label>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.topic')}</label>
<input
type="text"
value={mqttStateTopic}
@@ -1047,7 +1045,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-bambu-gray mb-1">JSON Path</label>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.jsonPath')}</label>
<input
type="text"
value={mqttStatePath}
@@ -1057,7 +1055,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">ON Value</label>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.onValue')}</label>
<input
type="text"
value={mqttStateOnValue}
@@ -1067,9 +1065,8 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
/>
</div>
</div>
<p className="text-xs text-bambu-gray">
JSON path extracts value from JSON payload. Leave empty for raw values.<br/>
ON value: the exact string that means "ON". Leave empty for auto-detect (ON, true, 1).
<p className="text-xs text-bambu-gray" style={{ whiteSpace: 'pre-line' }}>
{t('smartPlugs.mqttStateHint')}
</p>
</div>
</>
@@ -1080,7 +1077,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
{/* IP Address - only show for Tasmota */}
{plugType === 'tasmota' && (
<div>
<label className="block text-sm text-bambu-gray mb-1">IP Address *</label>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.ipAddress')}</label>
<div className="flex gap-2">
<input
type="text"
@@ -1103,7 +1100,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
) : (
<Wifi className="w-4 h-4" />
)}
Test
{t('smartPlugs.test')}
</Button>
</div>
</div>
@@ -1120,10 +1117,10 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
<>
<CheckCircle className="w-5 h-5" />
<div>
<p className="font-medium">Connected!</p>
<p className="font-medium">{t('smartPlugs.connectedResult')}</p>
<p className="text-sm opacity-80">
{testResult.device_name && `Device: ${testResult.device_name} - `}
State: {testResult.state}
{testResult.device_name && t('smartPlugs.deviceLabel', { name: testResult.device_name })}
{t('smartPlugs.stateLabel', { state: testResult.state })}
</p>
</div>
</>
@@ -1138,7 +1135,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
{/* Name */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Name *</label>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.nameLabel')}</label>
<input
type="text"
value={name}
@@ -1153,7 +1150,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
<>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-bambu-gray mb-1">Username</label>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.username')}</label>
<input
type="text"
value={username}
@@ -1163,7 +1160,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">Password</label>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.password')}</label>
<input
type="password"
value={password}
@@ -1174,7 +1171,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
</div>
</div>
<p className="text-xs text-bambu-gray -mt-2">
Leave empty if your Tasmota device doesn't require authentication
{t('smartPlugs.authHint')}
</p>
</>
)}
@@ -1182,13 +1179,13 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
{/* Link to Printer - not shown for MQTT plugs (monitor-only) */}
{plugType !== 'mqtt' && (
<div>
<label className="block text-sm text-bambu-gray mb-1">Link to Printer</label>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.linkToPrinter')}</label>
<select
value={printerId ?? ''}
onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
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"
>
<option value="">No printer (manual control only)</option>
<option value="">{t('smartPlugs.noPrinter')}</option>
{availablePrinters?.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
@@ -1196,7 +1193,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
))}
</select>
<p className="text-xs text-bambu-gray mt-1">
Linking enables automatic on/off when prints start/complete
{t('smartPlugs.linkingDescription')}
</p>
</div>
)}
@@ -1206,7 +1203,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Bell className="w-4 h-4 text-bambu-green" />
<span className="text-white font-medium">Power Alerts</span>
<span className="text-white font-medium">{t('smartPlugs.powerAlerts')}</span>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
@@ -1222,7 +1219,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-bambu-gray mb-1">Alert if above (W)</label>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.alertAbove')}</label>
<input
type="number"
value={powerAlertHigh}
@@ -1234,7 +1231,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">Alert if below (W)</label>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.alertBelow')}</label>
<input
type="number"
value={powerAlertLow}
@@ -1247,7 +1244,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
</div>
</div>
<p className="text-xs text-bambu-gray">
Get notified when power consumption crosses these thresholds. Leave empty to disable that direction.
{t('smartPlugs.alertDescription')}
</p>
</div>
)}
@@ -1259,7 +1256,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-bambu-green" />
<span className="text-white font-medium">Daily Schedule</span>
<span className="text-white font-medium">{t('smartPlugs.dailySchedule')}</span>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
@@ -1275,7 +1272,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-bambu-gray mb-1">Turn On at</label>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.turnOnAt')}</label>
<input
type="time"
value={scheduleOnTime}
@@ -1284,7 +1281,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">Turn Off at</label>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.turnOffAt')}</label>
<input
type="time"
value={scheduleOffTime}
@@ -1294,7 +1291,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
</div>
</div>
<p className="text-xs text-bambu-gray">
Automatically turn the plug on/off at these times daily. Leave empty to skip that action.
{t('smartPlugs.scheduleDescription')}
</p>
</div>
)}
@@ -1307,8 +1304,8 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
<div className="flex items-center gap-2">
<LayoutGrid className="w-4 h-4 text-bambu-green" />
<div>
<span className="text-white font-medium">Show in Switchbar</span>
<p className="text-xs text-bambu-gray">Quick access from sidebar</p>
<span className="text-white font-medium">{t('smartPlugs.showInSwitchbar')}</span>
<p className="text-xs text-bambu-gray">{t('smartPlugs.quickAccessSidebar')}</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
@@ -1330,8 +1327,8 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
<div className="flex items-center gap-2">
<Eye className="w-4 h-4 text-bambu-green" />
<div>
<span className="text-white font-medium">Show on Printer Card</span>
<p className="text-xs text-bambu-gray">Display button on printer card</p>
<span className="text-white font-medium">{t('smartPlugs.showOnPrinterCard')}</span>
<p className="text-xs text-bambu-gray">{t('smartPlugs.displayOnPrinterCard')}</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
@@ -1355,7 +1352,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
onClick={onClose}
className="flex-1"
>
Cancel
{t('smartPlugs.cancel')}
</Button>
<Button
type="submit"
@@ -1367,7 +1364,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
) : (
<Save className="w-4 h-4" />
)}
{isEditing ? 'Save' : 'Add'}
{isEditing ? t('smartPlugs.save') : t('smartPlugs.add')}
</Button>
</div>
</form>
@@ -205,7 +205,7 @@ export function GitHubBackupSettings() {
enabled,
});
setAccessToken(''); // Clear after save
showToast('Token updated');
showToast(t('backup.tokenUpdated'));
} else {
// Update without token
await api.updateGitHubBackupConfig({
@@ -218,14 +218,14 @@ export function GitHubBackupSettings() {
backup_settings: backupSettings,
enabled,
});
showToast('Settings saved');
showToast(t('backup.settingsSaved'));
}
queryClient.invalidateQueries({ queryKey: ['github-backup-config'] });
queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
} catch (error) {
showToast(`Failed to save: ${(error as Error).message}`, 'error');
showToast(t('backup.failedToSave', { message: (error as Error).message }), 'error');
}
}, [config?.has_token, repoUrl, accessToken, branch, scheduleEnabled, scheduleType, backupKProfiles, backupCloudProfiles, backupSettings, enabled, queryClient, showToast]);
}, [config?.has_token, repoUrl, accessToken, branch, scheduleEnabled, scheduleType, backupKProfiles, backupCloudProfiles, backupSettings, enabled, queryClient, showToast, t]);
// Auto-save effect for existing configs (debounced)
useEffect(() => {
@@ -271,12 +271,12 @@ export function GitHubBackupSettings() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['github-backup-config'] });
queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
showToast('GitHub backup enabled');
showToast(t('backup.githubBackupEnabled'));
setAccessToken('');
isInitializedRef.current = true;
},
onError: (error: Error) => {
showToast(`Failed to save: ${error.message}`, 'error');
showToast(t('backup.failedToSave', { message: error.message }), 'error');
},
});
@@ -287,16 +287,16 @@ export function GitHubBackupSettings() {
queryClient.invalidateQueries({ queryKey: ['github-backup-logs'] });
if (result.success) {
if (result.files_changed > 0) {
showToast(`Backup complete - ${result.files_changed} files updated`);
showToast(t('backup.backupCompleteFiles', { count: result.files_changed }));
} else {
showToast('Backup skipped - no changes');
showToast(t('backup.backupSkippedNoChanges'));
}
} else {
showToast(`Backup failed: ${result.message}`, 'error');
showToast(t('backup.backupFailed2', { message: result.message }), 'error');
}
},
onError: (error: Error) => {
showToast(`Backup failed: ${error.message}`, 'error');
showToast(t('backup.backupFailed2', { message: error.message }), 'error');
},
});
@@ -304,10 +304,10 @@ export function GitHubBackupSettings() {
mutationFn: () => api.clearGitHubBackupLogs(0),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['github-backup-logs'] });
showToast(`Cleared ${result.deleted} logs`);
showToast(t('backup.clearedLogs', { count: result.deleted }));
},
onError: (error: Error) => {
showToast(`Failed to clear logs: ${error.message}`, 'error');
showToast(t('backup.failedToClearLogs', { message: error.message }), 'error');
},
});
@@ -319,7 +319,7 @@ export function GitHubBackupSettings() {
// If user entered a new token, test with those credentials
if (accessToken) {
if (!repoUrl) {
showToast('Enter repository URL', 'error');
showToast(t('backup.enterRepoUrl'), 'error');
setTestLoading(false);
return;
}
@@ -328,7 +328,7 @@ export function GitHubBackupSettings() {
// Use stored credentials
result = await api.testGitHubStoredConnection();
} else {
showToast('Enter repository URL and access token', 'error');
showToast(t('backup.enterRepoAndToken'), 'error');
setTestLoading(false);
return;
}
@@ -343,11 +343,11 @@ export function GitHubBackupSettings() {
// Initial setup save (only for new configs)
const handleInitialSetup = () => {
if (!repoUrl) {
showToast('Repository URL is required', 'error');
showToast(t('backup.repoRequired'), 'error');
return;
}
if (!accessToken) {
showToast('Access token is required', 'error');
showToast(t('backup.tokenRequired'), 'error');
return;
}
@@ -381,11 +381,11 @@ export function GitHubBackupSettings() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Github className="w-5 h-5 text-gray-400" />
<h2 className="text-lg font-semibold text-white">GitHub Backup</h2>
<h2 className="text-lg font-semibold text-white">{t('backup.githubBackup')}</h2>
</div>
{config && cloudStatus?.is_authenticated && (
<div className="flex items-center gap-2">
<span className="text-sm text-bambu-gray">Enabled</span>
<span className="text-sm text-bambu-gray">{t('backup.enabled')}</span>
<Toggle
checked={enabled}
onChange={setEnabled}
@@ -400,19 +400,19 @@ export function GitHubBackupSettings() {
<div className="flex items-start gap-2 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<AlertTriangle className="w-4 h-4 text-yellow-400 mt-0.5 flex-shrink-0" />
<p className="text-sm text-yellow-400">
Bambu Cloud login required. Sign in under Profiles → Cloud Profiles to enable GitHub backup.
{t('backup.cloudLoginRequired')}
</p>
</div>
) : (
<>
<p className="text-sm text-bambu-gray">
Automatically sync your profiles to a private GitHub repository for backup and version history.
{t('backup.githubDescription')}
</p>
{/* Repository URL */}
<div>
<label className="block text-sm text-bambu-gray mb-1">
Repository URL
{t('backup.repositoryUrl')}
</label>
<input
type="text"
@@ -426,24 +426,24 @@ export function GitHubBackupSettings() {
{/* Access Token */}
<div>
<label className="block text-sm text-bambu-gray mb-1">
Personal Access Token {config?.has_token && <span className="text-green-400">(saved)</span>}
{t('backup.personalAccessToken')} {config?.has_token && <span className="text-green-400">{t('backup.tokenSaved')}</span>}
</label>
<input
type="password"
value={accessToken}
onChange={(e) => { setAccessToken(e.target.value); setTestResult(null); }}
placeholder={config?.has_token ? 'Enter new token to update' : 'ghp_xxxxxxxxxxxx'}
placeholder={config?.has_token ? t('backup.enterNewToken') : 'ghp_xxxxxxxxxxxx'}
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"
/>
<p className="text-xs text-bambu-gray mt-1">
Fine-grained token with Contents read/write permission
{t('backup.tokenHint')}
</p>
</div>
{/* Branch - inline with schedule */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-bambu-gray mb-1">Branch</label>
<label className="block text-sm text-bambu-gray mb-1">{t('backup.branch')}</label>
<input
type="text"
value={branch}
@@ -453,7 +453,7 @@ export function GitHubBackupSettings() {
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">Auto-backup</label>
<label className="block text-sm text-bambu-gray mb-1">{t('backup.autoBackup')}</label>
<select
value={scheduleEnabled ? scheduleType : 'disabled'}
onChange={(e) => {
@@ -466,17 +466,17 @@ export function GitHubBackupSettings() {
}}
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"
>
<option value="disabled">Manual only</option>
<option value="hourly">Hourly</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="disabled">{t('backup.manualOnly')}</option>
<option value="hourly">{t('backup.hourly')}</option>
<option value="daily">{t('backup.daily')}</option>
<option value="weekly">{t('backup.weekly')}</option>
</select>
</div>
</div>
{/* What to backup */}
<div>
<label className="block text-sm text-bambu-gray mb-2">Include in backup</label>
<label className="block text-sm text-bambu-gray mb-2">{t('backup.includeInBackup')}</label>
<div className="space-y-2">
<label className={`flex items-start gap-2 ${noPrintersConnected ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>
<input
@@ -488,21 +488,21 @@ export function GitHubBackupSettings() {
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className={`text-sm ${noPrintersConnected ? 'text-bambu-gray' : 'text-white'}`}>K-Profiles</span>
<span className={`text-sm ${noPrintersConnected ? 'text-bambu-gray' : 'text-white'}`}>{t('backup.kProfiles')}</span>
{noPrintersConnected && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-yellow-500/20 text-yellow-400">
<AlertTriangle className="w-3 h-3" />
No printers connected
{t('backup.noPrintersConnected')}
</span>
)}
{somePrintersDisconnected && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-yellow-500/20 text-yellow-400">
<AlertTriangle className="w-3 h-3" />
{connectedPrinters}/{totalPrinters} connected
{t('backup.printersConnected', { connected: connectedPrinters, total: totalPrinters })}
</span>
)}
</div>
<p className="text-xs text-bambu-gray">Pressure advance calibration from connected printers</p>
<p className="text-xs text-bambu-gray">{t('backup.kProfilesDescription')}</p>
</div>
</label>
<label className="flex items-start gap-2 cursor-pointer">
@@ -514,8 +514,8 @@ export function GitHubBackupSettings() {
disabled={!cloudStatus?.is_authenticated}
/>
<div>
<span className={`text-sm ${cloudStatus?.is_authenticated ? 'text-white' : 'text-bambu-gray'}`}>Cloud Profiles</span>
<p className="text-xs text-bambu-gray">Filament, printer, and process presets from Bambu Cloud</p>
<span className={`text-sm ${cloudStatus?.is_authenticated ? 'text-white' : 'text-bambu-gray'}`}>{t('backup.cloudProfiles')}</span>
<p className="text-xs text-bambu-gray">{t('backup.cloudProfilesDescription')}</p>
</div>
</label>
<label className="flex items-start gap-2 cursor-pointer">
@@ -526,8 +526,8 @@ export function GitHubBackupSettings() {
className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
/>
<div>
<span className="text-white text-sm">App Settings</span>
<p className="text-xs text-bambu-gray">Bambuddy configuration (complete database)</p>
<span className="text-white text-sm">{t('backup.appSettings')}</span>
<p className="text-xs text-bambu-gray">{t('backup.appSettingsDescription')}</p>
</div>
</label>
</div>
@@ -541,17 +541,17 @@ export function GitHubBackupSettings() {
<div className="flex items-center gap-2 text-bambu-gray">
{status.last_backup_at ? (
<>
<span>Last backup: {formatRelativeTime(status.last_backup_at, 'system', t)}</span>
<span>{t('backup.lastBackupAt')} {formatRelativeTime(status.last_backup_at, 'system', t)}</span>
<StatusBadge status={status.last_backup_status} />
</>
) : (
<span>No backups yet</span>
<span>{t('backup.noBackupsYet')}</span>
)}
</div>
{status.next_scheduled_run && (
<span className="text-bambu-gray">
<Clock className="w-3 h-3 inline mr-1" />
Next: {formatRelativeTime(status.next_scheduled_run, 'system', t)}
{t('backup.next')} {formatRelativeTime(status.next_scheduled_run, 'system', t)}
</span>
)}
</div>
@@ -572,7 +572,7 @@ export function GitHubBackupSettings() {
{(triggerBackupMutation.isPending || status.is_running) ? (
<div className="flex items-center gap-2 text-bambu-green">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-sm">{status.progress || 'Starting backup...'}</span>
<span className="text-sm">{status.progress || t('backup.startingBackup')}</span>
</div>
) : (
<>
@@ -583,7 +583,7 @@ export function GitHubBackupSettings() {
disabled={!config?.enabled}
>
<Play className="w-4 h-4" />
Backup Now
{t('backup.backupNow')}
</Button>
<Button
variant="secondary"
@@ -592,7 +592,7 @@ export function GitHubBackupSettings() {
disabled={testLoading}
>
{testLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
Test
{t('backup.test')}
</Button>
</>
)}
@@ -606,7 +606,7 @@ export function GitHubBackupSettings() {
disabled={saveConfigMutation.isPending || !repoUrl || !accessToken}
>
{saveConfigMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <CheckCircle className="w-4 h-4" />}
Enable Backup
{t('backup.enableBackup')}
</Button>
<Button
variant="secondary"
@@ -615,7 +615,7 @@ export function GitHubBackupSettings() {
disabled={testLoading || !repoUrl || !accessToken}
>
{testLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
Test Connection
{t('backup.testConnection')}
</Button>
</>
)}
@@ -633,7 +633,7 @@ export function GitHubBackupSettings() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<History className="w-5 h-5 text-gray-400" />
<h2 className="text-lg font-semibold text-white">History</h2>
<h2 className="text-lg font-semibold text-white">{t('backup.history')}</h2>
</div>
<Button
variant="ghost"
@@ -642,7 +642,7 @@ export function GitHubBackupSettings() {
disabled={clearLogsMutation.isPending}
>
<Trash2 className="w-4 h-4" />
Clear
{t('backup.clear')}
</Button>
</div>
</CardHeader>
@@ -651,9 +651,9 @@ export function GitHubBackupSettings() {
<table className="w-full text-sm">
<thead>
<tr className="text-bambu-gray border-b border-bambu-dark-tertiary">
<th className="text-left py-2 px-2">Date</th>
<th className="text-left py-2 px-2">Status</th>
<th className="text-left py-2 px-2">Commit</th>
<th className="text-left py-2 px-2">{t('backup.date')}</th>
<th className="text-left py-2 px-2">{t('backup.status')}</th>
<th className="text-left py-2 px-2">{t('backup.commit')}</th>
</tr>
</thead>
<tbody>
@@ -692,20 +692,20 @@ export function GitHubBackupSettings() {
<CardHeader>
<div className="flex items-center gap-2">
<Database className="w-5 h-5 text-gray-400" />
<h2 className="text-lg font-semibold text-white">Local Backup</h2>
<h2 className="text-lg font-semibold text-white">{t('backup.localBackup')}</h2>
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-bambu-gray">
Create a complete backup of your Bambuddy data including the database, archives, uploads, and all files.
{t('backup.localBackupDescription')}
</p>
{/* Export */}
<div className="flex items-center justify-between py-3 border-b border-bambu-dark-tertiary">
<div>
<p className="text-white">Download Backup</p>
<p className="text-white">{t('backup.downloadBackupLabel')}</p>
<p className="text-sm text-bambu-gray">
Complete backup: database + all files (ZIP)
{t('backup.completeBackupZip')}
</p>
</div>
<Button
@@ -714,20 +714,20 @@ export function GitHubBackupSettings() {
disabled={isExporting || isRestoring}
onClick={async () => {
setIsExporting(true);
setOperationStatus('Preparing backup...');
setOperationStatus(t('backup.preparingBackup'));
try {
setOperationStatus('Creating backup archive... This may take a while for large archives.');
setOperationStatus(t('backup.creatingArchive'));
const { blob, filename } = await api.exportBackup();
setOperationStatus('Downloading backup file...');
setOperationStatus(t('backup.downloadingFile'));
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
showToast('Backup downloaded successfully');
showToast(t('backup.backupDownloaded'));
} catch (e) {
showToast(`Failed to create backup: ${e instanceof Error ? e.message : 'Unknown error'}`, 'error');
showToast(t('backup.failedToCreateBackup', { message: e instanceof Error ? e.message : 'Unknown error' }), 'error');
} finally {
setIsExporting(false);
setOperationStatus('');
@@ -735,7 +735,7 @@ export function GitHubBackupSettings() {
}}
>
<Download className="w-4 h-4" />
Download
{t('backup.download')}
</Button>
</div>
@@ -771,7 +771,7 @@ export function GitHubBackupSettings() {
onClick={() => fileInputRef.current?.click()}
>
<Upload className="w-4 h-4" />
Restore
{t('backup.restore')}
</Button>
</div>
@@ -793,7 +793,7 @@ export function GitHubBackupSettings() {
onClick={() => window.location.reload()}
>
<RotateCcw className="w-3 h-3" />
Reload Now
{t('backup.reloadNow')}
</Button>
</div>
)}
@@ -807,8 +807,8 @@ export function GitHubBackupSettings() {
<div className="flex items-start gap-2 text-sm">
<AlertTriangle className="w-4 h-4 text-yellow-400 mt-0.5 flex-shrink-0" />
<div className="text-yellow-200">
<span className="font-medium">Restore replaces all data.</span>{' '}
<span className="text-yellow-200/70">Your current database and files will be completely replaced. A restart is required after restore.</span>
<span className="font-medium">{t('backup.restoreReplacesAll')}</span>{' '}
<span className="text-yellow-200/70">{t('backup.restoreReplacesAllDetail')}</span>
</div>
</div>
</div>
@@ -819,25 +819,25 @@ export function GitHubBackupSettings() {
{/* Restore Confirmation Modal */}
{showRestoreConfirm && restoreFile && (
<ConfirmModal
title="Restore Backup"
message={`Are you sure you want to restore from "${restoreFile.name}"? This will completely replace your current database and all files. The application will need to be restarted after restore.`}
confirmText="Restore Backup"
title={t('backup.restoreConfirmTitle')}
message={t('backup.restoreConfirmMessage', { filename: restoreFile.name })}
confirmText={t('backup.restoreConfirmButton')}
variant="danger"
onConfirm={async () => {
setShowRestoreConfirm(false);
setIsRestoring(true);
setRestoreResult(null);
try {
setOperationStatus('Uploading backup file...');
setOperationStatus(t('backup.uploadingFile'));
const result = await api.importBackup(restoreFile);
setRestoreResult(result);
if (result.success) {
showToast('Backup restored. Please restart Bambuddy.', 'success');
showToast(t('backup.backupRestoredRestart'), 'success');
} else {
showToast(result.message, 'error');
}
} catch (e) {
const message = e instanceof Error ? e.message : 'Failed to restore backup';
const message = e instanceof Error ? e.message : t('backup.failedToRestore');
setRestoreResult({ success: false, message });
showToast(message, 'error');
} finally {
@@ -864,16 +864,16 @@ export function GitHubBackupSettings() {
</div>
</div>
<h3 className="text-xl font-semibold text-white mb-2">
{isExporting ? 'Creating Backup' : 'Restoring Backup'}
{isExporting ? t('backup.creatingBackup') : t('backup.restoringBackup')}
</h3>
<p className="text-bambu-gray mb-4">
{operationStatus || (isExporting ? 'Preparing...' : 'Processing...')}
{operationStatus || (isExporting ? t('backup.preparing') : t('backup.processing'))}
</p>
<div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
<div className="flex items-start gap-2 text-sm">
<AlertTriangle className="w-4 h-4 text-yellow-400 mt-0.5 flex-shrink-0" />
<p className="text-yellow-200 text-left">
Please do not close this page or navigate away. This operation may take several minutes for large backups.
{t('backup.doNotClosePage')}
</p>
</div>
</div>
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { History, CheckCircle, XCircle, Loader2, Trash2, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
import { api } from '../api/client';
import { parseUTCDate, formatTimeOnly, formatDateTime, type TimeFormat } from '../utils/date';
@@ -7,19 +8,6 @@ import type { NotificationLogEntry } from '../api/client';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
const EVENT_LABELS: Record<string, string> = {
print_start: 'Print Started',
print_complete: 'Print Complete',
print_failed: 'Print Failed',
print_stopped: 'Print Stopped',
print_progress: 'Progress',
printer_offline: 'Printer Offline',
printer_error: 'Printer Error',
filament_low: 'Low Filament',
maintenance_due: 'Maintenance Due',
test: 'Test',
};
const EVENT_COLORS: Record<string, string> = {
print_start: 'text-blue-400',
print_complete: 'text-bambu-green',
@@ -38,6 +26,7 @@ interface NotificationLogViewerProps {
}
export function NotificationLogViewer({ onClose }: NotificationLogViewerProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
const [days, setDays] = useState(7);
@@ -83,7 +72,7 @@ export function NotificationLogViewer({ onClose }: NotificationLogViewerProps) {
const now = new Date();
const diff = now.getTime() - date.getTime();
if (diff < 60000) return 'Just now';
if (diff < 60000) return t('notifications.justNow');
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
@@ -97,7 +86,7 @@ export function NotificationLogViewer({ onClose }: NotificationLogViewerProps) {
<div className="p-4 border-b border-bambu-dark-tertiary flex items-center justify-between">
<div className="flex items-center gap-3">
<History className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">Notification Log</h2>
<h2 className="text-lg font-semibold text-white">{t('notifications.notificationLog')}</h2>
</div>
<button
onClick={onClose}
@@ -112,16 +101,16 @@ export function NotificationLogViewer({ onClose }: NotificationLogViewerProps) {
<div className="px-4 py-3 border-b border-bambu-dark-tertiary bg-bambu-dark/50">
<div className="flex items-center gap-6 text-sm">
<span className="text-bambu-gray">
Last {days} days: <span className="text-white font-medium">{stats.total}</span> notifications
{t('notifications.statsSummary', { days })} <span className="text-white font-medium">{stats.total}</span> {t('notifications.statsNotifications')}
</span>
<span className="flex items-center gap-1 text-bambu-green">
<CheckCircle className="w-4 h-4" />
{stats.success_count} sent
{t('notifications.statsSent', { count: stats.success_count })}
</span>
{stats.failure_count > 0 && (
<span className="flex items-center gap-1 text-red-400">
<XCircle className="w-4 h-4" />
{stats.failure_count} failed
{t('notifications.statsFailed', { count: stats.failure_count })}
</span>
)}
</div>
@@ -135,10 +124,10 @@ export function NotificationLogViewer({ onClose }: NotificationLogViewerProps) {
onChange={(e) => setDays(Number(e.target.value))}
className="px-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:ring-1 focus:ring-bambu-green"
>
<option value={1}>Last 24 hours</option>
<option value={7}>Last 7 days</option>
<option value={30}>Last 30 days</option>
<option value={90}>Last 90 days</option>
<option value={1}>{t('notifications.last24Hours')}</option>
<option value={7}>{t('notifications.last7Days')}</option>
<option value={30}>{t('notifications.last30Days')}</option>
<option value={90}>{t('notifications.last90Days')}</option>
</select>
<label className="flex items-center gap-2 text-sm text-bambu-gray cursor-pointer">
@@ -148,7 +137,7 @@ export function NotificationLogViewer({ onClose }: NotificationLogViewerProps) {
onChange={(e) => setShowFailedOnly(e.target.checked)}
className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
/>
Show failed only
{t('notifications.showFailedOnly')}
</label>
<div className="flex-1" />
@@ -164,7 +153,7 @@ export function NotificationLogViewer({ onClose }: NotificationLogViewerProps) {
) : (
<RefreshCw className="w-4 h-4" />
)}
Refresh
{t('notifications.refresh')}
</Button>
<Button
@@ -179,7 +168,7 @@ export function NotificationLogViewer({ onClose }: NotificationLogViewerProps) {
) : (
<Trash2 className="w-4 h-4" />
)}
Clear Old
{t('notifications.clearOld')}
</Button>
</div>
@@ -206,7 +195,7 @@ export function NotificationLogViewer({ onClose }: NotificationLogViewerProps) {
<div className="text-center py-12 text-bambu-gray">
<History className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="text-sm">
{showFailedOnly ? 'No failed notifications' : 'No notifications logged'}
{showFailedOnly ? t('notifications.noFailedNotifications') : t('notifications.noNotificationsLogged')}
</p>
</div>
)}
@@ -229,6 +218,7 @@ function LogEntry({
formatDate: (date: string) => string;
formatFullDate: (date: string) => string;
}) {
const { t } = useTranslation();
return (
<div
className={`border rounded-lg overflow-hidden transition-colors ${
@@ -248,11 +238,11 @@ function LogEntry({
)}
<span className={`text-xs font-medium ${EVENT_COLORS[log.event_type] || 'text-bambu-gray'}`}>
{EVENT_LABELS[log.event_type] || log.event_type}
{t(`notifications.eventTypes.${log.event_type}`, log.event_type)}
</span>
<span className="text-sm text-white truncate flex-1">
{log.provider_name || 'Unknown Provider'}
{log.provider_name || t('notifications.unknownProvider')}
</span>
{log.printer_name && (
@@ -275,22 +265,22 @@ function LogEntry({
{isExpanded && (
<div className="px-3 py-2 border-t border-bambu-dark-tertiary bg-bambu-dark/20 space-y-2">
<div>
<p className="text-xs text-bambu-gray mb-1">Title</p>
<p className="text-xs text-bambu-gray mb-1">{t('notifications.logTitle')}</p>
<p className="text-sm text-white">{log.title}</p>
</div>
<div>
<p className="text-xs text-bambu-gray mb-1">Message</p>
<p className="text-xs text-bambu-gray mb-1">{t('notifications.logMessage')}</p>
<p className="text-sm text-white whitespace-pre-wrap">{log.message}</p>
</div>
{!log.success && log.error_message && (
<div>
<p className="text-xs text-red-400 mb-1">Error</p>
<p className="text-xs text-red-400 mb-1">{t('notifications.logError')}</p>
<p className="text-sm text-red-300">{log.error_message}</p>
</div>
)}
<div className="flex gap-4 text-xs text-bambu-gray pt-1">
<span>Provider: {log.provider_type}</span>
<span>Time: {formatFullDate(log.created_at)}</span>
<span>{t('notifications.logProvider', { type: log.provider_type })}</span>
<span>{t('notifications.logTime', { time: formatFullDate(log.created_at) })}</span>
</div>
</div>
)}
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Bell, Trash2, Settings2, Edit2, Send, Loader2, CheckCircle, XCircle, Moon, Clock, ChevronDown, ChevronUp, Calendar } from 'lucide-react';
import { api } from '../api/client';
import { formatDateOnly, parseUTCDate } from '../utils/date';
@@ -14,17 +15,8 @@ interface NotificationProviderCardProps {
onEdit: (provider: NotificationProvider) => void;
}
const PROVIDER_LABELS: Record<string, string> = {
callmebot: 'CallMeBot/WhatsApp',
ntfy: 'ntfy',
pushover: 'Pushover',
telegram: 'Telegram',
email: 'Email',
discord: 'Discord',
webhook: 'Webhook',
};
export function NotificationProviderCard({ provider, onEdit }: NotificationProviderCardProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
@@ -84,20 +76,20 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
</div>
<div>
<h3 className="font-medium text-white">{provider.name}</h3>
<p className="text-sm text-bambu-gray">{PROVIDER_LABELS[provider.provider_type] || provider.provider_type}</p>
<p className="text-sm text-bambu-gray">{t(`notifications.providerTypes.${provider.provider_type}`, provider.provider_type)}</p>
</div>
</div>
{/* Quick enable/disable toggle + Status indicator */}
<div className="flex items-center gap-3">
{provider.last_success && (
<span className="text-xs text-status-ok hidden sm:inline">Last: {formatDateOnly(provider.last_success)}</span>
<span className="text-xs text-status-ok hidden sm:inline">{t('notifications.lastSuccess', { date: formatDateOnly(provider.last_success) })}</span>
)}
{/* Only show error if it's more recent than last success */}
{provider.last_error && provider.last_error_at && (
!provider.last_success || (parseUTCDate(provider.last_error_at)?.getTime() || 0) > (parseUTCDate(provider.last_success)?.getTime() || 0)
) && (
<span className="text-xs text-status-error" title={provider.last_error}>Error</span>
<span className="text-xs text-status-error" title={provider.last_error}>{t('notifications.error')}</span>
)}
<Toggle
checked={provider.enabled}
@@ -109,73 +101,73 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
{/* Linked Printer */}
{linkedPrinter && (
<div className="mb-3 px-2 py-1.5 bg-bambu-dark rounded-lg">
<span className="text-xs text-bambu-gray">Printer: </span>
<span className="text-xs text-bambu-gray">{t('notifications.printer')} </span>
<span className="text-sm text-white">{linkedPrinter.name}</span>
</div>
)}
{!linkedPrinter && !provider.printer_id && (
<div className="mb-3 px-2 py-1.5 bg-bambu-dark rounded-lg">
<span className="text-xs text-bambu-gray">All printers</span>
<span className="text-xs text-bambu-gray">{t('notifications.allPrinters')}</span>
</div>
)}
{/* Event summary - show all event tags */}
<div className="mb-3 flex flex-wrap gap-1">
{provider.on_print_start && (
<span className="px-2 py-0.5 bg-blue-500/20 text-blue-400 text-xs rounded">Start</span>
<span className="px-2 py-0.5 bg-blue-500/20 text-blue-400 text-xs rounded">{t('notifications.start')}</span>
)}
{provider.on_plate_not_empty && (
<span className="px-2 py-0.5 bg-rose-600/20 text-rose-300 text-xs rounded">Plate Check</span>
<span className="px-2 py-0.5 bg-rose-600/20 text-rose-300 text-xs rounded">{t('notifications.plateCheck')}</span>
)}
{provider.on_print_complete && (
<span className="px-2 py-0.5 bg-bambu-green/20 text-bambu-green text-xs rounded">Complete</span>
<span className="px-2 py-0.5 bg-bambu-green/20 text-bambu-green text-xs rounded">{t('notifications.complete')}</span>
)}
{provider.on_print_failed && (
<span className="px-2 py-0.5 bg-red-500/20 text-red-400 text-xs rounded">Failed</span>
<span className="px-2 py-0.5 bg-red-500/20 text-red-400 text-xs rounded">{t('notifications.failed')}</span>
)}
{provider.on_print_stopped && (
<span className="px-2 py-0.5 bg-orange-500/20 text-orange-400 text-xs rounded">Stopped</span>
<span className="px-2 py-0.5 bg-orange-500/20 text-orange-400 text-xs rounded">{t('notifications.stopped')}</span>
)}
{provider.on_print_progress && (
<span className="px-2 py-0.5 bg-yellow-500/20 text-yellow-400 text-xs rounded">Progress</span>
<span className="px-2 py-0.5 bg-yellow-500/20 text-yellow-400 text-xs rounded">{t('notifications.progress')}</span>
)}
{provider.on_printer_offline && (
<span className="px-2 py-0.5 bg-gray-500/20 text-gray-400 text-xs rounded">Offline</span>
<span className="px-2 py-0.5 bg-gray-500/20 text-gray-400 text-xs rounded">{t('notifications.offline')}</span>
)}
{provider.on_printer_error && (
<span className="px-2 py-0.5 bg-rose-500/20 text-rose-400 text-xs rounded">Error</span>
<span className="px-2 py-0.5 bg-rose-500/20 text-rose-400 text-xs rounded">{t('notifications.error')}</span>
)}
{provider.on_filament_low && (
<span className="px-2 py-0.5 bg-cyan-500/20 text-cyan-400 text-xs rounded">Low Filament</span>
<span className="px-2 py-0.5 bg-cyan-500/20 text-cyan-400 text-xs rounded">{t('notifications.lowFilament')}</span>
)}
{provider.on_maintenance_due && (
<span className="px-2 py-0.5 bg-purple-500/20 text-purple-400 text-xs rounded">Maintenance</span>
<span className="px-2 py-0.5 bg-purple-500/20 text-purple-400 text-xs rounded">{t('notifications.maintenance')}</span>
)}
{provider.on_ams_humidity_high && (
<span className="px-2 py-0.5 bg-blue-600/20 text-blue-300 text-xs rounded">AMS Humidity</span>
<span className="px-2 py-0.5 bg-blue-600/20 text-blue-300 text-xs rounded">{t('notifications.amsHumidity')}</span>
)}
{provider.on_ams_temperature_high && (
<span className="px-2 py-0.5 bg-orange-600/20 text-orange-300 text-xs rounded">AMS Temp</span>
<span className="px-2 py-0.5 bg-orange-600/20 text-orange-300 text-xs rounded">{t('notifications.amsTemp')}</span>
)}
{provider.on_ams_ht_humidity_high && (
<span className="px-2 py-0.5 bg-cyan-600/20 text-cyan-300 text-xs rounded">AMS-HT Humidity</span>
<span className="px-2 py-0.5 bg-cyan-600/20 text-cyan-300 text-xs rounded">{t('notifications.amsHtHumidity')}</span>
)}
{provider.on_ams_ht_temperature_high && (
<span className="px-2 py-0.5 bg-amber-600/20 text-amber-300 text-xs rounded">AMS-HT Temp</span>
<span className="px-2 py-0.5 bg-amber-600/20 text-amber-300 text-xs rounded">{t('notifications.amsHtTemp')}</span>
)}
{provider.on_bed_cooled && (
<span className="px-2 py-0.5 bg-teal-500/20 text-teal-400 text-xs rounded">Bed Cooled</span>
<span className="px-2 py-0.5 bg-teal-500/20 text-teal-400 text-xs rounded">{t('notifications.bedCooled')}</span>
)}
{provider.quiet_hours_enabled && (
<span className="px-2 py-0.5 bg-indigo-500/20 text-indigo-400 text-xs rounded flex items-center gap-1">
<Moon className="w-3 h-3" />
Quiet
{t('notifications.quiet')}
</span>
)}
{provider.daily_digest_enabled && (
<span className="px-2 py-0.5 bg-emerald-500/20 text-emerald-400 text-xs rounded flex items-center gap-1">
<Calendar className="w-3 h-3" />
Digest {provider.daily_digest_time}
{t('notifications.digest', { time: provider.daily_digest_time })}
</span>
)}
</div>
@@ -197,7 +189,7 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
) : (
<Send className="w-4 h-4" />
)}
Send Test Notification
{t('notifications.sendTestNotification')}
</Button>
</div>
@@ -224,7 +216,7 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
>
<span className="flex items-center gap-2">
<Settings2 className="w-4 h-4" />
Event Settings
{t('notifications.eventSettings')}
</span>
{isExpanded ? (
<ChevronUp className="w-4 h-4" />
@@ -239,8 +231,8 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
{/* Enabled Toggle */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Enabled</p>
<p className="text-xs text-bambu-gray">Send notifications from this provider</p>
<p className="text-sm text-white">{t('notifications.enabled')}</p>
<p className="text-xs text-bambu-gray">{t('notifications.sendFromProvider')}</p>
</div>
<Toggle
checked={provider.enabled}
@@ -250,10 +242,10 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
{/* Print Lifecycle Events */}
<div className="space-y-2">
<p className="text-xs text-bambu-gray uppercase tracking-wide">Print Events</p>
<p className="text-xs text-bambu-gray uppercase tracking-wide">{t('notifications.printEvents')}</p>
<div className="flex items-center justify-between">
<p className="text-sm text-white">Print Started</p>
<p className="text-sm text-white">{t('notifications.printStarted')}</p>
<Toggle
checked={provider.on_print_start}
onChange={(checked) => updateMutation.mutate({ on_print_start: checked })}
@@ -262,8 +254,8 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Plate Not Empty</p>
<p className="text-xs text-bambu-gray">Objects detected before print</p>
<p className="text-sm text-white">{t('notifications.plateNotEmpty')}</p>
<p className="text-xs text-bambu-gray">{t('notifications.plateNotEmptyDescription')}</p>
</div>
<Toggle
checked={provider.on_plate_not_empty ?? true}
@@ -272,7 +264,7 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-white">Print Completed</p>
<p className="text-sm text-white">{t('notifications.printCompleted')}</p>
<Toggle
checked={provider.on_print_complete}
onChange={(checked) => updateMutation.mutate({ on_print_complete: checked })}
@@ -281,8 +273,8 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Bed Cooled</p>
<p className="text-xs text-bambu-gray">Bed cooled below threshold after print</p>
<p className="text-sm text-white">{t('notifications.bedCooledLabel')}</p>
<p className="text-xs text-bambu-gray">{t('notifications.bedCooledDescription')}</p>
</div>
<Toggle
checked={provider.on_bed_cooled ?? false}
@@ -291,7 +283,7 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-white">Print Failed</p>
<p className="text-sm text-white">{t('notifications.printFailed')}</p>
<Toggle
checked={provider.on_print_failed}
onChange={(checked) => updateMutation.mutate({ on_print_failed: checked })}
@@ -299,7 +291,7 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-white">Print Stopped</p>
<p className="text-sm text-white">{t('notifications.printStopped')}</p>
<Toggle
checked={provider.on_print_stopped}
onChange={(checked) => updateMutation.mutate({ on_print_stopped: checked })}
@@ -308,8 +300,8 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Progress Milestones</p>
<p className="text-xs text-bambu-gray">Notify at 25%, 50%, 75%</p>
<p className="text-sm text-white">{t('notifications.progressMilestones')}</p>
<p className="text-xs text-bambu-gray">{t('notifications.progressMilestonesDescription')}</p>
</div>
<Toggle
checked={provider.on_print_progress}
@@ -320,10 +312,10 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
{/* Printer Status Events */}
<div className="space-y-2">
<p className="text-xs text-bambu-gray uppercase tracking-wide">Printer Status</p>
<p className="text-xs text-bambu-gray uppercase tracking-wide">{t('notifications.printerStatus')}</p>
<div className="flex items-center justify-between">
<p className="text-sm text-white">Printer Offline</p>
<p className="text-sm text-white">{t('notifications.printerOffline')}</p>
<Toggle
checked={provider.on_printer_offline}
onChange={(checked) => updateMutation.mutate({ on_printer_offline: checked })}
@@ -331,7 +323,7 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-white">Printer Error</p>
<p className="text-sm text-white">{t('notifications.printerError')}</p>
<Toggle
checked={provider.on_printer_error}
onChange={(checked) => updateMutation.mutate({ on_printer_error: checked })}
@@ -339,8 +331,8 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-white">Low Filament</p>
<Toggle
<p className="text-sm text-white">{t('notifications.lowFilamentLabel')}</p>
<Toggle
checked={provider.on_filament_low}
onChange={(checked) => updateMutation.mutate({ on_filament_low: checked })}
/>
@@ -348,8 +340,8 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Maintenance Due</p>
<p className="text-xs text-bambu-gray">Notify when maintenance is needed</p>
<p className="text-sm text-white">{t('notifications.maintenanceDue')}</p>
<p className="text-xs text-bambu-gray">{t('notifications.maintenanceDueDescription')}</p>
</div>
<Toggle
checked={provider.on_maintenance_due ?? false}
@@ -360,12 +352,12 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
{/* AMS Environmental Alarms (regular AMS) */}
<div className="space-y-2">
<p className="text-xs text-bambu-gray uppercase tracking-wide">AMS Alarms</p>
<p className="text-xs text-bambu-gray uppercase tracking-wide">{t('notifications.amsAlarms')}</p>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">AMS Humidity High</p>
<p className="text-xs text-bambu-gray">Regular AMS humidity exceeds threshold</p>
<p className="text-sm text-white">{t('notifications.amsHumidityHigh')}</p>
<p className="text-xs text-bambu-gray">{t('notifications.amsHumidityHighDescription')}</p>
</div>
<Toggle
checked={provider.on_ams_humidity_high ?? false}
@@ -375,8 +367,8 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">AMS Temperature High</p>
<p className="text-xs text-bambu-gray">Regular AMS temperature exceeds threshold</p>
<p className="text-sm text-white">{t('notifications.amsTemperatureHigh')}</p>
<p className="text-xs text-bambu-gray">{t('notifications.amsTemperatureHighDescription')}</p>
</div>
<Toggle
checked={provider.on_ams_temperature_high ?? false}
@@ -387,12 +379,12 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
{/* AMS-HT Environmental Alarms */}
<div className="space-y-2">
<p className="text-xs text-bambu-gray uppercase tracking-wide">AMS-HT Alarms</p>
<p className="text-xs text-bambu-gray uppercase tracking-wide">{t('notifications.amsHtAlarms')}</p>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">AMS-HT Humidity High</p>
<p className="text-xs text-bambu-gray">AMS-HT humidity exceeds threshold</p>
<p className="text-sm text-white">{t('notifications.amsHtHumidityHigh')}</p>
<p className="text-xs text-bambu-gray">{t('notifications.amsHtHumidityHighDescription')}</p>
</div>
<Toggle
checked={provider.on_ams_ht_humidity_high ?? false}
@@ -402,8 +394,8 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">AMS-HT Temperature High</p>
<p className="text-xs text-bambu-gray">AMS-HT temperature exceeds threshold</p>
<p className="text-sm text-white">{t('notifications.amsHtTemperatureHigh')}</p>
<p className="text-xs text-bambu-gray">{t('notifications.amsHtTemperatureHighDescription')}</p>
</div>
<Toggle
checked={provider.on_ams_ht_temperature_high ?? false}
@@ -414,12 +406,12 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
{/* Print Queue Events */}
<div className="space-y-2">
<p className="text-xs text-bambu-gray uppercase tracking-wide">Print Queue</p>
<p className="text-xs text-bambu-gray uppercase tracking-wide">{t('notifications.printQueue')}</p>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Job Added</p>
<p className="text-xs text-bambu-gray">Job added to queue</p>
<p className="text-sm text-white">{t('notifications.jobAdded')}</p>
<p className="text-xs text-bambu-gray">{t('notifications.jobAddedDescription')}</p>
</div>
<Toggle
checked={provider.on_queue_job_added ?? false}
@@ -429,8 +421,8 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Job Assigned</p>
<p className="text-xs text-bambu-gray">Model-based job assigned to printer</p>
<p className="text-sm text-white">{t('notifications.jobAssigned')}</p>
<p className="text-xs text-bambu-gray">{t('notifications.jobAssignedDescription')}</p>
</div>
<Toggle
checked={provider.on_queue_job_assigned ?? false}
@@ -440,8 +432,8 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Job Started</p>
<p className="text-xs text-bambu-gray">Queue job started printing</p>
<p className="text-sm text-white">{t('notifications.jobStarted')}</p>
<p className="text-xs text-bambu-gray">{t('notifications.jobStartedDescription')}</p>
</div>
<Toggle
checked={provider.on_queue_job_started ?? false}
@@ -451,8 +443,8 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Job Waiting</p>
<p className="text-xs text-bambu-gray">Job waiting for filament</p>
<p className="text-sm text-white">{t('notifications.jobWaiting')}</p>
<p className="text-xs text-bambu-gray">{t('notifications.jobWaitingDescription')}</p>
</div>
<Toggle
checked={provider.on_queue_job_waiting ?? true}
@@ -462,8 +454,8 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Job Skipped</p>
<p className="text-xs text-bambu-gray">Job skipped (previous failed)</p>
<p className="text-sm text-white">{t('notifications.jobSkipped')}</p>
<p className="text-xs text-bambu-gray">{t('notifications.jobSkippedDescription')}</p>
</div>
<Toggle
checked={provider.on_queue_job_skipped ?? true}
@@ -473,8 +465,8 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Job Failed</p>
<p className="text-xs text-bambu-gray">Job failed to start</p>
<p className="text-sm text-white">{t('notifications.jobFailed')}</p>
<p className="text-xs text-bambu-gray">{t('notifications.jobFailedDescription')}</p>
</div>
<Toggle
checked={provider.on_queue_job_failed ?? true}
@@ -484,8 +476,8 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Queue Complete</p>
<p className="text-xs text-bambu-gray">All queue jobs finished</p>
<p className="text-sm text-white">{t('notifications.queueComplete')}</p>
<p className="text-xs text-bambu-gray">{t('notifications.queueCompleteDescription')}</p>
</div>
<Toggle
checked={provider.on_queue_completed ?? false}
@@ -499,7 +491,7 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Moon className="w-4 h-4 text-purple-400" />
<p className="text-sm text-white">Quiet Hours</p>
<p className="text-sm text-white">{t('notifications.quietHours')}</p>
</div>
<Toggle
checked={provider.quiet_hours_enabled}
@@ -509,14 +501,14 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
{provider.quiet_hours_enabled && (
<div className="pl-4 border-l-2 border-bambu-dark-tertiary space-y-2">
<p className="text-xs text-bambu-gray">No notifications during these hours</p>
<p className="text-xs text-bambu-gray">{t('notifications.noNotificationsDuring')}</p>
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-bambu-gray" />
<span className="text-sm text-white">
{formatTime(provider.quiet_hours_start) || '22:00'} - {formatTime(provider.quiet_hours_end) || '07:00'}
</span>
</div>
<p className="text-xs text-bambu-gray">Edit provider to change quiet hours</p>
<p className="text-xs text-bambu-gray">{t('notifications.editProviderToChangeQuietHours')}</p>
</div>
)}
</div>
@@ -526,7 +518,7 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-emerald-400" />
<p className="text-sm text-white">Daily Digest</p>
<p className="text-sm text-white">{t('notifications.dailyDigest')}</p>
</div>
<Toggle
checked={provider.daily_digest_enabled}
@@ -536,14 +528,14 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
{provider.daily_digest_enabled && (
<div className="pl-4 border-l-2 border-bambu-dark-tertiary space-y-2">
<p className="text-xs text-bambu-gray">Batch notifications into a single daily summary</p>
<p className="text-xs text-bambu-gray">{t('notifications.batchNotifications')}</p>
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-bambu-gray" />
<span className="text-sm text-white">
Send at {formatTime(provider.daily_digest_time) || '08:00'}
{t('notifications.sendAt', { time: formatTime(provider.daily_digest_time) || '08:00' })}
</span>
</div>
<p className="text-xs text-bambu-gray">Edit provider to change digest time</p>
<p className="text-xs text-bambu-gray">{t('notifications.editProviderToChangeDigestTime')}</p>
</div>
)}
</div>
@@ -557,7 +549,7 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
className="flex-1"
>
<Edit2 className="w-4 h-4" />
Edit
{t('notifications.edit')}
</Button>
<Button
size="sm"
@@ -576,9 +568,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
{/* Delete Confirmation */}
{showDeleteConfirm && (
<ConfirmModal
title="Delete Notification Provider"
message={`Are you sure you want to delete "${provider.name}"? This cannot be undone.`}
confirmText="Delete"
title={t('notifications.deleteProvider')}
message={t('notifications.deleteConfirm', { name: provider.name })}
confirmText={t('notifications.delete')}
variant="danger"
onConfirm={() => {
deleteMutation.mutate();
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Save, Loader2, RotateCcw, Plus, Eye } from 'lucide-react';
import { api } from '../api/client';
import type { NotificationTemplate, NotificationTemplateUpdate } from '../api/client';
@@ -11,6 +12,7 @@ interface NotificationTemplateEditorProps {
}
export function NotificationTemplateEditor({ template, onClose }: NotificationTemplateEditorProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const bodyRef = useRef<HTMLTextAreaElement>(null);
@@ -78,11 +80,11 @@ export function NotificationTemplateEditor({ template, onClose }: NotificationTe
setError(null);
if (!titleTemplate.trim()) {
setError('Title is required');
setError(t('notifications.titleRequired'));
return;
}
if (!bodyTemplate.trim()) {
setError('Body is required');
setError(t('notifications.bodyRequired'));
return;
}
@@ -121,7 +123,7 @@ export function NotificationTemplateEditor({ template, onClose }: NotificationTe
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary shrink-0">
<h2 className="text-lg font-semibold text-white">
Edit Template: {template.name}
{t('notifications.editTemplate', { name: template.name })}
</h2>
<button
onClick={onClose}
@@ -142,21 +144,21 @@ export function NotificationTemplateEditor({ template, onClose }: NotificationTe
{/* Title */}
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">
Title
{t('notifications.titleLabel')}
</label>
<input
type="text"
value={titleTemplate}
onChange={(e) => setTitleTemplate(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white focus:outline-none focus:ring-1 focus:ring-bambu-green"
placeholder="Notification title..."
placeholder={t('notifications.titlePlaceholder')}
/>
</div>
{/* Body */}
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">
Body
{t('notifications.bodyLabel')}
</label>
<textarea
ref={bodyRef}
@@ -164,7 +166,7 @@ export function NotificationTemplateEditor({ template, onClose }: NotificationTe
onChange={(e) => setBodyTemplate(e.target.value)}
rows={4}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white focus:outline-none focus:ring-1 focus:ring-bambu-green font-mono text-sm resize-none"
placeholder="Notification body..."
placeholder={t('notifications.bodyPlaceholder')}
/>
</div>
@@ -172,7 +174,7 @@ export function NotificationTemplateEditor({ template, onClose }: NotificationTe
{eventVariables && (
<div>
<label className="block text-sm font-medium text-bambu-gray mb-2">
Available Variables
{t('notifications.availableVariables')}
</label>
<div className="flex flex-wrap gap-2">
{eventVariables.variables.map((variable) => (
@@ -188,7 +190,7 @@ export function NotificationTemplateEditor({ template, onClose }: NotificationTe
))}
</div>
<p className="text-xs text-bambu-gray/60 mt-1">
Click to insert at cursor position in body
{t('notifications.clickToInsert')}
</p>
</div>
)}
@@ -198,14 +200,14 @@ export function NotificationTemplateEditor({ template, onClose }: NotificationTe
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-bambu-gray flex items-center gap-2">
<Eye className="w-4 h-4" />
Live Preview
{t('notifications.livePreview')}
</label>
<button
type="button"
onClick={() => setShowPreview(!showPreview)}
className="text-xs text-bambu-green hover:text-bambu-green-light"
>
{showPreview ? 'Hide' : 'Show'}
{showPreview ? t('notifications.hide') : t('notifications.show')}
</button>
</div>
{showPreview && (
@@ -213,22 +215,22 @@ export function NotificationTemplateEditor({ template, onClose }: NotificationTe
{previewLoading ? (
<div className="flex items-center gap-2 text-bambu-gray text-sm">
<Loader2 className="w-4 h-4 animate-spin" />
Loading preview...
{t('notifications.loadingPreview')}
</div>
) : preview ? (
<>
<div>
<span className="text-xs text-bambu-gray">Title:</span>
<span className="text-xs text-bambu-gray">{t('notifications.titlePreview')}</span>
<div className="text-white font-medium">{preview.title}</div>
</div>
<div>
<span className="text-xs text-bambu-gray">Body:</span>
<span className="text-xs text-bambu-gray">{t('notifications.bodyPreview')}</span>
<div className="text-white whitespace-pre-wrap text-sm">{preview.body}</div>
</div>
</>
) : (
<div className="text-bambu-gray text-sm">
Enter template content to see preview
{t('notifications.enterTemplateContent')}
</div>
)}
</div>
@@ -250,12 +252,12 @@ export function NotificationTemplateEditor({ template, onClose }: NotificationTe
) : (
<RotateCcw className="w-4 h-4 mr-2" />
)}
Reset to Default
{t('notifications.resetToDefault')}
</Button>
<div className="flex gap-2">
<Button type="button" variant="secondary" onClick={onClose}>
Cancel
{t('notifications.cancel')}
</Button>
<Button
onClick={handleSubmit}
@@ -266,7 +268,7 @@ export function NotificationTemplateEditor({ template, onClose }: NotificationTe
) : (
<Save className="w-4 h-4 mr-2" />
)}
Save
{t('notifications.save')}
</Button>
</div>
</div>
+37 -50
View File
@@ -1,4 +1,5 @@
import { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Upload, X, AlertTriangle, CheckCircle, SkipForward, RefreshCw, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
import { Card, CardContent } from './Card';
import { Button } from './Button';
@@ -23,22 +24,8 @@ interface RestoreModalProps {
type ModalState = 'options' | 'restoring' | 'result';
const CATEGORY_LABELS: Record<string, string> = {
settings: 'Settings',
notification_providers: 'Notification Providers',
notification_templates: 'Notification Templates',
smart_plugs: 'Smart Plugs',
printers: 'Printers',
filaments: 'Filaments',
maintenance_types: 'Maintenance Types',
archives: 'Archives',
projects: 'Projects',
pending_uploads: 'Pending Uploads',
external_links: 'External Links',
api_keys: 'API Keys',
};
export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProps) {
const { t } = useTranslation();
const [state, setState] = useState<ModalState>('options');
const [overwrite, setOverwrite] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
@@ -80,7 +67,7 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
} catch {
setResult({
success: false,
message: 'Failed to restore backup. Please check the file format.',
message: t('backup.failedToRestore'),
});
setState('result');
}
@@ -143,13 +130,13 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
</div>
<div>
<h3 className="text-lg font-semibold text-white">
{state === 'options' && 'Restore Backup'}
{state === 'restoring' && 'Restoring...'}
{state === 'result' && (result?.success ? 'Restore Complete' : 'Restore Failed')}
{state === 'options' && t('backup.restoreBackup')}
{state === 'restoring' && t('backup.restoring')}
{state === 'result' && (result?.success ? t('backup.restoreComplete') : t('backup.restoreFailed2'))}
</h3>
<p className="text-sm text-bambu-gray">
{state === 'options' && 'Import settings from a backup file'}
{state === 'restoring' && 'Please wait while your data is being restored'}
{state === 'options' && t('backup.importSettings')}
{state === 'restoring' && t('backup.pleaseWaitRestoring')}
{state === 'result' && result?.message}
</p>
</div>
@@ -194,7 +181,7 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
) : (
<div className="flex flex-col items-center gap-2 text-bambu-gray">
<Upload className="w-8 h-8" />
<span>Click to select backup file (.json or .zip)</span>
<span>{t('backup.selectBackupFile')}</span>
</div>
)}
</button>
@@ -205,15 +192,15 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
<div className="flex items-start gap-2 text-sm">
<AlertTriangle className="w-4 h-4 text-blue-500 dark:text-blue-400 mt-0.5 flex-shrink-0" />
<div className="text-blue-700 dark:text-blue-200">
<p className="font-medium mb-1">How duplicate handling works:</p>
<p className="font-medium mb-1">{t('backup.duplicateHandling')}</p>
<ul className="text-blue-600 dark:text-blue-200/80 space-y-1 text-xs">
<li><strong>Printers</strong> - matched by serial number</li>
<li><strong>Smart Plugs</strong> - matched by IP address</li>
<li><strong>Notification Providers</strong> - matched by name</li>
<li><strong>Filaments</strong> - matched by name + type + brand</li>
<li><strong>Archives</strong> - matched by content hash (always skipped)</li>
<li><strong>Pending Uploads</strong> - matched by filename</li>
<li><strong>Settings & Templates</strong> - always overwritten</li>
<li><strong>{t('backup.matchPrinters')}</strong> - {t('backup.matchPrintersBy')}</li>
<li><strong>{t('backup.matchSmartPlugs')}</strong> - {t('backup.matchSmartPlugsBy')}</li>
<li><strong>{t('backup.matchNotificationProviders')}</strong> - {t('backup.matchNotificationProvidersBy')}</li>
<li><strong>{t('backup.matchFilaments')}</strong> - {t('backup.matchFilamentsBy')}</li>
<li><strong>{t('backup.matchArchives')}</strong> - {t('backup.matchArchivesBy')}</li>
<li><strong>{t('backup.matchPendingUploads')}</strong> - {t('backup.matchPendingUploadsBy')}</li>
<li><strong>{t('backup.matchSettingsTemplates')}</strong> - {t('backup.matchSettingsTemplatesBy')}</li>
</ul>
</div>
</div>
@@ -229,12 +216,12 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
) : (
<SkipForward className="w-4 h-4 text-bambu-gray" />
)}
{overwrite ? 'Replace existing data' : 'Keep existing data'}
{overwrite ? t('backup.replaceExisting') : t('backup.keepExisting')}
</p>
<p className="text-sm text-bambu-gray mt-1">
{overwrite
? 'Overwrite items that already exist with backup data'
: 'Only restore items that don\'t already exist'}
? t('backup.overwriteDescription')
: t('backup.keepDescription')}
</p>
</div>
<Toggle checked={overwrite} onChange={setOverwrite} />
@@ -246,7 +233,7 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
<div className="flex items-start gap-2 text-sm">
<AlertTriangle className="w-4 h-4 text-orange-500 dark:text-orange-400 mt-0.5 flex-shrink-0" />
<div className="text-orange-700 dark:text-orange-200">
<span className="font-medium">Caution:</span> Overwriting will replace your current configurations with data from the backup. Printer access codes are never overwritten for security.
<span className="font-medium">{t('backup.overwriteCaution')}</span> {t('backup.overwriteWarning')}
</div>
</div>
</div>
@@ -256,7 +243,7 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-4 border-t border-bambu-dark-tertiary">
<Button type="button" variant="secondary" onClick={onClose}>
Cancel
{t('backup.cancel')}
</Button>
<Button
type="button"
@@ -265,7 +252,7 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
className="bg-bambu-green hover:bg-bambu-green-dark disabled:opacity-50"
>
<Upload className="w-4 h-4 mr-2" />
Restore
{t('backup.restore')}
</Button>
</div>
</>
@@ -275,7 +262,7 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
{state === 'restoring' && (
<div className="p-8 flex flex-col items-center gap-4">
<Loader2 className="w-12 h-12 text-bambu-green animate-spin" />
<p className="text-bambu-gray">Processing backup file...</p>
<p className="text-bambu-gray">{t('backup.processingBackup')}</p>
</div>
)}
@@ -287,11 +274,11 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
<div className="grid grid-cols-2 gap-3">
<div className="p-3 rounded-lg bg-bambu-green/10 border border-bambu-green/30">
<div className="text-2xl font-bold text-bambu-green">{totalRestored}</div>
<div className="text-sm text-bambu-gray">Items Restored</div>
<div className="text-sm text-bambu-gray">{t('backup.itemsRestored')}</div>
</div>
<div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
<div className="text-2xl font-bold text-yellow-500">{totalSkipped}</div>
<div className="text-sm text-bambu-gray">Items Skipped</div>
<div className="text-sm text-bambu-gray">{t('backup.itemsSkipped')}</div>
</div>
</div>
@@ -300,20 +287,20 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
<div className="space-y-2">
<h4 className="text-sm font-medium text-bambu-gray flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-bambu-green" />
Restored
{t('backup.restored')}
</h4>
<div className="space-y-1">
{Object.entries(result.restored)
.filter(([, count]) => count > 0)
.map(([key, count]) => (
<div key={key} className="flex items-center justify-between text-sm p-2 rounded bg-bambu-dark">
<span className="text-white">{CATEGORY_LABELS[key] || key}</span>
<span className="text-white">{t(`backup.categories.${key}`, key)}</span>
<span className="text-bambu-green font-medium">{count}</span>
</div>
))}
{(result.files_restored || 0) > 0 && (
<div className="flex items-center justify-between text-sm p-2 rounded bg-bambu-dark">
<span className="text-white">Files (3MF, thumbnails, etc.)</span>
<span className="text-white">{t('backup.filesCategory')}</span>
<span className="text-bambu-green font-medium">{result.files_restored}</span>
</div>
)}
@@ -326,7 +313,7 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
<div className="space-y-2">
<h4 className="text-sm font-medium text-bambu-gray flex items-center gap-2">
<SkipForward className="w-4 h-4 text-yellow-500" />
Skipped (already exist)
{t('backup.skippedAlreadyExist')}
</h4>
<div className="space-y-1">
{Object.entries(result.skipped)
@@ -343,7 +330,7 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
}`}
>
<span className="text-white flex items-center gap-2">
{CATEGORY_LABELS[key] || key}
{t(`backup.categories.${key}`, key)}
{details.length > 0 && (
isExpanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />
)}
@@ -356,7 +343,7 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
<div key={i}>{item}</div>
))}
{details.length > 10 && (
<div className="text-bambu-gray/60">...and {details.length - 10} more</div>
<div className="text-bambu-gray/60">{t('backup.andMore', { count: details.length - 10 })}</div>
)}
</div>
)}
@@ -372,11 +359,11 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
<div className="space-y-2">
<h4 className="text-sm font-medium text-bambu-gray flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-orange-500" />
New API Keys Generated
{t('backup.newApiKeysGenerated')}
</h4>
<div className="p-3 rounded bg-orange-500/10 border border-orange-500/30">
<p className="text-xs text-orange-200 mb-2">
These keys are only shown once. Copy them now!
{t('backup.keysShownOnce')}
</p>
<div className="space-y-2">
{result.new_api_keys.map((apiKey: { name: string; key: string; key_prefix: string }, i: number) => (
@@ -390,7 +377,7 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
onClick={() => navigator.clipboard.writeText(apiKey.key)}
className="text-xs text-bambu-gray hover:text-white px-2 py-1 rounded bg-bambu-dark-tertiary"
>
Copy
{t('backup.copy')}
</button>
</div>
</div>
@@ -402,7 +389,7 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
{totalRestored === 0 && totalSkipped === 0 && (
<div className="p-4 text-center text-bambu-gray">
No data was found to restore in the backup file.
{t('backup.noDataFound')}
</div>
)}
</div>
@@ -410,7 +397,7 @@ export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProp
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-4 border-t border-bambu-dark-tertiary">
<Button onClick={handleClose}>
Close
{t('backup.close')}
</Button>
</div>
</>
+32 -32
View File
@@ -62,7 +62,7 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
if (context?.previousStatus) {
queryClient.setQueryData(['smart-plug-status', plug.id], context.previousStatus);
}
showToast(`Failed to turn ${action} "${plug.name}"`, 'error');
showToast(t('smartPlugs.failedToTurn', { action, name: plug.name }), 'error');
},
onSettled: () => {
// Refetch after a short delay to get actual state
@@ -162,13 +162,13 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
<div className="flex items-center gap-1 text-sm">
<span className="px-1 py-0.5 bg-blue-500/20 text-blue-400 text-[10px] font-medium rounded">HA</span>
<span className={isReachable ? (isOn ? 'text-status-ok' : 'text-bambu-gray') : 'text-status-error'}>
{isReachable ? (status?.state || '?') : 'Offline'}
{isReachable ? (status?.state || '?') : t('smartPlugs.offline')}
</span>
</div>
) : isReachable ? (
<div className="flex items-center gap-1 text-sm">
<Wifi className="w-4 h-4 text-status-ok" />
<span className={isOn ? 'text-status-ok' : 'text-bambu-gray'}>{status?.state || 'Unknown'}</span>
<span className={isOn ? 'text-status-ok' : 'text-bambu-gray'}>{status?.state || t('smartPlugs.unknown')}</span>
</div>
) : (
<div className="flex items-center gap-1 text-sm text-status-error">
@@ -195,7 +195,7 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
{/* Linked Printer */}
{linkedPrinter && (
<div className="mb-3 px-2 py-1.5 bg-bambu-dark rounded-lg">
<span className="text-xs text-bambu-gray">Linked to: </span>
<span className="text-xs text-bambu-gray">{t('smartPlugs.linkedTo')} </span>
<span className="text-sm text-white">{linkedPrinter.name}</span>
</div>
)}
@@ -206,13 +206,13 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
{plug.plug_type === 'mqtt' && (
<span className="flex items-center gap-1 px-2 py-0.5 bg-teal-500/20 text-teal-400 text-xs rounded-full">
<Eye className="w-3 h-3" />
Monitor Only
{t('smartPlugs.monitorOnly')}
</span>
)}
{plug.power_alert_enabled && (
<span className="flex items-center gap-1 px-2 py-0.5 bg-yellow-500/20 text-yellow-400 text-xs rounded-full">
<Bell className="w-3 h-3" />
Alerts
{t('smartPlugs.alerts')}
</span>
)}
{plug.schedule_enabled && (
@@ -221,8 +221,8 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
{plug.schedule_on_time && plug.schedule_off_time
? `${plug.schedule_on_time} - ${plug.schedule_off_time}`
: plug.schedule_on_time
? `On ${plug.schedule_on_time}`
: `Off ${plug.schedule_off_time}`}
? t('smartPlugs.scheduleOn', { time: plug.schedule_on_time })
: t('smartPlugs.scheduleOff', { time: plug.schedule_off_time })}
</span>
)}
</div>
@@ -239,7 +239,7 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
className="flex-1"
>
{isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
On
{t('smartPlugs.on')}
</Button>
<Button
size="sm"
@@ -249,7 +249,7 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
className="flex-1"
>
{isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
Off
{t('smartPlugs.off')}
</Button>
</div>
)}
@@ -260,13 +260,13 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
{status.energy.power !== null && status.energy.power !== undefined && (
<div className="flex-1 text-center">
<p className="text-lg font-semibold text-white">{Math.round(status.energy.power)}W</p>
<p className="text-xs text-bambu-gray">Power</p>
<p className="text-xs text-bambu-gray">{t('smartPlugs.power')}</p>
</div>
)}
{status.energy.today !== null && status.energy.today !== undefined && (
<div className="flex-1 text-center border-l border-bambu-dark-tertiary">
<p className="text-lg font-semibold text-white">{status.energy.today.toFixed(3)}</p>
<p className="text-xs text-bambu-gray">kWh Today</p>
<p className="text-xs text-bambu-gray">{t('smartPlugs.kwhToday')}</p>
</div>
)}
</div>
@@ -279,7 +279,7 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
>
<span className="flex items-center gap-2">
<Settings2 className="w-4 h-4" />
{plug.plug_type === 'mqtt' ? 'Settings' : 'Automation Settings'}
{plug.plug_type === 'mqtt' ? t('smartPlugs.settings') : t('smartPlugs.automationSettings')}
</span>
<span>{isExpanded ? '-' : '+'}</span>
</button>
@@ -292,8 +292,8 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
<div className="flex items-center gap-2">
<LayoutGrid className="w-4 h-4 text-bambu-green" />
<div>
<p className="text-sm text-white">Show in Switchbar</p>
<p className="text-xs text-bambu-gray">Quick access from sidebar</p>
<p className="text-sm text-white">{t('smartPlugs.showInSwitchbar')}</p>
<p className="text-xs text-bambu-gray">{t('smartPlugs.quickAccessSidebar')}</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
@@ -313,8 +313,8 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
{/* Enabled Toggle */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Enabled</p>
<p className="text-xs text-bambu-gray">Enable automation for this plug</p>
<p className="text-sm text-white">{t('smartPlugs.enabled')}</p>
<p className="text-xs text-bambu-gray">{t('smartPlugs.enableAutomation')}</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
@@ -330,8 +330,8 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
{/* Auto On */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Auto On</p>
<p className="text-xs text-bambu-gray">Turn on when print starts</p>
<p className="text-sm text-white">{t('smartPlugs.autoOn')}</p>
<p className="text-xs text-bambu-gray">{t('smartPlugs.autoOnDescription')}</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
@@ -347,8 +347,8 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
{/* Auto Off */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Auto Off</p>
<p className="text-xs text-bambu-gray">Turn off when print completes (one-shot)</p>
<p className="text-sm text-white">{t('smartPlugs.autoOff')}</p>
<p className="text-xs text-bambu-gray">{t('smartPlugs.autoOffDescription')}</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
@@ -365,7 +365,7 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
{plug.auto_off && (
<div className="space-y-3 pl-4 border-l-2 border-bambu-dark-tertiary">
<div>
<p className="text-sm text-white mb-2">Turn Off Delay Mode</p>
<p className="text-sm text-white mb-2">{t('smartPlugs.turnOffDelayMode')}</p>
<div className="flex gap-2">
<button
onClick={() => updateMutation.mutate({ off_delay_mode: 'time' })}
@@ -376,7 +376,7 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
}`}
>
<Clock className="w-4 h-4" />
Time
{t('smartPlugs.time')}
</button>
<button
onClick={() => updateMutation.mutate({ off_delay_mode: 'temperature' })}
@@ -387,14 +387,14 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
}`}
>
<Thermometer className="w-4 h-4" />
Temp
{t('smartPlugs.temp')}
</button>
</div>
</div>
{plug.off_delay_mode === 'time' ? (
<div>
<label className="block text-xs text-bambu-gray mb-1">Delay (minutes)</label>
<label className="block text-xs text-bambu-gray mb-1">{t('smartPlugs.delayMinutes')}</label>
<input
type="number"
min="1"
@@ -406,7 +406,7 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
</div>
) : (
<div>
<label className="block text-xs text-bambu-gray mb-1">Temperature threshold (C)</label>
<label className="block text-xs text-bambu-gray mb-1">{t('smartPlugs.tempThreshold')}</label>
<input
type="number"
min="30"
@@ -415,7 +415,7 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
onChange={(e) => updateMutation.mutate({ off_temp_threshold: parseInt(e.target.value) || 70 })}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
/>
<p className="text-xs text-bambu-gray mt-1">Turns off when nozzle cools below this temperature</p>
<p className="text-xs text-bambu-gray mt-1">{t('smartPlugs.tempThresholdDescription')}</p>
</div>
)}
</div>
@@ -432,7 +432,7 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
className="flex-1"
>
<Edit2 className="w-4 h-4" />
Edit
{t('smartPlugs.edit')}
</Button>
<Button
size="sm"
@@ -452,8 +452,8 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
{showDeleteConfirm && (
<ConfirmModal
title={t('smartPlugs.deleteSmartPlug')}
message={`Are you sure you want to delete "${plug.name}"? This cannot be undone.`}
confirmText="Delete"
message={t('smartPlugs.deleteConfirm', { name: plug.name })}
confirmText={t('smartPlugs.delete')}
variant="danger"
onConfirm={() => {
deleteMutation.mutate();
@@ -467,7 +467,7 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
{showPowerOnConfirm && (
<ConfirmModal
title={t('smartPlugs.turnOnSmartPlug')}
message={`Are you sure you want to turn on "${plug.name}"?`}
message={t('smartPlugs.turnOnConfirm', { name: plug.name })}
confirmText={t('smartPlugs.turnOn')}
variant="default"
onConfirm={() => {
@@ -482,7 +482,7 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
{showPowerOffConfirm && (
<ConfirmModal
title={t('smartPlugs.turnOffSmartPlug')}
message={`Are you sure you want to turn off "${plug.name}"? This will cut power to the connected device.`}
message={t('smartPlugs.turnOffConfirm', { name: plug.name })}
confirmText={t('smartPlugs.turnOff')}
variant="danger"
onConfirm={() => {
+4 -2
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Plug, Power, PowerOff, Loader2, Wifi, WifiOff, Zap, Radio, Eye } from 'lucide-react';
import { api } from '../api/client';
import type { SmartPlug } from '../api/client';
@@ -146,6 +147,7 @@ function SwitchItem({ plug }: { plug: SmartPlug }) {
}
export function SwitchbarPopover({ onClose }: SwitchbarPopoverProps) {
const { t } = useTranslation();
// Fetch all smart plugs
const { data: plugs, isLoading } = useQuery({
queryKey: ['smart-plugs'],
@@ -177,9 +179,9 @@ export function SwitchbarPopover({ onClose }: SwitchbarPopoverProps) {
) : switchbarPlugs.length === 0 ? (
<div className="text-center py-6 px-4">
<Plug className="w-8 h-8 text-bambu-gray mx-auto mb-2" />
<p className="text-sm text-bambu-gray">No switches in switchbar</p>
<p className="text-sm text-bambu-gray">{t('smartPlugs.noSwitchesInSwitchbar')}</p>
<p className="text-xs text-bambu-gray mt-1">
Enable "Show in Switchbar" in Settings &gt; Smart Plugs
{t('smartPlugs.enableSwitchbarHint')}
</p>
</div>
) : (