mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 11:56:01 +02:00
- Add Tasmota device discovery and switchbar quick access
- Features:
- Tasmota device discovery: Auto-detect network and scan for Tasmota devices
in Add Smart Plug modal. Supports devices with/without authentication.
- Switchbar: Quick access widget in sidebar footer for controlling smart plugs
from anywhere in the app. Shows real-time status and power consumption.
This commit is contained in:
@@ -14,7 +14,11 @@ async function request<T>(
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||
const detail = error.detail;
|
||||
const message = typeof detail === 'string'
|
||||
? detail
|
||||
: (detail ? JSON.stringify(detail) : `HTTP ${response.status}`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
@@ -659,6 +663,8 @@ export interface SmartPlug {
|
||||
schedule_enabled: boolean;
|
||||
schedule_on_time: string | null;
|
||||
schedule_off_time: string | null;
|
||||
// Switchbar visibility
|
||||
show_in_switchbar: boolean;
|
||||
// Status
|
||||
last_state: string | null;
|
||||
last_checked: string | null;
|
||||
@@ -687,6 +693,8 @@ export interface SmartPlugCreate {
|
||||
schedule_enabled?: boolean;
|
||||
schedule_on_time?: string | null;
|
||||
schedule_off_time?: string | null;
|
||||
// Switchbar visibility
|
||||
show_in_switchbar?: boolean;
|
||||
}
|
||||
|
||||
export interface SmartPlugUpdate {
|
||||
@@ -709,6 +717,8 @@ export interface SmartPlugUpdate {
|
||||
schedule_enabled?: boolean;
|
||||
schedule_on_time?: string | null;
|
||||
schedule_off_time?: string | null;
|
||||
// Switchbar visibility
|
||||
show_in_switchbar?: boolean;
|
||||
}
|
||||
|
||||
export interface SmartPlugEnergy {
|
||||
@@ -736,6 +746,21 @@ export interface SmartPlugTestResult {
|
||||
device_name: string | null;
|
||||
}
|
||||
|
||||
// Tasmota Discovery types
|
||||
export interface TasmotaScanStatus {
|
||||
running: boolean;
|
||||
scanned: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface DiscoveredTasmotaDevice {
|
||||
ip_address: string;
|
||||
name: string;
|
||||
module: number | null;
|
||||
state: string | null;
|
||||
discovered_at: string | null;
|
||||
}
|
||||
|
||||
// Print Queue types
|
||||
export interface PrintQueueItem {
|
||||
id: number;
|
||||
@@ -1757,6 +1782,18 @@ export const api = {
|
||||
body: JSON.stringify({ ip_address, username, password }),
|
||||
}),
|
||||
|
||||
// Tasmota Discovery (auto-detects network)
|
||||
startTasmotaScan: () =>
|
||||
fetch(`${API_BASE}/smart-plugs/discover/scan`, { method: 'POST' })
|
||||
.then(res => res.ok ? res.json() : res.json().then(e => { throw new Error(e.detail || `HTTP ${res.status}`); })),
|
||||
getTasmotaScanStatus: () =>
|
||||
request<TasmotaScanStatus>('/smart-plugs/discover/status'),
|
||||
stopTasmotaScan: () =>
|
||||
fetch(`${API_BASE}/smart-plugs/discover/stop`, { method: 'POST' })
|
||||
.then(res => res.ok ? res.json() : res.json().then(e => { throw new Error(e.detail || `HTTP ${res.status}`); })),
|
||||
getDiscoveredTasmotaDevices: () =>
|
||||
request<DiscoveredTasmotaDevice[]>('/smart-plugs/discover/devices'),
|
||||
|
||||
// Print Queue
|
||||
getQueue: (printerId?: number, status?: string) => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
|
||||
import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock } from 'lucide-react';
|
||||
import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock, LayoutGrid, Search, Plug, Power } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate } from '../api/client';
|
||||
import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate, DiscoveredTasmotaDevice } from '../api/client';
|
||||
import { Button } from './Button';
|
||||
|
||||
interface AddSmartPlugModalProps {
|
||||
@@ -32,6 +32,15 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
|
||||
const [scheduleOnTime, setScheduleOnTime] = useState<string>(plug?.schedule_on_time || '');
|
||||
const [scheduleOffTime, setScheduleOffTime] = useState<string>(plug?.schedule_off_time || '');
|
||||
|
||||
// Switchbar visibility
|
||||
const [showInSwitchbar, setShowInSwitchbar] = useState(plug?.show_in_switchbar || false);
|
||||
|
||||
// Discovery state
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [scanProgress, setScanProgress] = useState({ scanned: 0, total: 0 });
|
||||
const [discoveredDevices, setDiscoveredDevices] = useState<DiscoveredTasmotaDevice[]>([]);
|
||||
const scanPollRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Fetch printers for linking
|
||||
const { data: printers } = useQuery({
|
||||
queryKey: ['printers'],
|
||||
@@ -44,15 +53,82 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
|
||||
queryFn: api.getSmartPlugs,
|
||||
});
|
||||
|
||||
// Close on Escape key
|
||||
// Close on Escape key and cleanup scan polling
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
if (scanPollRef.current) {
|
||||
clearInterval(scanPollRef.current);
|
||||
}
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
// Start scanning for Tasmota devices (auto-detects network)
|
||||
const startScan = async () => {
|
||||
setIsScanning(true);
|
||||
setDiscoveredDevices([]);
|
||||
setScanProgress({ scanned: 0, total: 0 });
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await api.startTasmotaScan();
|
||||
|
||||
// Poll function to fetch status and devices
|
||||
const pollStatus = async () => {
|
||||
try {
|
||||
const status = await api.getTasmotaScanStatus();
|
||||
setScanProgress({ scanned: status.scanned, total: status.total });
|
||||
|
||||
const devices = await api.getDiscoveredTasmotaDevices();
|
||||
setDiscoveredDevices(devices);
|
||||
|
||||
if (!status.running) {
|
||||
setIsScanning(false);
|
||||
if (scanPollRef.current) {
|
||||
clearInterval(scanPollRef.current);
|
||||
scanPollRef.current = null;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Polling error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// Poll immediately, then every 500ms
|
||||
await pollStatus();
|
||||
scanPollRef.current = setInterval(pollStatus, 500);
|
||||
} catch (err) {
|
||||
setIsScanning(false);
|
||||
const errorMsg = err instanceof Error ? err.message : (typeof err === 'string' ? err : JSON.stringify(err));
|
||||
setError(errorMsg || 'Failed to start scan');
|
||||
}
|
||||
};
|
||||
|
||||
// Stop scanning
|
||||
const stopScan = async () => {
|
||||
try {
|
||||
await api.stopTasmotaScan();
|
||||
} catch {
|
||||
// Ignore stop errors
|
||||
}
|
||||
setIsScanning(false);
|
||||
if (scanPollRef.current) {
|
||||
clearInterval(scanPollRef.current);
|
||||
scanPollRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Select a discovered device
|
||||
const selectDevice = (device: DiscoveredTasmotaDevice) => {
|
||||
setIpAddress(device.ip_address);
|
||||
setName(device.name);
|
||||
setTestResult(null);
|
||||
};
|
||||
|
||||
// Test connection mutation
|
||||
const testMutation = useMutation({
|
||||
mutationFn: () => api.testSmartPlugConnection(ipAddress, username || null, password || null),
|
||||
@@ -127,6 +203,8 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
|
||||
schedule_enabled: scheduleEnabled,
|
||||
schedule_on_time: scheduleOnTime || null,
|
||||
schedule_off_time: scheduleOffTime || null,
|
||||
// Switchbar
|
||||
show_in_switchbar: showInSwitchbar,
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
@@ -168,6 +246,79 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Discovery Section - only show when not editing */}
|
||||
{!isEditing && (
|
||||
<div className="space-y-3">
|
||||
{/* Scan button - auto-detects network */}
|
||||
{isScanning ? (
|
||||
<Button type="button" variant="secondary" onClick={stopScan} className="w-full">
|
||||
<X className="w-4 h-4" />
|
||||
Stop Scanning
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" variant="primary" onClick={startScan} className="w-full">
|
||||
<Search className="w-4 h-4" />
|
||||
Discover Tasmota Devices
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Progress bar */}
|
||||
{isScanning && scanProgress.total > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs text-bambu-gray">
|
||||
<span>Scanning network...</span>
|
||||
<span>{scanProgress.scanned} / {scanProgress.total}</span>
|
||||
</div>
|
||||
<div className="w-full bg-bambu-dark-tertiary rounded-full h-2">
|
||||
<div
|
||||
className="bg-bambu-green h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${(scanProgress.scanned / scanProgress.total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
<div className="max-h-40 overflow-y-auto space-y-1">
|
||||
{discoveredDevices.map((device) => (
|
||||
<button
|
||||
key={device.ip_address}
|
||||
type="button"
|
||||
onClick={() => selectDevice(device)}
|
||||
className="w-full flex items-center justify-between p-2 bg-bambu-dark hover:bg-bambu-dark-tertiary rounded-lg transition-colors text-left border border-bambu-dark-tertiary"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Plug className="w-4 h-4 text-bambu-green" />
|
||||
<div>
|
||||
<p className="text-sm text-white">{device.name}</p>
|
||||
<p className="text-xs text-bambu-gray">{device.ip_address}</p>
|
||||
</div>
|
||||
</div>
|
||||
{device.state && (
|
||||
<span className={`flex items-center gap-1 text-xs ${
|
||||
device.state === 'ON' ? 'text-bambu-green' : 'text-bambu-gray'
|
||||
}`}>
|
||||
<Power className="w-3 h-3" />
|
||||
{device.state}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!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
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* IP Address */}
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">IP Address *</label>
|
||||
@@ -382,6 +533,28 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Switchbar Visibility */}
|
||||
<div className="border-t border-bambu-dark-tertiary pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showInSwitchbar}
|
||||
onChange={(e) => setShowInSwitchbar(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, X, Menu, Info, type LucideIcon } from 'lucide-react';
|
||||
import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, X, Menu, Info, Plug, type LucideIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
|
||||
import { SwitchbarPopover } from './SwitchbarPopover';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../api/client';
|
||||
import { getIconByName } from './IconPicker';
|
||||
@@ -72,6 +73,7 @@ export function Layout() {
|
||||
});
|
||||
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
||||
const [showShortcuts, setShowShortcuts] = useState(false);
|
||||
const [showSwitchbar, setShowSwitchbar] = useState(false);
|
||||
const [sidebarOrder, setSidebarOrder] = useState<string[]>(getSidebarOrder);
|
||||
const [draggedId, setDraggedId] = useState<string | null>(null);
|
||||
const [dragOverId, setDragOverId] = useState<string | null>(null);
|
||||
@@ -107,6 +109,15 @@ export function Layout() {
|
||||
queryFn: api.getExternalLinks,
|
||||
});
|
||||
|
||||
// Fetch smart plugs to check for switchbar items
|
||||
const { data: smartPlugs } = useQuery({
|
||||
queryKey: ['smart-plugs'],
|
||||
queryFn: api.getSmartPlugs,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
});
|
||||
|
||||
const hasSwitchbarPlugs = smartPlugs?.some(p => p.show_in_switchbar) ?? false;
|
||||
|
||||
// Build the unified sidebar items list
|
||||
const navItemsMap = new Map(defaultNavItems.map(item => [item.id, item]));
|
||||
const extLinksMap = new Map((externalLinks || []).map(link => [`ext-${link.id}`, link]));
|
||||
@@ -457,6 +468,22 @@ export function Layout() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{hasSwitchbarPlugs && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onMouseEnter={() => setShowSwitchbar(true)}
|
||||
className={`p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
|
||||
showSwitchbar ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'
|
||||
}`}
|
||||
title={t('nav.smartSwitches', { defaultValue: 'Smart Switches' })}
|
||||
>
|
||||
<Plug className="w-5 h-5" />
|
||||
</button>
|
||||
{showSwitchbar && (
|
||||
<SwitchbarPopover onClose={() => setShowSwitchbar(false)} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<NavLink
|
||||
to="/system"
|
||||
className={({ isActive }) =>
|
||||
@@ -504,6 +531,22 @@ export function Layout() {
|
||||
<ArrowUpCircle className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
{hasSwitchbarPlugs && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onMouseEnter={() => setShowSwitchbar(true)}
|
||||
className={`p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
|
||||
showSwitchbar ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'
|
||||
}`}
|
||||
title={t('nav.smartSwitches', { defaultValue: 'Smart Switches' })}
|
||||
>
|
||||
<Plug className="w-5 h-5" />
|
||||
</button>
|
||||
{showSwitchbar && (
|
||||
<SwitchbarPopover onClose={() => setShowSwitchbar(false)} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<NavLink
|
||||
to="/system"
|
||||
className={({ isActive }) =>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
|
||||
import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2, Bell, Calendar } from 'lucide-react';
|
||||
import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2, Bell, Calendar, LayoutGrid } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { SmartPlug, SmartPlugUpdate } from '../api/client';
|
||||
import { Card, CardContent } from './Card';
|
||||
@@ -169,6 +169,26 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
|
||||
{/* Expanded Settings */}
|
||||
{isExpanded && (
|
||||
<div className="pt-3 border-t border-bambu-dark-tertiary space-y-4">
|
||||
{/* Show in Switchbar Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={plug.show_in_switchbar}
|
||||
onChange={(e) => updateMutation.mutate({ show_in_switchbar: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Enabled Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plug, Power, PowerOff, Loader2, Wifi, WifiOff, Zap } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { SmartPlug } from '../api/client';
|
||||
|
||||
interface SwitchbarPopoverProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function SwitchItem({ plug }: { plug: SmartPlug }) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch current status
|
||||
const { data: status, isLoading: statusLoading } = useQuery({
|
||||
queryKey: ['smart-plug-status', plug.id],
|
||||
queryFn: () => api.getSmartPlugStatus(plug.id),
|
||||
refetchInterval: 10000, // Refresh every 10 seconds when popover is open
|
||||
});
|
||||
|
||||
// Control mutation
|
||||
const controlMutation = useMutation({
|
||||
mutationFn: (action: 'on' | 'off') => api.controlSmartPlug(plug.id, action),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['smart-plug-status', plug.id] });
|
||||
},
|
||||
});
|
||||
|
||||
const isOn = status?.state === 'ON';
|
||||
const isReachable = status?.reachable ?? false;
|
||||
const isPending = controlMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2 px-3 hover:bg-bambu-dark-tertiary rounded-lg transition-colors">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`p-1.5 rounded ${isReachable ? (isOn ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20'}`}>
|
||||
<Plug className={`w-4 h-4 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-white font-medium">{plug.name}</p>
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
{statusLoading ? (
|
||||
<Loader2 className="w-3 h-3 text-bambu-gray animate-spin" />
|
||||
) : isReachable ? (
|
||||
<>
|
||||
<Wifi className="w-3 h-3 text-bambu-green" />
|
||||
<span className={isOn ? 'text-bambu-green' : 'text-bambu-gray'}>{status?.state || 'Unknown'}</span>
|
||||
{status?.energy?.power !== null && status?.energy?.power !== undefined && (
|
||||
<>
|
||||
<span className="text-bambu-gray mx-1">|</span>
|
||||
<Zap className="w-3 h-3 text-yellow-400" />
|
||||
<span className="text-yellow-400">{Math.round(status.energy.power)}W</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<WifiOff className="w-3 h-3 text-red-400" />
|
||||
<span className="text-red-400">Offline</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => controlMutation.mutate('on')}
|
||||
disabled={!isReachable || isPending}
|
||||
className={`p-1.5 rounded transition-colors ${
|
||||
isOn
|
||||
? 'bg-bambu-green text-white'
|
||||
: 'bg-bambu-dark text-bambu-gray hover:text-white'
|
||||
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
title="Turn On"
|
||||
>
|
||||
{isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => controlMutation.mutate('off')}
|
||||
disabled={!isReachable || isPending}
|
||||
className={`p-1.5 rounded transition-colors ${
|
||||
!isOn && isReachable
|
||||
? 'bg-bambu-dark-tertiary text-white'
|
||||
: 'bg-bambu-dark text-bambu-gray hover:text-white'
|
||||
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
title="Turn Off"
|
||||
>
|
||||
{isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SwitchbarPopover({ onClose }: SwitchbarPopoverProps) {
|
||||
// Fetch all smart plugs
|
||||
const { data: plugs, isLoading } = useQuery({
|
||||
queryKey: ['smart-plugs'],
|
||||
queryFn: api.getSmartPlugs,
|
||||
});
|
||||
|
||||
// Filter to only show plugs with show_in_switchbar enabled
|
||||
const switchbarPlugs = plugs?.filter(p => p.show_in_switchbar) || [];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute bottom-full left-0 mb-2 w-72 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-xl z-50"
|
||||
onMouseLeave={onClose}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-bambu-dark-tertiary">
|
||||
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||
<Plug className="w-4 h-4 text-bambu-green" />
|
||||
Smart Switches
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-2 max-h-80 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 text-bambu-gray animate-spin" />
|
||||
</div>
|
||||
) : 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-xs text-bambu-gray mt-1">
|
||||
Enable "Show in Switchbar" in Settings > Smart Plugs
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{switchbarPlugs.map(plug => (
|
||||
<SwitchItem key={plug.id} plug={plug} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user