Files
imperial-command-center/src/App.tsx
root 81236d908c Add weather badge, chore chart, fix controls grid
- Header now shows weather icon + temp from weather.forecast_home_2 next
  to the connection badge (right side of nav bar)
- New ChoreChart widget below TodoWidget on Home view; reads from
  todo.chores HA entity; items prefixed with a name (Becca: / Chris: /
  Arabella:) get per-person color coding matching the calendar palette;
  tap toggles completion
- Controls overlay simplified from grid-cols-12 auto-rows-min to a plain
  grid-cols-2 — removes the uneven gaps between widgets
2026-04-16 22:35:00 -05:00

270 lines
9.8 KiB
TypeScript

import { useEffect, useState, useCallback } from 'react';
import { Dashboard } from '@/components/layout';
import { ThermostatOverlay } from '@/components/climate';
import { LightsOverlay } from '@/components/lights';
import { LocksOverlay } from '@/components/locks';
import { ControlsOverlay } from '@/components/controls';
import { CalendarWidget } from '@/components/calendar';
import { TodoWidget } from '@/components/todo';
import { ChoreChart } from '@/components/chores';
import { SettingsPanel, ConnectionModal } from '@/components/settings';
import { CameraOverlay } from '@/components/cameras';
import { CameraFeed } from '@/components/cameras/CameraFeed';
import { JellyfinOverlay } from '@/components/media';
import { GlobalKeyboard } from '@/components/keyboard';
import { PhotoFrame } from '@/components/photoframe';
import { useHomeAssistant } from '@/hooks';
import { useIdle } from '@/hooks/useIdle';
import { useHAStore } from '@/stores/haStore';
import { useUIStore, useCameraOverlay } from '@/stores/uiStore';
import { useSettingsStore } from '@/stores/settingsStore';
import { env } from '@/config/environment';
// Person detection alert overlay - shows for 30 seconds when person detected on any configured camera
function PersonAlert({ cameraName, onClose }: { cameraName: string; onClose: () => void }) {
const cameras = useSettingsStore((state) => state.config.cameras);
const cameraLower = cameraName.toLowerCase();
const camera = cameras.find(
(c) => c.frigateCamera?.toLowerCase() === cameraLower || c.name.toLowerCase() === cameraLower,
);
useEffect(() => {
const timer = setTimeout(onClose, 30000);
return () => clearTimeout(timer);
}, [onClose]);
if (!camera) return null;
return (
<div className="fixed inset-0 z-50 bg-black flex flex-col">
<div className="h-14 bg-dark-secondary border-b border-status-warning flex items-center justify-between px-5">
<div className="flex items-center gap-3">
<div className="w-3 h-3 rounded-full bg-status-warning animate-pulse" />
<h2 className="text-lg font-semibold text-status-warning">
Person Detected - {camera.displayName}
</h2>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-dark-hover rounded-xl transition-colors"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex-1 p-2">
<CameraFeed
camera={camera}
className="w-full h-full"
showLabel={false}
/>
</div>
</div>
);
}
function ConnectionPrompt() {
const openSettings = useUIStore((state) => state.openSettings);
return (
<div className="h-screen w-screen flex items-center justify-center bg-imperial-black">
<div className="card-imperial max-w-md text-center">
<div className="w-20 h-20 rounded-full bg-imperial-red mx-auto mb-6 flex items-center justify-center">
<svg className="w-10 h-10 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<h1 className="font-display text-2xl uppercase tracking-wider text-imperial-red mb-4">
Imperial Command Center
</h1>
<p className="text-gray-400 mb-6">
Enter your Home Assistant long-lived access token to connect.
</p>
<button onClick={openSettings} className="btn-primary">
Configure Connection
</button>
</div>
</div>
);
}
function DashboardContent() {
const config = useSettingsStore((state) => state.config);
return (
<div className="col-span-12 flex flex-row gap-3 min-h-0">
{config.calendar && (
<div className="flex-[4] min-w-0">
<CalendarWidget />
</div>
)}
<div className="flex-1 min-w-0 flex flex-col gap-3">
{config.todoList && (
<div className="flex-1 min-h-0">
<TodoWidget />
</div>
)}
<div className="flex-1 min-h-0">
<ChoreChart />
</div>
</div>
</div>
);
}
export default function App() {
const { connectionState } = useHomeAssistant();
const accessToken = useHAStore((state) => state.accessToken);
const connect = useHAStore((state) => state.connect);
const settingsOpen = useUIStore((state) => state.settingsOpen);
const lightsOverlayOpen = useUIStore((state) => state.lightsOverlayOpen);
const locksOverlayOpen = useUIStore((state) => state.locksOverlayOpen);
const thermostatsOverlayOpen = useUIStore((state) => state.thermostatsOverlayOpen);
const mediaOverlayOpen = useUIStore((state) => state.mediaOverlayOpen);
const controlsOverlayOpen = useUIStore((state) => state.controlsOverlayOpen);
const { isOpen: cameraOverlayOpen } = useCameraOverlay();
const isIdle = useIdle(env.photoFrameIdleTimeout);
// Person detection alert state (via MQTT from Electron main process)
const [alertCamera, setAlertCamera] = useState<string | null>(null);
// Report touch/click activity to main process for screen wake on Wayland
useEffect(() => {
const handleActivity = () => {
if (window.electronAPI?.screen?.activity) {
window.electronAPI.screen.activity();
}
};
// Listen for any touch or click events
document.addEventListener('touchstart', handleActivity, { passive: true });
document.addEventListener('mousedown', handleActivity, { passive: true });
return () => {
document.removeEventListener('touchstart', handleActivity);
document.removeEventListener('mousedown', handleActivity);
};
}, []);
// Auto-connect if token is stored (check localStorage first, then config file)
useEffect(() => {
const initConfig = async () => {
// Load HA token
let storedToken = localStorage.getItem('ha_access_token');
// If no token in localStorage, try to get from config file
if (!storedToken && window.electronAPI?.config?.getStoredToken) {
const fileToken = await window.electronAPI.config.getStoredToken();
if (fileToken) {
storedToken = fileToken;
localStorage.setItem('ha_access_token', fileToken);
}
}
if (storedToken && !accessToken) {
connect(storedToken);
}
// Load Jellyfin API key from config file
if (window.electronAPI?.config?.getJellyfinApiKey) {
const jellyfinKey = await window.electronAPI.config.getJellyfinApiKey();
if (jellyfinKey) {
useSettingsStore.getState().setJellyfinApiKey(jellyfinKey);
}
}
};
initConfig();
}, [accessToken, connect]);
// Listen for person detection via MQTT (from Electron main process)
useEffect(() => {
const api = window.electronAPI;
if (!api?.frigate?.onPersonDetected) return;
const unsub = api.frigate.onPersonDetected((camera: string) => {
setAlertCamera(camera);
useUIStore.getState().setIdle(false);
});
return unsub;
}, []);
const closePersonAlert = useCallback(() => {
setAlertCamera(null);
}, []);
// Set up screen idle timeout
useEffect(() => {
if (window.electronAPI) {
window.electronAPI.screen.setIdleTimeout(env.screenIdleTimeout);
}
}, []);
// Show connection prompt if no token
if (!accessToken) {
return (
<>
<ConnectionPrompt />
{settingsOpen && <ConnectionModal />}
</>
);
}
// Show loading state
if (connectionState === 'connecting') {
return (
<div className="h-screen w-screen flex items-center justify-center bg-dark-primary">
<div className="text-center">
<div className="w-16 h-16 border-4 border-accent border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-lg text-accent">
Connecting to Home Assistant...
</p>
</div>
</div>
);
}
// Show error state
if (connectionState === 'error') {
return (
<div className="h-screen w-screen flex items-center justify-center bg-dark-primary">
<div className="widget max-w-md text-center p-8">
<div className="w-20 h-20 rounded-full bg-status-error mx-auto mb-6 flex items-center justify-center">
<svg className="w-10 h-10 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h2 className="text-xl font-semibold text-status-error mb-4">
Connection Error
</h2>
<p className="text-gray-400 mb-6">
Failed to connect to Home Assistant. Please check your configuration.
</p>
<button onClick={() => window.location.reload()} className="btn btn-primary">
Retry Connection
</button>
</div>
</div>
);
}
return (
<>
<Dashboard>
<DashboardContent />
</Dashboard>
{lightsOverlayOpen && <LightsOverlay />}
{locksOverlayOpen && <LocksOverlay />}
{thermostatsOverlayOpen && <ThermostatOverlay />}
{mediaOverlayOpen && <JellyfinOverlay />}
{controlsOverlayOpen && <ControlsOverlay />}
{cameraOverlayOpen && <CameraOverlay />}
{settingsOpen && <SettingsPanel />}
{isIdle && !alertCamera && <PhotoFrame intervalMs={env.photoFrameInterval} />}
{alertCamera && <PersonAlert cameraName={alertCamera} onClose={closePersonAlert} />}
<GlobalKeyboard />
</>
);
}