mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-07 14:41:24 +02:00
The app was built dark-first, so hundreds of hardcoded Tailwind semantic text/icon utilities at light shades (text-amber-400, text-blue-300, ...) had no dark: variant. With darkMode:'class' they applied in light theme too, producing washed-out text on pale tints and white cards — including the three reported spots (AMS Drying banner, Archives no-3MF warning, debug-logging banner). Give each a theme-aware pair: a darker readable shade in light theme with the original pinned to dark:, so dark theme is unchanged. ~100 files. The bambu-* CSS-variable palette (self-correcting) and the dark-only SpoolBuddy kiosk are left untouched. Plain text-white is already theme-aware via the existing index.css .text-white override, so it needed no changes.
198 lines
7.9 KiB
TypeScript
198 lines
7.9 KiB
TypeScript
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';
|
|
import { ConfirmModal } from './ConfirmModal';
|
|
|
|
interface SwitchbarPopoverProps {
|
|
onClose: () => void;
|
|
}
|
|
|
|
function SwitchItem({ plug }: { plug: SmartPlug }) {
|
|
const queryClient = useQueryClient();
|
|
const [confirmAction, setConfirmAction] = useState<'on' | 'off' | null>(null);
|
|
|
|
// 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';
|
|
// For MQTT plugs, consider reachable if we have power data
|
|
const hasMqttData = plug.plug_type === 'mqtt' && (status?.energy?.power !== null && status?.energy?.power !== undefined);
|
|
const isReachable = (status?.reachable ?? false) || hasMqttData;
|
|
const isPending = controlMutation.isPending;
|
|
const isMqtt = plug.plug_type === 'mqtt';
|
|
|
|
const handleConfirm = () => {
|
|
if (confirmAction) {
|
|
controlMutation.mutate(confirmAction);
|
|
setConfirmAction(null);
|
|
}
|
|
};
|
|
|
|
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 ${
|
|
isMqtt
|
|
? (isReachable ? 'bg-teal-500/20' : 'bg-red-500/20')
|
|
: (isReachable ? (isOn ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20')
|
|
}`}>
|
|
{isMqtt ? (
|
|
<Radio className={`w-4 h-4 ${isReachable ? 'text-teal-600 dark:text-teal-400' : 'text-red-600 dark:text-red-400'}`} />
|
|
) : (
|
|
<Plug className={`w-4 h-4 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-600 dark: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" />
|
|
) : isMqtt ? (
|
|
/* MQTT plugs show power and monitor-only indicator */
|
|
isReachable ? (
|
|
<>
|
|
<Zap className="w-3 h-3 text-teal-600 dark:text-teal-400" />
|
|
<span className="text-teal-700 dark:text-teal-400">{Math.round(status?.energy?.power ?? 0)}W</span>
|
|
<span className="text-bambu-gray mx-1">|</span>
|
|
<Eye className="w-3 h-3 text-bambu-gray" />
|
|
<span className="text-bambu-gray">Monitor</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<WifiOff className="w-3 h-3 text-status-error" />
|
|
<span className="text-status-error">Waiting</span>
|
|
</>
|
|
)
|
|
) : isReachable ? (
|
|
<>
|
|
<Wifi className="w-3 h-3 text-status-ok" />
|
|
<span className={isOn ? 'text-status-ok' : '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-600 dark:text-yellow-400" />
|
|
<span className="text-yellow-700 dark:text-yellow-400">{Math.round(status.energy.power)}W</span>
|
|
</>
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
<WifiOff className="w-3 h-3 text-status-error" />
|
|
<span className="text-status-error">Offline</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Hide on/off buttons for MQTT plugs (monitor-only) */}
|
|
{!isMqtt && (
|
|
<div className="flex gap-1">
|
|
<button
|
|
onClick={() => setConfirmAction('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={() => setConfirmAction('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>
|
|
|
|
{confirmAction && (
|
|
<ConfirmModal
|
|
title={`Turn ${confirmAction === 'on' ? 'On' : 'Off'} Smart Plug`}
|
|
message={`Are you sure you want to turn ${confirmAction === 'on' ? 'on' : 'off'} "${plug.name}"?`}
|
|
confirmText={confirmAction === 'on' ? 'Turn On' : 'Turn Off'}
|
|
variant={confirmAction === 'off' ? 'warning' : 'default'}
|
|
onConfirm={handleConfirm}
|
|
onCancel={() => setConfirmAction(null)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function SwitchbarPopover({ onClose }: SwitchbarPopoverProps) {
|
|
const { t } = useTranslation();
|
|
// 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">{t('smartPlugs.noSwitchesInSwitchbar')}</p>
|
|
<p className="text-xs text-bambu-gray mt-1">
|
|
{t('smartPlugs.enableSwitchbarHint')}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-1">
|
|
{switchbarPlugs.map(plug => (
|
|
<SwitchItem key={plug.id} plug={plug} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|