- 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
270 lines
9.8 KiB
TypeScript
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 />
|
|
</>
|
|
);
|
|
}
|