mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 01:57:41 +02:00
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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 > Smart Plugs
|
||||
{t('smartPlugs.enableSwitchbarHint')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user