Initial commit: Electron + React touchscreen kiosk dashboard for Home Assistant
This commit is contained in:
328
src/App.tsx
Normal file
328
src/App.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { Dashboard } from '@/components/layout';
|
||||
import { ThermostatOverlay } from '@/components/climate';
|
||||
import { LightsOverlay } from '@/components/lights';
|
||||
import { LocksOverlay } from '@/components/locks';
|
||||
import { AlarmoPanel } from '@/components/alarm';
|
||||
import { CalendarWidget } from '@/components/calendar';
|
||||
import { TodoWidget } from '@/components/todo';
|
||||
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 { useHomeAssistant } from '@/hooks';
|
||||
// Motion detection now runs in Electron main process (MotionDetector.ts)
|
||||
// import { useSimpleMotion } from '@/hooks/useSimpleMotion';
|
||||
import { useHAStore, useEntityAttribute } from '@/stores/haStore';
|
||||
import { useUIStore, useCameraOverlay } from '@/stores/uiStore';
|
||||
import { useSettingsStore } from '@/stores/settingsStore';
|
||||
import { env } from '@/config/environment';
|
||||
|
||||
// Front porch alert overlay - shows for 30 seconds when person detected
|
||||
function FrontPorchAlert({ onClose }: { onClose: () => void }) {
|
||||
const cameras = useSettingsStore((state) => state.config.cameras);
|
||||
const frontPorchCamera = cameras.find((c) => c.name === 'Front_Porch');
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(onClose, 30000); // 30 seconds
|
||||
return () => clearTimeout(timer);
|
||||
}, [onClose]);
|
||||
|
||||
if (!frontPorchCamera) 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 - Front Porch
|
||||
</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={frontPorchCamera}
|
||||
className="w-full h-full"
|
||||
showLabel={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Simple thermostat temp display
|
||||
function ThermostatTemp({ entityId }: { entityId: string }) {
|
||||
const currentTemp = useEntityAttribute<number>(entityId, 'current_temperature');
|
||||
return <>{currentTemp?.toFixed(0) ?? '--'}°</>;
|
||||
}
|
||||
|
||||
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);
|
||||
const openLightsOverlay = useUIStore((state) => state.openLightsOverlay);
|
||||
const openLocksOverlay = useUIStore((state) => state.openLocksOverlay);
|
||||
const openThermostatsOverlay = useUIStore((state) => state.openThermostatsOverlay);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Left Column - Calendar (spans 2 columns) */}
|
||||
<div className="col-span-8 flex flex-col gap-4">
|
||||
{config.calendar && (
|
||||
<div className="flex-1 min-h-0">
|
||||
<CalendarWidget />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Column - Controls, Alarm, Todo */}
|
||||
<div className="col-span-4 flex flex-col gap-3">
|
||||
{/* Control Buttons Row - Lights, Locks, Thermostats */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{config.lights.length > 0 && (
|
||||
<button
|
||||
onClick={openLightsOverlay}
|
||||
className="widget flex-col items-center justify-center gap-1 py-3 hover:bg-dark-hover transition-colors cursor-pointer"
|
||||
>
|
||||
<svg className="w-6 h-6 text-yellow-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
<span className="text-xs">Lights</span>
|
||||
</button>
|
||||
)}
|
||||
{config.locks.length > 0 && (
|
||||
<button
|
||||
onClick={openLocksOverlay}
|
||||
className="widget flex-col items-center justify-center gap-1 py-3 hover:bg-dark-hover transition-colors cursor-pointer"
|
||||
>
|
||||
<svg className="w-6 h-6 text-status-success" 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>
|
||||
<span className="text-xs">Locks</span>
|
||||
</button>
|
||||
)}
|
||||
{config.thermostats.map((thermostat) => (
|
||||
<button
|
||||
key={thermostat.entityId}
|
||||
onClick={openThermostatsOverlay}
|
||||
className="widget flex-col items-center justify-center gap-1 py-3 hover:bg-dark-hover transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="text-xl font-light text-orange-400">
|
||||
<ThermostatTemp entityId={thermostat.entityId} />
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{thermostat.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Alarm Panel */}
|
||||
{config.alarm && <AlarmoPanel />}
|
||||
|
||||
{/* Todo List */}
|
||||
{config.todoList && (
|
||||
<div className="flex-1 min-h-0">
|
||||
<TodoWidget />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { isConnected, connectionState } = useHomeAssistant();
|
||||
const accessToken = useHAStore((state) => state.accessToken);
|
||||
const connect = useHAStore((state) => state.connect);
|
||||
const entities = useHAStore((state) => state.entities);
|
||||
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 { isOpen: cameraOverlayOpen } = useCameraOverlay();
|
||||
|
||||
// Front porch alert state
|
||||
const [showFrontPorchAlert, setShowFrontPorchAlert] = useState(false);
|
||||
const frontPorchAlertShownRef = useRef(false);
|
||||
|
||||
// Motion detection now runs in the Electron main process (MotionDetector.ts)
|
||||
// This prevents browser throttling when the screensaver is active
|
||||
|
||||
// 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 Front Porch person detection - show full screen overlay for 30 seconds
|
||||
useEffect(() => {
|
||||
if (!isConnected) return;
|
||||
|
||||
const frontPorchEntity = entities['binary_sensor.front_porch_person_occupancy'];
|
||||
const isPersonDetected = frontPorchEntity?.state === 'on';
|
||||
|
||||
if (isPersonDetected && !frontPorchAlertShownRef.current) {
|
||||
// Person just detected - show alert
|
||||
frontPorchAlertShownRef.current = true;
|
||||
setShowFrontPorchAlert(true);
|
||||
// Also wake the screen
|
||||
if (window.electronAPI?.screen?.wake) {
|
||||
window.electronAPI.screen.wake();
|
||||
}
|
||||
} else if (!isPersonDetected) {
|
||||
// Reset flag when person clears so next detection triggers alert
|
||||
frontPorchAlertShownRef.current = false;
|
||||
}
|
||||
}, [isConnected, entities]);
|
||||
|
||||
const closeFrontPorchAlert = useCallback(() => {
|
||||
setShowFrontPorchAlert(false);
|
||||
}, []);
|
||||
|
||||
// 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 />}
|
||||
{cameraOverlayOpen && <CameraOverlay />}
|
||||
{settingsOpen && <SettingsPanel />}
|
||||
{showFrontPorchAlert && <FrontPorchAlert onClose={closeFrontPorchAlert} />}
|
||||
<GlobalKeyboard />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user