Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edd8cd01c1 | ||
|
|
a74a2ddd7f | ||
|
|
81236d908c | ||
|
|
f5461db97d | ||
|
|
3b38a78295 | ||
|
|
55dd117520 | ||
|
|
9315374944 | ||
|
|
7b36551c32 | ||
|
|
d0db8c55b3 | ||
|
|
a5803f70e3 | ||
|
|
cede430dc9 | ||
|
|
7886e72f38 | ||
|
|
1dd32c6afe | ||
|
|
5fe7bc71ef | ||
|
|
58ebd3e239 |
10
.env.example
10
.env.example
@@ -20,3 +20,13 @@ VITE_PRESENCE_CONFIDENCE_THRESHOLD=0.6
|
|||||||
# Frigate Streaming from built-in camera
|
# Frigate Streaming from built-in camera
|
||||||
VITE_FRIGATE_STREAM_ENABLED=true
|
VITE_FRIGATE_STREAM_ENABLED=true
|
||||||
VITE_FRIGATE_RTSP_OUTPUT=rtsp://192.168.1.241:8554/command_center
|
VITE_FRIGATE_RTSP_OUTPUT=rtsp://192.168.1.241:8554/command_center
|
||||||
|
|
||||||
|
# Photo Frame (shown after inactivity)
|
||||||
|
# Idle timeout in ms before photo frame takes over (default 5 min)
|
||||||
|
VITE_PHOTO_FRAME_IDLE_TIMEOUT=300000
|
||||||
|
# Seconds between photo transitions
|
||||||
|
VITE_PHOTO_FRAME_INTERVAL=15000
|
||||||
|
# Path to photos directory on the kiosk (set via PHOTOS_PATH env var for the
|
||||||
|
# Electron process, not via Vite). Typically a mounted Samba share. Example:
|
||||||
|
# PHOTOS_PATH=/mnt/family-photos
|
||||||
|
# If unset, defaults to ~/Pictures/dashboard on the kiosk user's account.
|
||||||
|
|||||||
83
EMAIL_UPLOAD.md
Normal file
83
EMAIL_UPLOAD.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# Email-to-Photo-Frame Upload
|
||||||
|
|
||||||
|
Photos emailed to a dedicated mailbox are automatically saved to the
|
||||||
|
photo-frame Samba share, where the kiosk picks them up on its next scan
|
||||||
|
(PhotoManager rescans every 10 minutes).
|
||||||
|
|
||||||
|
## Three moving parts
|
||||||
|
|
||||||
|
### 1. Mailbox to receive photos
|
||||||
|
|
||||||
|
Easiest path: a dedicated Gmail account (e.g. `deathstarhomephotos@gmail.com`).
|
||||||
|
|
||||||
|
- Enable 2FA on the Google account
|
||||||
|
- Generate an **app password** under Security → App passwords (needed for IMAP,
|
||||||
|
since n8n's IMAP node uses username/password auth)
|
||||||
|
- Share the address with family members
|
||||||
|
|
||||||
|
Lock it down in the n8n workflow by whitelisting sender addresses; reject
|
||||||
|
anything else so strangers can't spam the frame.
|
||||||
|
|
||||||
|
### 2. Samba share visible to n8n
|
||||||
|
|
||||||
|
n8n runs on 192.168.1.254 and needs to write to the same Samba share the
|
||||||
|
kiosk reads from. Two options:
|
||||||
|
|
||||||
|
**Option A — Mount the share inside the n8n container**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On the n8n LXC host (192.168.1.254), install cifs-utils and mount:
|
||||||
|
apt install -y cifs-utils
|
||||||
|
mkdir -p /mnt/family-photos
|
||||||
|
echo '//SERVER/SHARE /mnt/family-photos cifs credentials=/etc/samba/credentials-n8n,uid=1000,gid=1000,rw,_netdev,x-systemd.automount 0 0' >> /etc/fstab
|
||||||
|
# credentials file with chmod 600
|
||||||
|
mount -a
|
||||||
|
```
|
||||||
|
|
||||||
|
Then bind-mount `/mnt/family-photos` into the n8n Docker container so the
|
||||||
|
Write Binary File node can target it at a path like `/data/family-photos/`.
|
||||||
|
|
||||||
|
**Option B — SSH to the fileshare LXC (192.168.1.193)**
|
||||||
|
|
||||||
|
Give n8n an SSH key to the fileshare host. Use the Execute Command node to
|
||||||
|
`scp` / `cat > file` onto the share. Simpler to configure than cifs, but
|
||||||
|
adds SSH surface area on the fileshare box.
|
||||||
|
|
||||||
|
### 3. n8n workflow
|
||||||
|
|
||||||
|
Pseudocode:
|
||||||
|
|
||||||
|
```
|
||||||
|
IMAP Email Trigger (mark read, download attachments, poll every 2 min)
|
||||||
|
→ IF From ∈ [your-email, wife-email]
|
||||||
|
→ Split attachments
|
||||||
|
→ IF mimeType startsWith "image/"
|
||||||
|
→ Rename: ${yyyyMMdd_HHmmss}_${randomHex}.${ext}
|
||||||
|
→ Write Binary File → /data/family-photos/${filename}
|
||||||
|
```
|
||||||
|
|
||||||
|
Nodes used:
|
||||||
|
- **Email Trigger (IMAP)** — reads Gmail (server `imap.gmail.com`, port 993, SSL)
|
||||||
|
- **IF** — sender whitelist guard
|
||||||
|
- **Item Lists / Split In Batches** — one item per attachment
|
||||||
|
- **IF** — filter on `$binary.attachment_0.mimeType`
|
||||||
|
- **Set** — build filename
|
||||||
|
- **Write Binary File** — write to the mount path
|
||||||
|
|
||||||
|
Kiosk picks them up on the next rescan (up to 10 min), or restart the app
|
||||||
|
to force an immediate scan.
|
||||||
|
|
||||||
|
## Hardening
|
||||||
|
|
||||||
|
- Limit attachment size (e.g. reject > 30 MB per file) to avoid filling
|
||||||
|
the share
|
||||||
|
- Strip EXIF if you don't want location data persisted on the share
|
||||||
|
- Keep an audit trail: add an extra branch that appends sender + filename
|
||||||
|
to a log file
|
||||||
|
|
||||||
|
## Credentials I'll need to build the workflow
|
||||||
|
|
||||||
|
- Gmail address + app password (or OAuth refresh token)
|
||||||
|
- Which option above for the Samba mount (A or B)
|
||||||
|
- If option B, SSH key + fileshare path to write to
|
||||||
|
- List of allowed sender emails
|
||||||
78
PHOTO_FRAME.md
Normal file
78
PHOTO_FRAME.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Photo Frame (Digital Picture Frame)
|
||||||
|
|
||||||
|
After `VITE_PHOTO_FRAME_IDLE_TIMEOUT` ms of no touch/mouse/keyboard activity
|
||||||
|
(default 5 minutes), the dashboard is covered by a full-screen photo frame
|
||||||
|
that cycles images from a local directory with a clock overlay. Touching the
|
||||||
|
screen exits idle and returns to the dashboard. Motion detected by the
|
||||||
|
Electron `MotionDetector` also cancels idle.
|
||||||
|
|
||||||
|
## Photo source
|
||||||
|
|
||||||
|
The Electron main process reads photos from the directory set in the
|
||||||
|
`PHOTOS_PATH` environment variable. If unset, it falls back to
|
||||||
|
`~/Pictures/dashboard`. Files are served via a `photo://` custom protocol
|
||||||
|
so the renderer never exposes raw filesystem paths.
|
||||||
|
|
||||||
|
Supported extensions: jpg, jpeg, png, webp, gif, heic, heif. Subdirectories
|
||||||
|
are scanned up to two levels deep.
|
||||||
|
|
||||||
|
## Mounting the Samba share on the kiosk
|
||||||
|
|
||||||
|
On the kiosk (192.168.1.190):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Install cifs-utils
|
||||||
|
sudo apt install -y cifs-utils
|
||||||
|
|
||||||
|
# 2. Create a credentials file (chmod 600)
|
||||||
|
sudo tee /etc/samba/credentials-dashboard <<'EOF'
|
||||||
|
username=YOUR_SAMBA_USER
|
||||||
|
password=YOUR_SAMBA_PASSWORD
|
||||||
|
EOF
|
||||||
|
sudo chmod 600 /etc/samba/credentials-dashboard
|
||||||
|
|
||||||
|
# 3. Create mountpoint
|
||||||
|
sudo mkdir -p /mnt/family-photos
|
||||||
|
|
||||||
|
# 4. Add to /etc/fstab (replace server/share with actual values)
|
||||||
|
echo '//SERVER_IP/SHARE_NAME /mnt/family-photos cifs credentials=/etc/samba/credentials-dashboard,uid=1000,gid=1000,iocharset=utf8,vers=3.0,ro,_netdev,x-systemd.automount 0 0' | sudo tee -a /etc/fstab
|
||||||
|
|
||||||
|
# 5. Mount
|
||||||
|
sudo mount -a
|
||||||
|
ls /mnt/family-photos
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pointing the app at the share
|
||||||
|
|
||||||
|
Option A — systemd unit (if you run the app as a service):
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# /etc/systemd/system/imperial-command-center.service (append to [Service])
|
||||||
|
Environment=PHOTOS_PATH=/mnt/family-photos
|
||||||
|
```
|
||||||
|
|
||||||
|
Then `sudo systemctl daemon-reload && sudo systemctl restart imperial-command-center`.
|
||||||
|
|
||||||
|
Option B — desktop launcher (if started from the GUI):
|
||||||
|
|
||||||
|
Edit `~/Desktop/imperial-command-center.desktop` and set:
|
||||||
|
|
||||||
|
```
|
||||||
|
Exec=env PHOTOS_PATH=/mnt/family-photos /opt/imperial-command-center/imperial-command-center --no-sandbox --ozone-platform=wayland
|
||||||
|
```
|
||||||
|
|
||||||
|
Option C — shell wrapper used by current deployment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u chrisryn PHOTOS_PATH=/mnt/family-photos \
|
||||||
|
XDG_RUNTIME_DIR=/run/user/1000 WAYLAND_DISPLAY=wayland-0 \
|
||||||
|
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \
|
||||||
|
/opt/imperial-command-center/imperial-command-center --no-sandbox --ozone-platform=wayland
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tuning
|
||||||
|
|
||||||
|
- `VITE_PHOTO_FRAME_IDLE_TIMEOUT` — idle timeout in ms (default 300000)
|
||||||
|
- `VITE_PHOTO_FRAME_INTERVAL` — ms between photo transitions (default 15000)
|
||||||
|
|
||||||
|
These are baked at build time.
|
||||||
@@ -1,18 +1,28 @@
|
|||||||
import { app, BrowserWindow, ipcMain, screen, powerSaveBlocker } from 'electron';
|
import { app, BrowserWindow, ipcMain, screen, powerSaveBlocker } from 'electron';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
import * as http from 'http';
|
||||||
import { ScreenManager } from './services/ScreenManager';
|
import { ScreenManager } from './services/ScreenManager';
|
||||||
import { PresenceDetector } from './services/PresenceDetector';
|
import { PresenceDetector } from './services/PresenceDetector';
|
||||||
import { FrigateStreamer } from './services/FrigateStreamer';
|
import { FrigateStreamer } from './services/FrigateStreamer';
|
||||||
import { MotionDetector } from './services/MotionDetector';
|
import { PhotoManager } from './services/PhotoManager';
|
||||||
|
import { FrigateDetector } from './services/FrigateDetector';
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
let screenManager: ScreenManager | null = null;
|
let screenManager: ScreenManager | null = null;
|
||||||
let presenceDetector: PresenceDetector | null = null;
|
let presenceDetector: PresenceDetector | null = null;
|
||||||
let frigateStreamer: FrigateStreamer | null = null;
|
let frigateStreamer: FrigateStreamer | null = null;
|
||||||
let motionDetector: MotionDetector | null = null;
|
let photoManager: PhotoManager | null = null;
|
||||||
|
let frigateDetector: FrigateDetector | null = null;
|
||||||
let powerSaveBlockerId: number | null = null;
|
let powerSaveBlockerId: number | null = null;
|
||||||
|
|
||||||
|
// Photos directory: env var PHOTOS_PATH wins, else fall back to ~/Pictures/dashboard
|
||||||
|
function resolvePhotosDir(): string {
|
||||||
|
const env = process.env.PHOTOS_PATH;
|
||||||
|
if (env && env.trim()) return env.trim();
|
||||||
|
return path.join(app.getPath('home'), 'Pictures', 'dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
// Check if we're in dev mode: explicitly set NODE_ENV or running from source with vite dev server
|
// Check if we're in dev mode: explicitly set NODE_ENV or running from source with vite dev server
|
||||||
// When running from unpacked folder, app.isPackaged is false but we still want production behavior
|
// When running from unpacked folder, app.isPackaged is false but we still want production behavior
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
@@ -30,7 +40,7 @@ function createWindow(): void {
|
|||||||
kiosk: !isDev, // Kiosk mode in production
|
kiosk: !isDev, // Kiosk mode in production
|
||||||
frame: false,
|
frame: false,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
backgroundColor: '#0a0a0a',
|
backgroundColor: '#faf6f0',
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: path.join(__dirname, 'preload.js'),
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
@@ -54,22 +64,23 @@ function createWindow(): void {
|
|||||||
// Initialize services
|
// Initialize services
|
||||||
screenManager = new ScreenManager(mainWindow);
|
screenManager = new ScreenManager(mainWindow);
|
||||||
|
|
||||||
// Initialize motion detector (runs in main process, not throttled by browser)
|
// Photo frame slideshow source
|
||||||
// Uses file size comparison which is more reliable for JPEG streams
|
photoManager = new PhotoManager(resolvePhotosDir());
|
||||||
motionDetector = new MotionDetector({
|
console.log(`PhotoManager: watching ${photoManager.getDir()}`);
|
||||||
go2rtcUrl: 'http://192.168.1.241:1985',
|
|
||||||
cameraName: 'Kitchen_Panel',
|
|
||||||
sensitivityThreshold: 5, // % file size change to trigger (5% = significant motion)
|
|
||||||
checkIntervalMs: 2000, // Check every 2 seconds for responsiveness
|
|
||||||
});
|
|
||||||
|
|
||||||
motionDetector.on('motion', () => {
|
// Frigate person detection via MQTT (bypasses HA entities)
|
||||||
console.log('MotionDetector: Motion detected, waking screen');
|
frigateDetector = new FrigateDetector({
|
||||||
screenManager?.wakeScreen();
|
mqttUrl: 'mqtt://192.168.1.50:1883',
|
||||||
mainWindow?.webContents.send('motion:detected');
|
mqttUser: 'mqtt',
|
||||||
|
mqttPassword: '11xpfcryan',
|
||||||
|
topicPrefix: 'frigate',
|
||||||
|
cameras: ['Front_Porch', 'FPE', 'Porch_Downstairs', 'Driveway_door'],
|
||||||
});
|
});
|
||||||
|
frigateDetector.on('personDetected', (camera: string) => {
|
||||||
motionDetector.start();
|
console.log(`FrigateDetector: person on ${camera}`);
|
||||||
|
mainWindow?.webContents.send('frigate:personDetected', camera);
|
||||||
|
});
|
||||||
|
frigateDetector.start();
|
||||||
|
|
||||||
// Handle window close
|
// Handle window close
|
||||||
mainWindow.on('closed', () => {
|
mainWindow.on('closed', () => {
|
||||||
@@ -174,6 +185,24 @@ function setupIpcHandlers(): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Photo frame
|
||||||
|
ipcMain.handle('photos:list', async () => {
|
||||||
|
return photoManager?.list() ?? [];
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('photos:getDir', async () => {
|
||||||
|
return photoManager?.getDir() ?? '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Given a relative filename returned by photos:list, return an absolute
|
||||||
|
// file:// URL the renderer can drop into an <img src>. Returns null if the
|
||||||
|
// file is missing or resolves outside the photos dir (traversal guard).
|
||||||
|
ipcMain.handle('photos:getUrl', async (_event, rel: string) => {
|
||||||
|
const full = photoManager?.resolve(rel);
|
||||||
|
if (!full || !fs.existsSync(full)) return null;
|
||||||
|
return 'file://' + full.split(path.sep).map((p, i) => (i === 0 && p === '' ? '' : encodeURIComponent(p))).join('/');
|
||||||
|
});
|
||||||
|
|
||||||
// App control
|
// App control
|
||||||
ipcMain.handle('app:quit', () => {
|
ipcMain.handle('app:quit', () => {
|
||||||
app.quit();
|
app.quit();
|
||||||
@@ -212,13 +241,41 @@ app.on('window-all-closed', () => {
|
|||||||
}
|
}
|
||||||
presenceDetector?.stop();
|
presenceDetector?.stop();
|
||||||
frigateStreamer?.stop();
|
frigateStreamer?.stop();
|
||||||
motionDetector?.stop();
|
frigateDetector?.stop();
|
||||||
|
|
||||||
if (process.platform !== 'darwin') {
|
if (process.platform !== 'darwin') {
|
||||||
app.quit();
|
app.quit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Localhost-only HTTP endpoint for self-screenshots. GNOME Wayland blocks
|
||||||
|
// external screen capture, but webContents.capturePage() works from inside.
|
||||||
|
// Usage: curl -s http://127.0.0.1:9990/screenshot > out.png
|
||||||
|
http
|
||||||
|
.createServer(async (req, res) => {
|
||||||
|
if (req.url === '/screenshot' && mainWindow) {
|
||||||
|
try {
|
||||||
|
const wc = mainWindow.webContents;
|
||||||
|
if (!wc.debugger.isAttached()) wc.debugger.attach('1.3');
|
||||||
|
const result = (await wc.debugger.sendCommand('Page.captureScreenshot', {
|
||||||
|
format: 'png',
|
||||||
|
captureBeyondViewport: false,
|
||||||
|
})) as { data: string };
|
||||||
|
res.setHeader('Content-Type', 'image/png');
|
||||||
|
res.end(Buffer.from(result.data, 'base64'));
|
||||||
|
} catch (err) {
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.end(String(err));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.listen(9990, '127.0.0.1', () => {
|
||||||
|
console.log('Screenshot endpoint on http://127.0.0.1:9990/screenshot');
|
||||||
|
});
|
||||||
|
|
||||||
// Handle uncaught exceptions
|
// Handle uncaught exceptions
|
||||||
process.on('uncaughtException', (error) => {
|
process.on('uncaughtException', (error) => {
|
||||||
console.error('Uncaught exception:', error);
|
console.error('Uncaught exception:', error);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface ElectronAPI {
|
|||||||
frigate: {
|
frigate: {
|
||||||
startStream: (rtspUrl: string) => Promise<boolean>;
|
startStream: (rtspUrl: string) => Promise<boolean>;
|
||||||
stopStream: () => Promise<boolean>;
|
stopStream: () => Promise<boolean>;
|
||||||
|
onPersonDetected: (callback: (camera: string) => void) => () => void;
|
||||||
};
|
};
|
||||||
app: {
|
app: {
|
||||||
quit: () => void;
|
quit: () => void;
|
||||||
@@ -26,6 +27,11 @@ export interface ElectronAPI {
|
|||||||
getStoredToken: () => Promise<string | null>;
|
getStoredToken: () => Promise<string | null>;
|
||||||
getJellyfinApiKey: () => Promise<string | null>;
|
getJellyfinApiKey: () => Promise<string | null>;
|
||||||
};
|
};
|
||||||
|
photos: {
|
||||||
|
list: () => Promise<string[]>;
|
||||||
|
getDir: () => Promise<string>;
|
||||||
|
getUrl: (relative: string) => Promise<string | null>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const electronAPI: ElectronAPI = {
|
const electronAPI: ElectronAPI = {
|
||||||
@@ -52,6 +58,11 @@ const electronAPI: ElectronAPI = {
|
|||||||
frigate: {
|
frigate: {
|
||||||
startStream: (rtspUrl: string) => ipcRenderer.invoke('frigate:startStream', rtspUrl),
|
startStream: (rtspUrl: string) => ipcRenderer.invoke('frigate:startStream', rtspUrl),
|
||||||
stopStream: () => ipcRenderer.invoke('frigate:stopStream'),
|
stopStream: () => ipcRenderer.invoke('frigate:stopStream'),
|
||||||
|
onPersonDetected: (callback: (camera: string) => void) => {
|
||||||
|
const handler = (_event: IpcRendererEvent, camera: string) => callback(camera);
|
||||||
|
ipcRenderer.on('frigate:personDetected', handler);
|
||||||
|
return () => ipcRenderer.removeListener('frigate:personDetected', handler);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
app: {
|
app: {
|
||||||
quit: () => ipcRenderer.invoke('app:quit'),
|
quit: () => ipcRenderer.invoke('app:quit'),
|
||||||
@@ -62,6 +73,11 @@ const electronAPI: ElectronAPI = {
|
|||||||
getStoredToken: () => ipcRenderer.invoke('config:getStoredToken'),
|
getStoredToken: () => ipcRenderer.invoke('config:getStoredToken'),
|
||||||
getJellyfinApiKey: () => ipcRenderer.invoke('config:getJellyfinApiKey'),
|
getJellyfinApiKey: () => ipcRenderer.invoke('config:getJellyfinApiKey'),
|
||||||
},
|
},
|
||||||
|
photos: {
|
||||||
|
list: () => ipcRenderer.invoke('photos:list'),
|
||||||
|
getDir: () => ipcRenderer.invoke('photos:getDir'),
|
||||||
|
getUrl: (relative: string) => ipcRenderer.invoke('photos:getUrl', relative),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('electronAPI', electronAPI);
|
contextBridge.exposeInMainWorld('electronAPI', electronAPI);
|
||||||
|
|||||||
106
electron/services/FrigateDetector.ts
Normal file
106
electron/services/FrigateDetector.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import mqtt from 'mqtt';
|
||||||
|
|
||||||
|
interface FrigateDetectorConfig {
|
||||||
|
mqttUrl: string;
|
||||||
|
mqttUser?: string;
|
||||||
|
mqttPassword?: string;
|
||||||
|
topicPrefix?: string;
|
||||||
|
cameras: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to Frigate's MQTT events directly and emits 'personDetected'
|
||||||
|
* with the camera name when a new person event starts on a watched camera.
|
||||||
|
*/
|
||||||
|
export class FrigateDetector extends EventEmitter {
|
||||||
|
private client: mqtt.MqttClient | null = null;
|
||||||
|
private config: FrigateDetectorConfig;
|
||||||
|
private seenEvents = new Set<string>();
|
||||||
|
|
||||||
|
constructor(config: FrigateDetectorConfig) {
|
||||||
|
super();
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
const { mqttUrl, mqttUser, mqttPassword, topicPrefix = 'frigate' } = this.config;
|
||||||
|
const opts: mqtt.IClientOptions = {
|
||||||
|
clientId: `icc-frigate-${Date.now()}`,
|
||||||
|
reconnectPeriod: 5000,
|
||||||
|
connectTimeout: 10000,
|
||||||
|
};
|
||||||
|
if (mqttUser) opts.username = mqttUser;
|
||||||
|
if (mqttPassword) opts.password = mqttPassword;
|
||||||
|
|
||||||
|
this.client = mqtt.connect(mqttUrl, opts);
|
||||||
|
|
||||||
|
this.client.on('connect', () => {
|
||||||
|
console.log('FrigateDetector: MQTT connected');
|
||||||
|
// Subscribe to per-camera person topic for each watched camera
|
||||||
|
for (const cam of this.config.cameras) {
|
||||||
|
this.client!.subscribe(`${topicPrefix}/${cam}/person`, { qos: 0 });
|
||||||
|
}
|
||||||
|
// Also subscribe to events topic for richer event data
|
||||||
|
this.client!.subscribe(`${topicPrefix}/events`, { qos: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on('message', (topic: string, payload: Buffer) => {
|
||||||
|
try {
|
||||||
|
const prefix = this.config.topicPrefix || 'frigate';
|
||||||
|
|
||||||
|
// frigate/<camera>/person → payload is a count (0 or 1+)
|
||||||
|
const personMatch = topic.match(new RegExp(`^${prefix}/(.+)/person$`));
|
||||||
|
if (personMatch) {
|
||||||
|
const cam = personMatch[1];
|
||||||
|
const count = parseInt(payload.toString(), 10);
|
||||||
|
if (count > 0) {
|
||||||
|
const key = `person-${cam}`;
|
||||||
|
if (!this.seenEvents.has(key)) {
|
||||||
|
this.seenEvents.add(key);
|
||||||
|
console.log(`FrigateDetector: person detected on ${cam}`);
|
||||||
|
this.emit('personDetected', cam);
|
||||||
|
// Clear after 60s so a new detection can trigger again
|
||||||
|
setTimeout(() => this.seenEvents.delete(key), 60000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Person left — allow re-trigger
|
||||||
|
this.seenEvents.delete(`person-${cam}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// frigate/events → JSON with event details
|
||||||
|
if (topic === `${prefix}/events`) {
|
||||||
|
const data = JSON.parse(payload.toString());
|
||||||
|
const after = data?.after || data;
|
||||||
|
if (after?.label === 'person' && data?.type === 'new') {
|
||||||
|
const cam = after.camera;
|
||||||
|
if (this.config.cameras.some((c) => c.toLowerCase() === cam?.toLowerCase())) {
|
||||||
|
const key = `event-${after.id}`;
|
||||||
|
if (!this.seenEvents.has(key)) {
|
||||||
|
this.seenEvents.add(key);
|
||||||
|
this.emit('personDetected', cam);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('FrigateDetector: parse error', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on('error', (err: Error) => {
|
||||||
|
console.error('FrigateDetector: MQTT error', err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on('reconnect', () => {
|
||||||
|
console.log('FrigateDetector: MQTT reconnecting...');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.client?.end(true);
|
||||||
|
this.client = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
53
electron/services/PhotoManager.ts
Normal file
53
electron/services/PhotoManager.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.heic', '.heif']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists image files from the configured photos directory.
|
||||||
|
* Recurses one level deep so users can organise photos into subfolders.
|
||||||
|
*/
|
||||||
|
export class PhotoManager {
|
||||||
|
constructor(private photosDir: string) {}
|
||||||
|
|
||||||
|
setDir(dir: string): void {
|
||||||
|
this.photosDir = dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDir(): string {
|
||||||
|
return this.photosDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): string[] {
|
||||||
|
if (!this.photosDir || !fs.existsSync(this.photosDir)) return [];
|
||||||
|
const files: string[] = [];
|
||||||
|
try {
|
||||||
|
this.walk(this.photosDir, files, 0);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('PhotoManager: failed to list photos', err);
|
||||||
|
}
|
||||||
|
// Return paths relative to photosDir so the renderer can build photo:// URLs
|
||||||
|
return files.map((f) => path.relative(this.photosDir, f));
|
||||||
|
}
|
||||||
|
|
||||||
|
private walk(dir: string, out: string[], depth: number): void {
|
||||||
|
if (depth > 2) return;
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (entry.name.startsWith('.')) continue;
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
this.walk(full, out, depth + 1);
|
||||||
|
} else if (entry.isFile() && IMAGE_EXTS.has(path.extname(entry.name).toLowerCase())) {
|
||||||
|
out.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(relative: string): string | null {
|
||||||
|
if (!this.photosDir) return null;
|
||||||
|
const safe = path.normalize(relative).replace(/^(\.\.[\/\\])+/, '');
|
||||||
|
const full = path.join(this.photosDir, safe);
|
||||||
|
if (!full.startsWith(this.photosDir)) return null;
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,10 +12,8 @@ const DBUS_ENV = {
|
|||||||
|
|
||||||
export class ScreenManager {
|
export class ScreenManager {
|
||||||
private window: BrowserWindow;
|
private window: BrowserWindow;
|
||||||
private idleTimeout: number = 300000; // 5 minutes default
|
|
||||||
private idleTimer: NodeJS.Timeout | null = null;
|
private idleTimer: NodeJS.Timeout | null = null;
|
||||||
private isScreenOn: boolean = true;
|
private isScreenOn: boolean = true;
|
||||||
private lastActivity: number = Date.now();
|
|
||||||
private screenControlAvailable: boolean = true;
|
private screenControlAvailable: boolean = true;
|
||||||
private isWayland: boolean = false;
|
private isWayland: boolean = false;
|
||||||
|
|
||||||
@@ -23,7 +21,8 @@ export class ScreenManager {
|
|||||||
this.window = window;
|
this.window = window;
|
||||||
this.detectDisplayServer();
|
this.detectDisplayServer();
|
||||||
this.setupActivityListeners();
|
this.setupActivityListeners();
|
||||||
this.startIdleMonitor();
|
// Idle monitor intentionally disabled: monitor stays on; photo frame
|
||||||
|
// handles the visual idle state instead.
|
||||||
}
|
}
|
||||||
|
|
||||||
private detectDisplayServer(): void {
|
private detectDisplayServer(): void {
|
||||||
@@ -44,31 +43,19 @@ export class ScreenManager {
|
|||||||
|
|
||||||
// Called from renderer when touch/click detected
|
// Called from renderer when touch/click detected
|
||||||
public handleUserActivity(): void {
|
public handleUserActivity(): void {
|
||||||
this.lastActivity = Date.now();
|
|
||||||
// Always try to wake the screen on touch/click - user is actively interacting
|
|
||||||
if (!this.isScreenOn) {
|
if (!this.isScreenOn) {
|
||||||
this.wakeScreen();
|
this.wakeScreen();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private startIdleMonitor(): void {
|
|
||||||
this.idleTimer = setInterval(() => {
|
|
||||||
const idleTime = Date.now() - this.lastActivity;
|
|
||||||
if (idleTime >= this.idleTimeout && this.isScreenOn && this.screenControlAvailable) {
|
|
||||||
this.sleepScreen();
|
|
||||||
}
|
|
||||||
}, 30000); // Check every 30 seconds (not 10) to reduce spam
|
|
||||||
}
|
|
||||||
|
|
||||||
private resetIdleTimer(): void {
|
private resetIdleTimer(): void {
|
||||||
this.lastActivity = Date.now();
|
|
||||||
if (!this.isScreenOn) {
|
if (!this.isScreenOn) {
|
||||||
this.wakeScreen();
|
this.wakeScreen();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setIdleTimeout(timeout: number): void {
|
setIdleTimeout(_timeout: number): void {
|
||||||
this.idleTimeout = timeout;
|
// no-op: idle monitor is disabled; monitor stays on
|
||||||
}
|
}
|
||||||
|
|
||||||
async wakeScreen(): Promise<void> {
|
async wakeScreen(): Promise<void> {
|
||||||
@@ -164,7 +151,6 @@ export class ScreenManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.isScreenOn = true;
|
this.isScreenOn = true;
|
||||||
this.lastActivity = Date.now();
|
|
||||||
this.window.webContents.send('screen:woke');
|
this.window.webContents.send('screen:woke');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to wake screen:', error);
|
console.error('Failed to wake screen:', error);
|
||||||
|
|||||||
8091
package-lock.json
generated
Normal file
8091
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@@ -18,26 +18,27 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"home-assistant-js-websocket": "^9.4.0",
|
|
||||||
"zustand": "^4.5.0",
|
|
||||||
"googleapis": "^131.0.0",
|
|
||||||
"@tensorflow/tfjs": "^4.17.0",
|
|
||||||
"@tensorflow-models/coco-ssd": "^2.2.3",
|
"@tensorflow-models/coco-ssd": "^2.2.3",
|
||||||
|
"@tensorflow/tfjs": "^4.17.0",
|
||||||
|
"date-fns": "^3.3.1",
|
||||||
"electron-store": "^8.1.0",
|
"electron-store": "^8.1.0",
|
||||||
"date-fns": "^3.3.1"
|
"googleapis": "^131.0.0",
|
||||||
|
"home-assistant-js-websocket": "^9.4.0",
|
||||||
|
"mqtt": "^5.15.1",
|
||||||
|
"zustand": "^4.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.11.0",
|
"@types/node": "^20.11.0",
|
||||||
"@types/react": "^18.2.48",
|
"@types/react": "^18.2.48",
|
||||||
"@types/react-dom": "^18.2.18",
|
"@types/react-dom": "^18.2.18",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||||
|
"@typescript-eslint/parser": "^6.19.0",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"autoprefixer": "^10.4.17",
|
"autoprefixer": "^10.4.17",
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
"electron": "^28.2.0",
|
"electron": "^28.2.0",
|
||||||
"electron-builder": "^24.9.1",
|
"electron-builder": "^24.9.1",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
|
||||||
"@typescript-eslint/parser": "^6.19.0",
|
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"postcss": "^8.4.33",
|
"postcss": "^8.4.33",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
|||||||
145
src/App.tsx
145
src/App.tsx
@@ -1,35 +1,40 @@
|
|||||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { Dashboard } from '@/components/layout';
|
import { Dashboard } from '@/components/layout';
|
||||||
import { ThermostatOverlay } from '@/components/climate';
|
import { ThermostatOverlay } from '@/components/climate';
|
||||||
import { LightsOverlay } from '@/components/lights';
|
import { LightsOverlay } from '@/components/lights';
|
||||||
import { LocksOverlay } from '@/components/locks';
|
import { LocksOverlay } from '@/components/locks';
|
||||||
import { AlarmoPanel } from '@/components/alarm';
|
import { ControlsOverlay } from '@/components/controls';
|
||||||
import { CalendarWidget } from '@/components/calendar';
|
import { CalendarWidget } from '@/components/calendar';
|
||||||
import { TodoWidget } from '@/components/todo';
|
import { TodoWidget } from '@/components/todo';
|
||||||
|
import { ChoreChart } from '@/components/chores';
|
||||||
import { SettingsPanel, ConnectionModal } from '@/components/settings';
|
import { SettingsPanel, ConnectionModal } from '@/components/settings';
|
||||||
import { CameraOverlay } from '@/components/cameras';
|
import { CameraOverlay } from '@/components/cameras';
|
||||||
import { CameraFeed } from '@/components/cameras/CameraFeed';
|
import { CameraFeed } from '@/components/cameras/CameraFeed';
|
||||||
import { JellyfinOverlay } from '@/components/media';
|
import { JellyfinOverlay } from '@/components/media';
|
||||||
import { GlobalKeyboard } from '@/components/keyboard';
|
import { GlobalKeyboard } from '@/components/keyboard';
|
||||||
|
import { PhotoFrame } from '@/components/photoframe';
|
||||||
import { useHomeAssistant } from '@/hooks';
|
import { useHomeAssistant } from '@/hooks';
|
||||||
// Motion detection now runs in Electron main process (MotionDetector.ts)
|
import { useIdle } from '@/hooks/useIdle';
|
||||||
// import { useSimpleMotion } from '@/hooks/useSimpleMotion';
|
import { useHAStore } from '@/stores/haStore';
|
||||||
import { useHAStore, useEntityAttribute } from '@/stores/haStore';
|
|
||||||
import { useUIStore, useCameraOverlay } from '@/stores/uiStore';
|
import { useUIStore, useCameraOverlay } from '@/stores/uiStore';
|
||||||
import { useSettingsStore } from '@/stores/settingsStore';
|
import { useSettingsStore } from '@/stores/settingsStore';
|
||||||
import { env } from '@/config/environment';
|
import { env } from '@/config/environment';
|
||||||
|
|
||||||
// Front porch alert overlay - shows for 30 seconds when person detected
|
|
||||||
function FrontPorchAlert({ onClose }: { onClose: () => void }) {
|
// 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 cameras = useSettingsStore((state) => state.config.cameras);
|
||||||
const frontPorchCamera = cameras.find((c) => c.name === 'Front_Porch');
|
const cameraLower = cameraName.toLowerCase();
|
||||||
|
const camera = cameras.find(
|
||||||
|
(c) => c.frigateCamera?.toLowerCase() === cameraLower || c.name.toLowerCase() === cameraLower,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(onClose, 30000); // 30 seconds
|
const timer = setTimeout(onClose, 30000);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
if (!frontPorchCamera) return null;
|
if (!camera) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 bg-black flex flex-col">
|
<div className="fixed inset-0 z-50 bg-black flex flex-col">
|
||||||
@@ -37,7 +42,7 @@ function FrontPorchAlert({ onClose }: { onClose: () => void }) {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-3 h-3 rounded-full bg-status-warning animate-pulse" />
|
<div className="w-3 h-3 rounded-full bg-status-warning animate-pulse" />
|
||||||
<h2 className="text-lg font-semibold text-status-warning">
|
<h2 className="text-lg font-semibold text-status-warning">
|
||||||
Person Detected - Front Porch
|
Person Detected - {camera.displayName}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -51,7 +56,7 @@ function FrontPorchAlert({ onClose }: { onClose: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 p-2">
|
<div className="flex-1 p-2">
|
||||||
<CameraFeed
|
<CameraFeed
|
||||||
camera={frontPorchCamera}
|
camera={camera}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
showLabel={false}
|
showLabel={false}
|
||||||
/>
|
/>
|
||||||
@@ -60,12 +65,6 @@ function FrontPorchAlert({ onClose }: { onClose: () => void }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple thermostat temp display
|
|
||||||
function ThermostatTemp({ entityId }: { entityId: string }) {
|
|
||||||
const currentTemp = useEntityAttribute<number>(entityId, 'current_temperature');
|
|
||||||
return <>{currentTemp?.toFixed(0) ?? '--'}°</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConnectionPrompt() {
|
function ConnectionPrompt() {
|
||||||
const openSettings = useUIStore((state) => state.openSettings);
|
const openSettings = useUIStore((state) => state.openSettings);
|
||||||
|
|
||||||
@@ -93,93 +92,43 @@ function ConnectionPrompt() {
|
|||||||
|
|
||||||
function DashboardContent() {
|
function DashboardContent() {
|
||||||
const config = useSettingsStore((state) => state.config);
|
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 (
|
return (
|
||||||
<>
|
<div className="col-span-12 flex flex-row gap-3 min-h-0">
|
||||||
{/* Left Column - Calendar (spans 2 columns) */}
|
|
||||||
<div className="col-span-8 flex flex-col gap-4">
|
|
||||||
{config.calendar && (
|
{config.calendar && (
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-[4] min-w-0">
|
||||||
<CalendarWidget />
|
<CalendarWidget />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
<div className="flex-1 min-w-0 flex flex-col gap-3">
|
||||||
|
|
||||||
{/* 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 && (
|
{config.todoList && (
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0">
|
||||||
<TodoWidget />
|
<TodoWidget />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
<ChoreChart />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { isConnected, connectionState } = useHomeAssistant();
|
const { connectionState } = useHomeAssistant();
|
||||||
const accessToken = useHAStore((state) => state.accessToken);
|
const accessToken = useHAStore((state) => state.accessToken);
|
||||||
const connect = useHAStore((state) => state.connect);
|
const connect = useHAStore((state) => state.connect);
|
||||||
const entities = useHAStore((state) => state.entities);
|
|
||||||
const settingsOpen = useUIStore((state) => state.settingsOpen);
|
const settingsOpen = useUIStore((state) => state.settingsOpen);
|
||||||
const lightsOverlayOpen = useUIStore((state) => state.lightsOverlayOpen);
|
const lightsOverlayOpen = useUIStore((state) => state.lightsOverlayOpen);
|
||||||
const locksOverlayOpen = useUIStore((state) => state.locksOverlayOpen);
|
const locksOverlayOpen = useUIStore((state) => state.locksOverlayOpen);
|
||||||
const thermostatsOverlayOpen = useUIStore((state) => state.thermostatsOverlayOpen);
|
const thermostatsOverlayOpen = useUIStore((state) => state.thermostatsOverlayOpen);
|
||||||
const mediaOverlayOpen = useUIStore((state) => state.mediaOverlayOpen);
|
const mediaOverlayOpen = useUIStore((state) => state.mediaOverlayOpen);
|
||||||
|
const controlsOverlayOpen = useUIStore((state) => state.controlsOverlayOpen);
|
||||||
const { isOpen: cameraOverlayOpen } = useCameraOverlay();
|
const { isOpen: cameraOverlayOpen } = useCameraOverlay();
|
||||||
|
const isIdle = useIdle(env.photoFrameIdleTimeout);
|
||||||
|
|
||||||
// Front porch alert state
|
// Person detection alert state (via MQTT from Electron main process)
|
||||||
const [showFrontPorchAlert, setShowFrontPorchAlert] = useState(false);
|
const [alertCamera, setAlertCamera] = useState<string | null>(null);
|
||||||
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
|
// Report touch/click activity to main process for screen wake on Wayland
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -230,29 +179,19 @@ export default function App() {
|
|||||||
initConfig();
|
initConfig();
|
||||||
}, [accessToken, connect]);
|
}, [accessToken, connect]);
|
||||||
|
|
||||||
// Listen for Front Porch person detection - show full screen overlay for 30 seconds
|
// Listen for person detection via MQTT (from Electron main process)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isConnected) return;
|
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 frontPorchEntity = entities['binary_sensor.front_porch_person_occupancy'];
|
const closePersonAlert = useCallback(() => {
|
||||||
const isPersonDetected = frontPorchEntity?.state === 'on';
|
setAlertCamera(null);
|
||||||
|
|
||||||
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
|
// Set up screen idle timeout
|
||||||
@@ -319,9 +258,11 @@ export default function App() {
|
|||||||
{locksOverlayOpen && <LocksOverlay />}
|
{locksOverlayOpen && <LocksOverlay />}
|
||||||
{thermostatsOverlayOpen && <ThermostatOverlay />}
|
{thermostatsOverlayOpen && <ThermostatOverlay />}
|
||||||
{mediaOverlayOpen && <JellyfinOverlay />}
|
{mediaOverlayOpen && <JellyfinOverlay />}
|
||||||
|
{controlsOverlayOpen && <ControlsOverlay />}
|
||||||
{cameraOverlayOpen && <CameraOverlay />}
|
{cameraOverlayOpen && <CameraOverlay />}
|
||||||
{settingsOpen && <SettingsPanel />}
|
{settingsOpen && <SettingsPanel />}
|
||||||
{showFrontPorchAlert && <FrontPorchAlert onClose={closeFrontPorchAlert} />}
|
{isIdle && !alertCamera && <PhotoFrame intervalMs={env.photoFrameInterval} />}
|
||||||
|
{alertCamera && <PersonAlert cameraName={alertCamera} onClose={closePersonAlert} />}
|
||||||
<GlobalKeyboard />
|
<GlobalKeyboard />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,10 +2,15 @@ import { useState, useMemo, useCallback, useEffect } from 'react';
|
|||||||
import { format, startOfMonth, endOfMonth, startOfWeek, endOfWeek, addDays, isSameMonth, isSameDay, isToday } from 'date-fns';
|
import { format, startOfMonth, endOfMonth, startOfWeek, endOfWeek, addDays, isSameMonth, isSameDay, isToday } from 'date-fns';
|
||||||
import { useCalendar, CalendarEvent, formatEventTime } from '@/hooks/useCalendar';
|
import { useCalendar, CalendarEvent, formatEventTime } from '@/hooks/useCalendar';
|
||||||
import { VirtualKeyboard } from '@/components/keyboard';
|
import { VirtualKeyboard } from '@/components/keyboard';
|
||||||
|
import { getEventColor } from './eventColors';
|
||||||
|
|
||||||
function EventItem({ event }: { event: CalendarEvent }) {
|
function EventItem({ event }: { event: CalendarEvent }) {
|
||||||
|
const color = getEventColor(event.summary);
|
||||||
return (
|
return (
|
||||||
<div className="text-[0.6rem] truncate px-1 py-0.5 rounded bg-accent/20 text-accent-light">
|
<div
|
||||||
|
className="text-[0.6rem] truncate px-1 py-0.5 rounded"
|
||||||
|
style={{ backgroundColor: color.bg, color: color.text }}
|
||||||
|
>
|
||||||
{formatEventTime(event)} {event.summary}
|
{formatEventTime(event)} {event.summary}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -74,10 +79,16 @@ function EventDetails({ date, events, onAddEvent }: { date: Date; events: Calend
|
|||||||
<p className="text-xs text-gray-500">No events</p>
|
<p className="text-xs text-gray-500">No events</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5 max-h-24 overflow-y-auto">
|
<div className="space-y-1.5 max-h-24 overflow-y-auto">
|
||||||
{events.map((event) => (
|
{events.map((event) => {
|
||||||
|
const color = getEventColor(event.summary);
|
||||||
|
return (
|
||||||
<div key={event.id} className="text-xs">
|
<div key={event.id} className="text-xs">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-accent font-medium">
|
<span
|
||||||
|
className="inline-block w-2 h-2 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: color.bg }}
|
||||||
|
/>
|
||||||
|
<span className="font-medium" style={{ color: color.bg }}>
|
||||||
{formatEventTime(event)}
|
{formatEventTime(event)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1 truncate">{event.summary}</span>
|
<span className="flex-1 truncate">{event.summary}</span>
|
||||||
@@ -88,7 +99,8 @@ function EventDetails({ date, events, onAddEvent }: { date: Date; events: Calend
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
28
src/components/calendar/eventColors.ts
Normal file
28
src/components/calendar/eventColors.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// Per-person event colors. Any event whose summary contains one of the
|
||||||
|
// configured names (case-insensitive) renders in that person's color.
|
||||||
|
// Events that don't match a name fall back to a neutral slate color.
|
||||||
|
export interface PersonColor {
|
||||||
|
name: string;
|
||||||
|
bg: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PEOPLE: PersonColor[] = [
|
||||||
|
{ name: 'Becca', bg: '#14b8a6', text: '#ffffff' }, // teal-500
|
||||||
|
{ name: 'Chris', bg: '#22c55e', text: '#ffffff' }, // green-500
|
||||||
|
{ name: 'Arabella', bg: '#ec4899', text: '#ffffff' }, // pink-500
|
||||||
|
];
|
||||||
|
|
||||||
|
const FALLBACK: PersonColor = {
|
||||||
|
name: 'default',
|
||||||
|
bg: '#94a3b8', // slate-400 - muted pastel neutral
|
||||||
|
text: '#ffffff',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getEventColor(summary: string): PersonColor {
|
||||||
|
const lowered = summary.toLowerCase();
|
||||||
|
for (const p of PEOPLE) {
|
||||||
|
if (lowered.includes(p.name.toLowerCase())) return p;
|
||||||
|
}
|
||||||
|
return FALLBACK;
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
import { Go2RTCWebRTC } from '@/services/go2rtc';
|
import { Go2RTCWebRTC } from '@/services/go2rtc';
|
||||||
import { CameraConfig } from '@/config/entities';
|
import { CameraConfig } from '@/config/entities';
|
||||||
|
|
||||||
|
const RECONNECT_DELAY_MS = 3000;
|
||||||
|
const MAX_RECONNECT_DELAY_MS = 30000;
|
||||||
|
|
||||||
interface CameraFeedProps {
|
interface CameraFeedProps {
|
||||||
camera: CameraConfig;
|
camera: CameraConfig;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
@@ -21,50 +24,99 @@ export function CameraFeed({
|
|||||||
}: CameraFeedProps) {
|
}: CameraFeedProps) {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const webrtcRef = useRef<Go2RTCWebRTC | null>(null);
|
const webrtcRef = useRef<Go2RTCWebRTC | null>(null);
|
||||||
|
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
const reconnectDelayRef = useRef(RECONNECT_DELAY_MS);
|
||||||
|
const mountedRef = useRef(true);
|
||||||
const [isConnecting, setIsConnecting] = useState(true);
|
const [isConnecting, setIsConnecting] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Use substream for grid view (lower bandwidth)
|
// Use substream for grid view (lower bandwidth)
|
||||||
const streamName = useSubstream ? `${camera.go2rtcStream}_sub` : camera.go2rtcStream;
|
const streamName = useSubstream ? `${camera.go2rtcStream}_sub` : camera.go2rtcStream;
|
||||||
|
|
||||||
useEffect(() => {
|
const connect = useCallback(async () => {
|
||||||
let mounted = true;
|
if (!mountedRef.current) return;
|
||||||
let timeoutId: ReturnType<typeof setTimeout>;
|
|
||||||
|
|
||||||
const connect = async () => {
|
|
||||||
try {
|
try {
|
||||||
setIsConnecting(true);
|
setIsConnecting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
webrtcRef.current = new Go2RTCWebRTC(streamName);
|
// Disconnect previous instance
|
||||||
|
webrtcRef.current?.disconnect();
|
||||||
|
|
||||||
await webrtcRef.current.connect((stream) => {
|
const webrtc = new Go2RTCWebRTC(streamName);
|
||||||
if (mounted && videoRef.current) {
|
webrtcRef.current = webrtc;
|
||||||
|
|
||||||
|
await webrtc.connect(
|
||||||
|
// onTrack
|
||||||
|
(stream) => {
|
||||||
|
if (mountedRef.current && videoRef.current) {
|
||||||
videoRef.current.srcObject = stream;
|
videoRef.current.srcObject = stream;
|
||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
|
setError(null);
|
||||||
|
// Reset backoff on successful connection
|
||||||
|
reconnectDelayRef.current = RECONNECT_DELAY_MS;
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
// onDisconnect - schedule reconnect
|
||||||
|
() => {
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
console.log(`WebRTC disconnected for ${streamName}, reconnecting in ${reconnectDelayRef.current}ms`);
|
||||||
|
setError('Stream disconnected');
|
||||||
|
|
||||||
|
clearTimeout(reconnectTimerRef.current);
|
||||||
|
reconnectTimerRef.current = setTimeout(() => {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
}, reconnectDelayRef.current);
|
||||||
|
|
||||||
|
// Exponential backoff capped at max
|
||||||
|
reconnectDelayRef.current = Math.min(
|
||||||
|
reconnectDelayRef.current * 1.5,
|
||||||
|
MAX_RECONNECT_DELAY_MS,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (mounted) {
|
if (!mountedRef.current) return;
|
||||||
setError(err instanceof Error ? err.message : 'Failed to connect');
|
const msg = err instanceof Error ? err.message : 'Failed to connect';
|
||||||
|
setError(msg);
|
||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
|
|
||||||
|
// Retry on connection failure too
|
||||||
|
clearTimeout(reconnectTimerRef.current);
|
||||||
|
reconnectTimerRef.current = setTimeout(() => {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
connect();
|
||||||
}
|
}
|
||||||
|
}, reconnectDelayRef.current);
|
||||||
|
|
||||||
|
reconnectDelayRef.current = Math.min(
|
||||||
|
reconnectDelayRef.current * 1.5,
|
||||||
|
MAX_RECONNECT_DELAY_MS,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
}, [streamName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
|
reconnectDelayRef.current = RECONNECT_DELAY_MS;
|
||||||
|
let initTimer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
// Stagger connections to avoid overwhelming go2rtc
|
// Stagger connections to avoid overwhelming go2rtc
|
||||||
if (delayMs > 0) {
|
if (delayMs > 0) {
|
||||||
timeoutId = setTimeout(connect, delayMs);
|
initTimer = setTimeout(connect, delayMs);
|
||||||
} else {
|
} else {
|
||||||
connect();
|
connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
mountedRef.current = false;
|
||||||
if (timeoutId) clearTimeout(timeoutId);
|
clearTimeout(initTimer);
|
||||||
|
clearTimeout(reconnectTimerRef.current);
|
||||||
webrtcRef.current?.disconnect();
|
webrtcRef.current?.disconnect();
|
||||||
};
|
};
|
||||||
}, [streamName, delayMs]);
|
}, [connect, delayMs]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -93,13 +145,14 @@ export function CameraFeed({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error Overlay */}
|
{/* Error Overlay */}
|
||||||
{error && (
|
{error && !isConnecting && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-imperial-black/80">
|
<div className="absolute inset-0 flex items-center justify-center bg-imperial-black/80">
|
||||||
<div className="flex flex-col items-center gap-2 text-center p-4">
|
<div className="flex flex-col items-center gap-2 text-center p-4">
|
||||||
<svg className="w-8 h-8 text-status-error" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="w-8 h-8 text-status-error" 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" />
|
<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>
|
</svg>
|
||||||
<span className="text-sm text-gray-400">{error}</span>
|
<span className="text-sm text-gray-400">{error}</span>
|
||||||
|
<span className="text-xs text-gray-500">Reconnecting...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
171
src/components/chores/ChoreChart.tsx
Normal file
171
src/components/chores/ChoreChart.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { useHAStore } from '@/stores/haStore';
|
||||||
|
import { haConnection } from '@/services/homeAssistant/connection';
|
||||||
|
|
||||||
|
// Person color mapping (matches calendar eventColors)
|
||||||
|
const PEOPLE: Record<string, { bg: string; text: string }> = {
|
||||||
|
becca: { bg: '#14b8a6', text: '#ffffff' },
|
||||||
|
chris: { bg: '#22c55e', text: '#ffffff' },
|
||||||
|
arabella: { bg: '#ec4899', text: '#ffffff' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const FALLBACK_COLOR = { bg: '#94a3b8', text: '#ffffff' };
|
||||||
|
|
||||||
|
interface ChoreItem {
|
||||||
|
uid: string;
|
||||||
|
summary: string;
|
||||||
|
status: string;
|
||||||
|
person: string | null;
|
||||||
|
task: string;
|
||||||
|
color: { bg: string; text: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseChore(item: { uid: string; summary: string; status: string }): ChoreItem {
|
||||||
|
// Supports "Becca: Dishes", "Chris - Dishes", or just "Dishes"
|
||||||
|
let person: string | null = null;
|
||||||
|
let task = item.summary;
|
||||||
|
let color = FALLBACK_COLOR;
|
||||||
|
|
||||||
|
// Try splitting on ":" or " - "
|
||||||
|
for (const sep of [':', ' - ', ' – ']) {
|
||||||
|
const idx = item.summary.indexOf(sep);
|
||||||
|
if (idx > 0 && idx < 20) {
|
||||||
|
const prefix = item.summary.slice(0, idx).trim().toLowerCase();
|
||||||
|
if (PEOPLE[prefix]) {
|
||||||
|
person = item.summary.slice(0, idx).trim();
|
||||||
|
task = item.summary.slice(idx + sep.length).trim();
|
||||||
|
color = PEOPLE[prefix];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: check if any person name appears anywhere
|
||||||
|
if (!person) {
|
||||||
|
for (const [name, c] of Object.entries(PEOPLE)) {
|
||||||
|
if (item.summary.toLowerCase().includes(name)) {
|
||||||
|
person = name.charAt(0).toUpperCase() + name.slice(1);
|
||||||
|
color = c;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { uid: item.uid, summary: item.summary, status: item.status, person, task, color };
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHORE_ENTITY = 'todo.chores';
|
||||||
|
|
||||||
|
export function ChoreChart() {
|
||||||
|
const isConnected = useHAStore((s) => s.connectionState === 'connected');
|
||||||
|
const [chores, setChores] = useState<ChoreItem[]>([]);
|
||||||
|
|
||||||
|
const fetchChores = useCallback(async () => {
|
||||||
|
const conn = haConnection.getConnection();
|
||||||
|
if (!conn) return;
|
||||||
|
try {
|
||||||
|
const result = await conn.sendMessagePromise({
|
||||||
|
type: 'call_service',
|
||||||
|
domain: 'todo',
|
||||||
|
service: 'get_items',
|
||||||
|
target: { entity_id: CHORE_ENTITY },
|
||||||
|
return_response: true,
|
||||||
|
}) as { response?: Record<string, { items: Array<{ uid: string; summary: string; status: string }> }> };
|
||||||
|
const items = result?.response?.[CHORE_ENTITY]?.items ?? [];
|
||||||
|
setChores(items.map(parseChore));
|
||||||
|
} catch {
|
||||||
|
// Entity may not exist yet
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isConnected) return;
|
||||||
|
fetchChores();
|
||||||
|
const id = setInterval(fetchChores, 30000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [isConnected, fetchChores]);
|
||||||
|
|
||||||
|
const toggleChore = useCallback(async (uid: string, currentStatus: string) => {
|
||||||
|
const conn = haConnection.getConnection();
|
||||||
|
if (!conn) return;
|
||||||
|
const newStatus = currentStatus === 'completed' ? 'needs_action' : 'completed';
|
||||||
|
try {
|
||||||
|
await conn.sendMessagePromise({
|
||||||
|
type: 'call_service',
|
||||||
|
domain: 'todo',
|
||||||
|
service: 'update_item',
|
||||||
|
target: { entity_id: CHORE_ENTITY },
|
||||||
|
service_data: { item: uid, status: newStatus },
|
||||||
|
});
|
||||||
|
fetchChores();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [fetchChores]);
|
||||||
|
|
||||||
|
if (!chores.length) {
|
||||||
|
return (
|
||||||
|
<div className="widget h-full">
|
||||||
|
<div className="widget-title">
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||||
|
</svg>
|
||||||
|
Chores
|
||||||
|
</div>
|
||||||
|
<div className="widget-content flex items-center justify-center">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Add a <code>todo.chores</code> list in HA
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="widget h-full">
|
||||||
|
<div className="widget-title">
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||||
|
</svg>
|
||||||
|
Chores
|
||||||
|
</div>
|
||||||
|
<div className="widget-content overflow-y-auto space-y-1">
|
||||||
|
{chores.map((chore) => (
|
||||||
|
<button
|
||||||
|
key={chore.uid}
|
||||||
|
onClick={() => toggleChore(chore.uid, chore.status)}
|
||||||
|
className="compact-row w-full gap-2 touch-manipulation"
|
||||||
|
>
|
||||||
|
{/* Checkbox */}
|
||||||
|
<div
|
||||||
|
className="w-5 h-5 rounded-md border-2 flex items-center justify-center shrink-0 transition-colors"
|
||||||
|
style={{
|
||||||
|
borderColor: chore.color.bg,
|
||||||
|
backgroundColor: chore.status === 'completed' ? chore.color.bg : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{chore.status === 'completed' && (
|
||||||
|
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Person badge */}
|
||||||
|
{chore.person && (
|
||||||
|
<span
|
||||||
|
className="text-[0.6rem] font-bold px-1.5 py-0.5 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: chore.color.bg, color: chore.color.text }}
|
||||||
|
>
|
||||||
|
{chore.person}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Task text */}
|
||||||
|
<span className={`text-sm flex-1 text-left truncate ${chore.status === 'completed' ? 'line-through opacity-50' : ''}`}>
|
||||||
|
{chore.task}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/chores/index.ts
Normal file
1
src/components/chores/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { ChoreChart } from './ChoreChart';
|
||||||
49
src/components/controls/ControlsOverlay.tsx
Normal file
49
src/components/controls/ControlsOverlay.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
|
import { useSettingsStore } from '@/stores/settingsStore';
|
||||||
|
import { LightsWidget } from '@/components/lights';
|
||||||
|
import { LocksWidget } from '@/components/locks';
|
||||||
|
import { ThermostatWidget } from '@/components/climate';
|
||||||
|
import { AlarmoPanel } from '@/components/alarm';
|
||||||
|
|
||||||
|
export function ControlsOverlay() {
|
||||||
|
const closeControlsOverlay = useUIStore((s) => s.closeControlsOverlay);
|
||||||
|
const config = useSettingsStore((s) => s.config);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overlay-full">
|
||||||
|
<header className="h-16 bg-dark-secondary border-b border-dark-border flex items-center justify-between px-5 shrink-0">
|
||||||
|
<h2 className="text-2xl font-bold text-ink">Controls</h2>
|
||||||
|
<button
|
||||||
|
onClick={closeControlsOverlay}
|
||||||
|
className="btn btn-sm"
|
||||||
|
aria-label="Close controls"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto p-5 space-y-5">
|
||||||
|
{/* Climate controls — grouped together, equal width */}
|
||||||
|
{config.thermostats.length > 0 && (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{config.thermostats.map((thermostat) => (
|
||||||
|
<div key={thermostat.entityId} className="min-h-[200px]">
|
||||||
|
<ThermostatWidget config={thermostat} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Alarm, Lights, Locks */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{config.alarm && <AlarmoPanel />}
|
||||||
|
{config.lights.length > 0 && <LightsWidget />}
|
||||||
|
{config.locks.length > 0 && <LocksWidget />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/controls/index.ts
Normal file
1
src/components/controls/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { ControlsOverlay } from './ControlsOverlay';
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useConnectionState, useEntityState, useEntityAttribute } from '@/stores/haStore';
|
import { useConnectionState, useEntityState, useEntityAttribute, useHAStore } from '@/stores/haStore';
|
||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
import { useSettingsStore } from '@/stores/settingsStore';
|
import { useSettingsStore } from '@/stores/settingsStore';
|
||||||
import { env } from '@/config/environment';
|
import { env } from '@/config/environment';
|
||||||
@@ -80,15 +80,104 @@ function PackageStatus() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WEATHER_ICONS: Record<string, string> = {
|
||||||
|
'clear-night': '🌙',
|
||||||
|
'cloudy': '☁️',
|
||||||
|
'exceptional': '⚠️',
|
||||||
|
'fog': '🌫️',
|
||||||
|
'hail': '🌨️',
|
||||||
|
'lightning': '⚡',
|
||||||
|
'lightning-rainy': '⛈️',
|
||||||
|
'partlycloudy': '⛅',
|
||||||
|
'pouring': '🌧️',
|
||||||
|
'rainy': '🌧️',
|
||||||
|
'snowy': '❄️',
|
||||||
|
'snowy-rainy': '🌨️',
|
||||||
|
'sunny': '☀️',
|
||||||
|
'windy': '💨',
|
||||||
|
'windy-variant': '💨',
|
||||||
|
};
|
||||||
|
|
||||||
|
function WeatherBadge() {
|
||||||
|
const weatherEntity = 'weather.forecast_home_2';
|
||||||
|
const condition = useEntityState(weatherEntity) ?? '';
|
||||||
|
const entities = useHAStore((s) => s.entities);
|
||||||
|
const attrs = entities[weatherEntity]?.attributes as Record<string, unknown> | undefined;
|
||||||
|
const temp = attrs?.temperature as number | undefined;
|
||||||
|
|
||||||
|
if (!temp) return null;
|
||||||
|
|
||||||
|
const icon = WEATHER_ICONS[condition] || '🌡️';
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 text-ink">
|
||||||
|
<span className="text-lg">{icon}</span>
|
||||||
|
<span className="text-lg font-bold">{Math.round(temp)}°</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const connectionState = useConnectionState();
|
const connectionState = useConnectionState();
|
||||||
|
const cameraOverlayOpen = useUIStore((state) => state.cameraOverlayOpen);
|
||||||
|
const mediaOverlayOpen = useUIStore((state) => state.mediaOverlayOpen);
|
||||||
|
const controlsOverlayOpen = useUIStore((state) => state.controlsOverlayOpen);
|
||||||
|
const settingsOpen = useUIStore((state) => state.settingsOpen);
|
||||||
const openCameraOverlay = useUIStore((state) => state.openCameraOverlay);
|
const openCameraOverlay = useUIStore((state) => state.openCameraOverlay);
|
||||||
|
const closeCameraOverlay = useUIStore((state) => state.closeCameraOverlay);
|
||||||
const openMediaOverlay = useUIStore((state) => state.openMediaOverlay);
|
const openMediaOverlay = useUIStore((state) => state.openMediaOverlay);
|
||||||
|
const closeMediaOverlay = useUIStore((state) => state.closeMediaOverlay);
|
||||||
|
const openControlsOverlay = useUIStore((state) => state.openControlsOverlay);
|
||||||
|
const closeControlsOverlay = useUIStore((state) => state.closeControlsOverlay);
|
||||||
const openSettings = useUIStore((state) => state.openSettings);
|
const openSettings = useUIStore((state) => state.openSettings);
|
||||||
|
const closeSettings = useUIStore((state) => state.closeSettings);
|
||||||
const people = useSettingsStore((state) => state.config.people);
|
const people = useSettingsStore((state) => state.config.people);
|
||||||
const cameras = useSettingsStore((state) => state.config.cameras);
|
const cameras = useSettingsStore((state) => state.config.cameras);
|
||||||
const [currentTime, setCurrentTime] = useState(new Date());
|
const [currentTime, setCurrentTime] = useState(new Date());
|
||||||
|
|
||||||
|
const activeTab: 'home' | 'controls' | 'media' | 'cameras' | 'settings' =
|
||||||
|
settingsOpen
|
||||||
|
? 'settings'
|
||||||
|
: cameraOverlayOpen
|
||||||
|
? 'cameras'
|
||||||
|
: mediaOverlayOpen
|
||||||
|
? 'media'
|
||||||
|
: controlsOverlayOpen
|
||||||
|
? 'controls'
|
||||||
|
: 'home';
|
||||||
|
|
||||||
|
const closeAll = () => {
|
||||||
|
closeCameraOverlay();
|
||||||
|
closeMediaOverlay();
|
||||||
|
closeControlsOverlay();
|
||||||
|
closeSettings();
|
||||||
|
};
|
||||||
|
const goHome = () => {
|
||||||
|
closeAll();
|
||||||
|
};
|
||||||
|
const goControls = () => {
|
||||||
|
closeAll();
|
||||||
|
openControlsOverlay();
|
||||||
|
};
|
||||||
|
const goMedia = () => {
|
||||||
|
closeAll();
|
||||||
|
openMediaOverlay();
|
||||||
|
};
|
||||||
|
const goCameras = () => {
|
||||||
|
closeAll();
|
||||||
|
openCameraOverlay();
|
||||||
|
};
|
||||||
|
const goSettings = () => {
|
||||||
|
closeAll();
|
||||||
|
openSettings();
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabClass = (name: typeof activeTab) =>
|
||||||
|
`flex items-center gap-2 px-4 py-2 rounded-2xl font-semibold text-sm transition-all ${
|
||||||
|
activeTab === name
|
||||||
|
? 'bg-accent text-white shadow-card'
|
||||||
|
: 'bg-transparent text-ink-muted hover:bg-dark-hover'
|
||||||
|
}`;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
setCurrentTime(new Date());
|
setCurrentTime(new Date());
|
||||||
@@ -122,23 +211,18 @@ export function Header() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="h-14 bg-dark-secondary border-b border-dark-border flex items-center justify-between px-5">
|
<header className="h-16 bg-dark-secondary border-b border-dark-border flex items-center justify-between px-5 gap-4">
|
||||||
{/* Left - Time and Date */}
|
{/* Left - Time, Date, People */}
|
||||||
|
<div className="flex items-center gap-5 min-w-0">
|
||||||
<div className="flex items-baseline gap-3">
|
<div className="flex items-baseline gap-3">
|
||||||
<span className="text-2xl font-semibold text-white tracking-tight">
|
<span className="text-2xl font-bold text-ink tracking-tight">
|
||||||
{format(currentTime, 'h:mm')}
|
{format(currentTime, 'h:mm')}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-gray-500">
|
<span className="text-sm font-semibold text-ink-muted">
|
||||||
{format(currentTime, 'EEE, MMM d')}
|
{format(currentTime, 'EEE, MMM d')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Center - Status Icons */}
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
{/* Package Status */}
|
|
||||||
<PackageStatus />
|
<PackageStatus />
|
||||||
|
|
||||||
{/* People */}
|
|
||||||
{people.length > 0 && (
|
{people.length > 0 && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{people.map((person) => (
|
{people.map((person) => (
|
||||||
@@ -153,53 +237,51 @@ export function Header() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right - Connection Status, Cameras, Settings */}
|
{/* Center - Tab Navigation */}
|
||||||
<div className="flex items-center gap-4">
|
<nav className="flex items-center gap-1 bg-dark-tertiary rounded-2xl p-1">
|
||||||
{/* Connection Status */}
|
<button onClick={goHome} className={tabClass('home')}>
|
||||||
<div className="status-badge">
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<div className={`status-dot ${getConnectionStatusClass()}`} />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||||
<span>{getConnectionText()}</span>
|
</svg>
|
||||||
</div>
|
Home
|
||||||
|
</button>
|
||||||
{/* Media Button */}
|
<button onClick={goControls} className={tabClass('controls')}>
|
||||||
<button
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
onClick={openMediaOverlay}
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
|
||||||
className="btn btn-sm"
|
</svg>
|
||||||
aria-label="Open media"
|
Controls
|
||||||
>
|
</button>
|
||||||
<svg className="w-4 h-4 text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<button onClick={goMedia} className={tabClass('media')}>
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
Media
|
Media
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Camera Button */}
|
|
||||||
{cameras.length > 0 && (
|
{cameras.length > 0 && (
|
||||||
<button
|
<button onClick={goCameras} className={tabClass('cameras')}>
|
||||||
onClick={() => openCameraOverlay()}
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
className="btn btn-sm"
|
|
||||||
aria-label="View cameras"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
Cameras
|
Cameras
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<button onClick={goSettings} className={tabClass('settings')}>
|
||||||
{/* Settings Button */}
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<button
|
|
||||||
onClick={openSettings}
|
|
||||||
className="btn btn-sm"
|
|
||||||
aria-label="Settings"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
Settings
|
Settings
|
||||||
</button>
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Right - Weather + Connection */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<WeatherBadge />
|
||||||
|
<div className="status-badge">
|
||||||
|
<div className={`status-dot ${getConnectionStatusClass()}`} />
|
||||||
|
<span>{getConnectionText()}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
172
src/components/photoframe/PhotoFrame.tsx
Normal file
172
src/components/photoframe/PhotoFrame.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
|
|
||||||
|
interface PhotoFrameProps {
|
||||||
|
intervalMs?: number;
|
||||||
|
transitionMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffle<T>(arr: T[]): T[] {
|
||||||
|
const out = arr.slice();
|
||||||
|
for (let i = out.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[out[i], out[j]] = [out[j], out[i]];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useClock() {
|
||||||
|
const [now, setNow] = useState(() => new Date());
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setNow(new Date()), 10_000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
return now;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PhotoFrame({ intervalMs = 15_000, transitionMs = 1_200 }: PhotoFrameProps) {
|
||||||
|
const setIdle = useUIStore((s) => s.setIdle);
|
||||||
|
const [files, setFiles] = useState<string[]>([]);
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
const [currentUrl, setCurrentUrl] = useState<string | null>(null);
|
||||||
|
const [prevUrl, setPrevUrl] = useState<string | null>(null);
|
||||||
|
const [empty, setEmpty] = useState(false);
|
||||||
|
const [emptyDir, setEmptyDir] = useState('');
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
const api = window.electronAPI;
|
||||||
|
if (!api?.photos) {
|
||||||
|
setEmpty(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const list = await api.photos.list();
|
||||||
|
const dir = await api.photos.getDir();
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
if (!list.length) {
|
||||||
|
setEmpty(true);
|
||||||
|
setEmptyDir(dir);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFiles(shuffle(list));
|
||||||
|
setEmpty(false);
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
// Re-scan every 10 min in case photos are added while idle
|
||||||
|
const id = setInterval(load, 10 * 60_000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!files.length) return;
|
||||||
|
const id = setInterval(() => {
|
||||||
|
setIndex((i) => (i + 1) % files.length);
|
||||||
|
}, intervalMs);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [files.length, intervalMs]);
|
||||||
|
|
||||||
|
// Resolve file:// URLs via IPC when the index changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!files.length) return;
|
||||||
|
const api = window.electronAPI;
|
||||||
|
if (!api?.photos?.getUrl) return;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const url = await api.photos.getUrl(files[index]);
|
||||||
|
if (cancelled || !mountedRef.current) return;
|
||||||
|
setPrevUrl(currentUrl);
|
||||||
|
setCurrentUrl(url);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [index, files]);
|
||||||
|
|
||||||
|
const handleExit = () => setIdle(false);
|
||||||
|
|
||||||
|
const now = useClock();
|
||||||
|
const timeStr = useMemo(
|
||||||
|
() =>
|
||||||
|
now.toLocaleTimeString(undefined, {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
}),
|
||||||
|
[now],
|
||||||
|
);
|
||||||
|
const dateStr = useMemo(
|
||||||
|
() =>
|
||||||
|
now.toLocaleDateString(undefined, {
|
||||||
|
weekday: 'long',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
}),
|
||||||
|
[now],
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentSrc = currentUrl;
|
||||||
|
const prevSrc = prevUrl;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[60] bg-black flex items-center justify-center overflow-hidden animate-fade-in"
|
||||||
|
onClick={handleExit}
|
||||||
|
onTouchStart={handleExit}
|
||||||
|
>
|
||||||
|
{empty ? (
|
||||||
|
<div className="text-center text-white/80 px-8">
|
||||||
|
<div className="text-6xl font-light mb-6">{timeStr}</div>
|
||||||
|
<div className="text-2xl mb-10 capitalize">{dateStr}</div>
|
||||||
|
<div className="text-lg opacity-60">
|
||||||
|
No photos found{emptyDir ? ` in ${emptyDir}` : ''}.
|
||||||
|
</div>
|
||||||
|
<div className="text-sm opacity-40 mt-2">Touch anywhere to exit</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{prevSrc && (
|
||||||
|
<img
|
||||||
|
key={`prev-${prevSrc}`}
|
||||||
|
src={prevSrc}
|
||||||
|
alt=""
|
||||||
|
className="absolute inset-0 w-full h-full object-contain animate-ken-burns"
|
||||||
|
style={{ opacity: 1 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{currentSrc && (
|
||||||
|
<img
|
||||||
|
key={`cur-${index}`}
|
||||||
|
src={currentSrc}
|
||||||
|
alt=""
|
||||||
|
className="absolute inset-0 w-full h-full object-contain animate-ken-burns"
|
||||||
|
style={{
|
||||||
|
opacity: 0,
|
||||||
|
animation: `photo-fade-in ${transitionMs}ms ease-out forwards, ken-burns 20s ease-in-out infinite alternate`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Lower-right clock + date overlay */}
|
||||||
|
<div className="pointer-events-none absolute bottom-8 right-10 text-right text-white leading-tight"
|
||||||
|
style={{ textShadow: '0 2px 12px rgba(0,0,0,0.9), 0 0 4px rgba(0,0,0,0.7)' }}>
|
||||||
|
<div className="text-6xl md:text-7xl font-light tracking-tight">{timeStr}</div>
|
||||||
|
<div className="text-xl md:text-2xl mt-1 capitalize font-light">{dateStr}</div>
|
||||||
|
</div>
|
||||||
|
<style>{`
|
||||||
|
@keyframes photo-fade-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/photoframe/index.ts
Normal file
1
src/components/photoframe/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { PhotoFrame } from './PhotoFrame';
|
||||||
@@ -20,6 +20,10 @@ export const env = {
|
|||||||
// Screen management
|
// Screen management
|
||||||
screenIdleTimeout: parseInt(import.meta.env.VITE_SCREEN_IDLE_TIMEOUT || '300000', 10),
|
screenIdleTimeout: parseInt(import.meta.env.VITE_SCREEN_IDLE_TIMEOUT || '300000', 10),
|
||||||
|
|
||||||
|
// Photo frame idle timeout (ms) - 5 minutes default
|
||||||
|
photoFrameIdleTimeout: parseInt(import.meta.env.VITE_PHOTO_FRAME_IDLE_TIMEOUT || '300000', 10),
|
||||||
|
photoFrameInterval: parseInt(import.meta.env.VITE_PHOTO_FRAME_INTERVAL || '15000', 10),
|
||||||
|
|
||||||
// Presence detection
|
// Presence detection
|
||||||
presenceEnabled: import.meta.env.VITE_PRESENCE_DETECTION_ENABLED === 'true',
|
presenceEnabled: import.meta.env.VITE_PRESENCE_DETECTION_ENABLED === 'true',
|
||||||
presenceConfidenceThreshold: parseFloat(import.meta.env.VITE_PRESENCE_CONFIDENCE_THRESHOLD || '0.6'),
|
presenceConfidenceThreshold: parseFloat(import.meta.env.VITE_PRESENCE_CONFIDENCE_THRESHOLD || '0.6'),
|
||||||
|
|||||||
60
src/hooks/useFrigateDetection.ts
Normal file
60
src/hooks/useFrigateDetection.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { env } from '@/config/environment';
|
||||||
|
|
||||||
|
interface FrigateEvent {
|
||||||
|
id: string;
|
||||||
|
camera: string;
|
||||||
|
label: string;
|
||||||
|
start_time: number;
|
||||||
|
end_time: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polls Frigate's /api/events for active (end_time=null) person events
|
||||||
|
* on the specified cameras. Fires onPersonDetected with the camera name
|
||||||
|
* when a NEW event appears. Bypasses MQTT/HA entirely.
|
||||||
|
*/
|
||||||
|
export function useFrigateDetection({
|
||||||
|
cameras,
|
||||||
|
onPersonDetected,
|
||||||
|
pollIntervalMs = 5000,
|
||||||
|
}: {
|
||||||
|
cameras: string[];
|
||||||
|
onPersonDetected: (camera: string) => void;
|
||||||
|
pollIntervalMs?: number;
|
||||||
|
}) {
|
||||||
|
const seenRef = useRef<Set<string>>(new Set());
|
||||||
|
const onDetectRef = useRef(onPersonDetected);
|
||||||
|
onDetectRef.current = onPersonDetected;
|
||||||
|
|
||||||
|
const poll = useCallback(async () => {
|
||||||
|
if (!cameras.length) return;
|
||||||
|
try {
|
||||||
|
const url = `${env.frigateUrl}/api/events?labels=person&limit=5&has_clip=0&in_progress=1`;
|
||||||
|
const resp = await fetch(url);
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const events: FrigateEvent[] = await resp.json();
|
||||||
|
for (const ev of events) {
|
||||||
|
if (ev.end_time !== null) continue; // already ended
|
||||||
|
if (!cameras.some((c) => c.toLowerCase() === ev.camera.toLowerCase())) continue;
|
||||||
|
if (seenRef.current.has(ev.id)) continue;
|
||||||
|
seenRef.current.add(ev.id);
|
||||||
|
onDetectRef.current(ev.camera);
|
||||||
|
break; // one at a time
|
||||||
|
}
|
||||||
|
// Prune old seen IDs (keep last 50)
|
||||||
|
if (seenRef.current.size > 50) {
|
||||||
|
const arr = [...seenRef.current];
|
||||||
|
seenRef.current = new Set(arr.slice(arr.length - 25));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Frigate unreachable — skip silently
|
||||||
|
}
|
||||||
|
}, [cameras]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
poll();
|
||||||
|
const id = setInterval(poll, pollIntervalMs);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [poll, pollIntervalMs]);
|
||||||
|
}
|
||||||
55
src/hooks/useIdle.ts
Normal file
55
src/hooks/useIdle.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks touch/mouse/keyboard activity. After `timeoutMs` of no activity,
|
||||||
|
* flips the UI into idle mode (photo frame). Any activity exits idle.
|
||||||
|
* Idle is also suppressed while the Media overlay is open (Jellyfin runs
|
||||||
|
* in a cross-origin iframe, so we can't observe the video element's
|
||||||
|
* play state directly — the overlay being open is our proxy).
|
||||||
|
*/
|
||||||
|
export function useIdle(timeoutMs: number) {
|
||||||
|
const isIdle = useUIStore((s) => s.isIdle);
|
||||||
|
const setIdle = useUIStore((s) => s.setIdle);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timeoutMs <= 0) return;
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const schedule = () => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
// Re-check at fire time so "media overlay open" is always current
|
||||||
|
if (useUIStore.getState().mediaOverlayOpen) {
|
||||||
|
schedule();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIdle(true);
|
||||||
|
}, timeoutMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
if (useUIStore.getState().isIdle) setIdle(false);
|
||||||
|
schedule();
|
||||||
|
};
|
||||||
|
|
||||||
|
const events: Array<keyof DocumentEventMap> = [
|
||||||
|
'touchstart',
|
||||||
|
'mousedown',
|
||||||
|
'mousemove',
|
||||||
|
'keydown',
|
||||||
|
'wheel',
|
||||||
|
];
|
||||||
|
for (const e of events) document.addEventListener(e, reset, { passive: true });
|
||||||
|
|
||||||
|
schedule();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
for (const e of events) document.removeEventListener(e, reset);
|
||||||
|
};
|
||||||
|
}, [timeoutMs, setIdle]);
|
||||||
|
|
||||||
|
return isIdle;
|
||||||
|
}
|
||||||
@@ -10,13 +10,22 @@ export class Go2RTCWebRTC {
|
|||||||
private mediaStream: MediaStream | null = null;
|
private mediaStream: MediaStream | null = null;
|
||||||
private streamName: string;
|
private streamName: string;
|
||||||
private onTrackCallback: ((stream: MediaStream) => void) | null = null;
|
private onTrackCallback: ((stream: MediaStream) => void) | null = null;
|
||||||
|
private onDisconnectCallback: (() => void) | null = null;
|
||||||
|
private disposed = false;
|
||||||
|
|
||||||
constructor(streamName: string) {
|
constructor(streamName: string) {
|
||||||
this.streamName = streamName;
|
this.streamName = streamName;
|
||||||
}
|
}
|
||||||
|
|
||||||
async connect(onTrack: (stream: MediaStream) => void): Promise<void> {
|
async connect(
|
||||||
|
onTrack: (stream: MediaStream) => void,
|
||||||
|
onDisconnect?: () => void,
|
||||||
|
): Promise<void> {
|
||||||
this.onTrackCallback = onTrack;
|
this.onTrackCallback = onTrack;
|
||||||
|
this.onDisconnectCallback = onDisconnect ?? null;
|
||||||
|
|
||||||
|
// Clean up any existing connection before reconnecting
|
||||||
|
this.cleanupConnection();
|
||||||
|
|
||||||
// Create peer connection with STUN servers
|
// Create peer connection with STUN servers
|
||||||
const config: RTCConfiguration = {
|
const config: RTCConfiguration = {
|
||||||
@@ -29,6 +38,7 @@ export class Go2RTCWebRTC {
|
|||||||
|
|
||||||
// Handle incoming tracks
|
// Handle incoming tracks
|
||||||
this.peerConnection.ontrack = (event) => {
|
this.peerConnection.ontrack = (event) => {
|
||||||
|
if (this.disposed) return;
|
||||||
console.log(`Received track for ${this.streamName}:`, event.track.kind);
|
console.log(`Received track for ${this.streamName}:`, event.track.kind);
|
||||||
if (event.streams && event.streams[0]) {
|
if (event.streams && event.streams[0]) {
|
||||||
this.mediaStream = event.streams[0];
|
this.mediaStream = event.streams[0];
|
||||||
@@ -44,9 +54,13 @@ export class Go2RTCWebRTC {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle connection state changes
|
// Handle connection state changes - notify on disconnect/failure
|
||||||
this.peerConnection.onconnectionstatechange = () => {
|
this.peerConnection.onconnectionstatechange = () => {
|
||||||
console.log(`WebRTC connection state for ${this.streamName}:`, this.peerConnection?.connectionState);
|
const state = this.peerConnection?.connectionState;
|
||||||
|
console.log(`WebRTC connection state for ${this.streamName}:`, state);
|
||||||
|
if (!this.disposed && (state === 'disconnected' || state === 'failed')) {
|
||||||
|
this.onDisconnectCallback?.();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add transceiver for video (receive only)
|
// Add transceiver for video (receive only)
|
||||||
@@ -62,6 +76,20 @@ export class Go2RTCWebRTC {
|
|||||||
await this.peerConnection.setRemoteDescription(answer);
|
await this.peerConnection.setRemoteDescription(answer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private cleanupConnection(): void {
|
||||||
|
if (this.mediaStream) {
|
||||||
|
this.mediaStream.getTracks().forEach((track) => track.stop());
|
||||||
|
this.mediaStream = null;
|
||||||
|
}
|
||||||
|
if (this.peerConnection) {
|
||||||
|
this.peerConnection.ontrack = null;
|
||||||
|
this.peerConnection.onicecandidate = null;
|
||||||
|
this.peerConnection.onconnectionstatechange = null;
|
||||||
|
this.peerConnection.close();
|
||||||
|
this.peerConnection = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async sendOffer(offer: RTCSessionDescriptionInit): Promise<RTCSessionDescriptionInit> {
|
private async sendOffer(offer: RTCSessionDescriptionInit): Promise<RTCSessionDescriptionInit> {
|
||||||
const url = `${env.go2rtcUrl}/api/webrtc?src=${encodeURIComponent(this.streamName)}`;
|
const url = `${env.go2rtcUrl}/api/webrtc?src=${encodeURIComponent(this.streamName)}`;
|
||||||
|
|
||||||
@@ -91,17 +119,10 @@ export class Go2RTCWebRTC {
|
|||||||
}
|
}
|
||||||
|
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
if (this.mediaStream) {
|
this.disposed = true;
|
||||||
this.mediaStream.getTracks().forEach((track) => track.stop());
|
this.cleanupConnection();
|
||||||
this.mediaStream = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.peerConnection) {
|
|
||||||
this.peerConnection.close();
|
|
||||||
this.peerConnection = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.onTrackCallback = null;
|
this.onTrackCallback = null;
|
||||||
|
this.onDisconnectCallback = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getMediaStream(): MediaStream | null {
|
getMediaStream(): MediaStream | null {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { create } from 'zustand';
|
|||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
// Increment this when default cameras/config changes to force refresh
|
// Increment this when default cameras/config changes to force refresh
|
||||||
const CONFIG_VERSION = 8;
|
const CONFIG_VERSION = 9;
|
||||||
|
|
||||||
export interface ThermostatConfig {
|
export interface ThermostatConfig {
|
||||||
entityId: string;
|
entityId: string;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ interface UIState {
|
|||||||
locksOverlayOpen: boolean;
|
locksOverlayOpen: boolean;
|
||||||
thermostatsOverlayOpen: boolean;
|
thermostatsOverlayOpen: boolean;
|
||||||
mediaOverlayOpen: boolean;
|
mediaOverlayOpen: boolean;
|
||||||
|
controlsOverlayOpen: boolean;
|
||||||
personAlertActive: boolean;
|
personAlertActive: boolean;
|
||||||
personAlertCamera: string | null;
|
personAlertCamera: string | null;
|
||||||
|
|
||||||
@@ -21,6 +22,10 @@ interface UIState {
|
|||||||
// Screen state
|
// Screen state
|
||||||
screenOn: boolean;
|
screenOn: boolean;
|
||||||
|
|
||||||
|
// Idle / photo frame
|
||||||
|
isIdle: boolean;
|
||||||
|
setIdle: (idle: boolean) => void;
|
||||||
|
|
||||||
// Virtual keyboard
|
// Virtual keyboard
|
||||||
keyboardOpen: boolean;
|
keyboardOpen: boolean;
|
||||||
keyboardNumpad: boolean;
|
keyboardNumpad: boolean;
|
||||||
@@ -42,6 +47,9 @@ interface UIState {
|
|||||||
openMediaOverlay: () => void;
|
openMediaOverlay: () => void;
|
||||||
closeMediaOverlay: () => void;
|
closeMediaOverlay: () => void;
|
||||||
|
|
||||||
|
openControlsOverlay: () => void;
|
||||||
|
closeControlsOverlay: () => void;
|
||||||
|
|
||||||
showPersonAlert: (camera: string) => void;
|
showPersonAlert: (camera: string) => void;
|
||||||
dismissPersonAlert: () => void;
|
dismissPersonAlert: () => void;
|
||||||
|
|
||||||
@@ -65,12 +73,14 @@ export const useUIStore = create<UIState>((set) => ({
|
|||||||
locksOverlayOpen: false,
|
locksOverlayOpen: false,
|
||||||
thermostatsOverlayOpen: false,
|
thermostatsOverlayOpen: false,
|
||||||
mediaOverlayOpen: false,
|
mediaOverlayOpen: false,
|
||||||
|
controlsOverlayOpen: false,
|
||||||
personAlertActive: false,
|
personAlertActive: false,
|
||||||
personAlertCamera: null,
|
personAlertCamera: null,
|
||||||
alarmoKeypadOpen: false,
|
alarmoKeypadOpen: false,
|
||||||
alarmoAction: null,
|
alarmoAction: null,
|
||||||
settingsOpen: false,
|
settingsOpen: false,
|
||||||
screenOn: true,
|
screenOn: true,
|
||||||
|
isIdle: false,
|
||||||
keyboardOpen: false,
|
keyboardOpen: false,
|
||||||
keyboardNumpad: false,
|
keyboardNumpad: false,
|
||||||
|
|
||||||
@@ -106,6 +116,10 @@ export const useUIStore = create<UIState>((set) => ({
|
|||||||
openMediaOverlay: () => set({ mediaOverlayOpen: true }),
|
openMediaOverlay: () => set({ mediaOverlayOpen: true }),
|
||||||
closeMediaOverlay: () => set({ mediaOverlayOpen: false }),
|
closeMediaOverlay: () => set({ mediaOverlayOpen: false }),
|
||||||
|
|
||||||
|
// Controls overlay (lights / locks / alarm / thermostats)
|
||||||
|
openControlsOverlay: () => set({ controlsOverlayOpen: true }),
|
||||||
|
closeControlsOverlay: () => set({ controlsOverlayOpen: false }),
|
||||||
|
|
||||||
// Person detection alert
|
// Person detection alert
|
||||||
showPersonAlert: (camera) =>
|
showPersonAlert: (camera) =>
|
||||||
set({
|
set({
|
||||||
@@ -137,6 +151,9 @@ export const useUIStore = create<UIState>((set) => ({
|
|||||||
// Screen
|
// Screen
|
||||||
setScreenOn: (on) => set({ screenOn: on }),
|
setScreenOn: (on) => set({ screenOn: on }),
|
||||||
|
|
||||||
|
// Idle
|
||||||
|
setIdle: (idle) => set({ isIdle: idle }),
|
||||||
|
|
||||||
// Keyboard
|
// Keyboard
|
||||||
openKeyboard: (numpad = false) => set({ keyboardOpen: true, keyboardNumpad: numpad }),
|
openKeyboard: (numpad = false) => set({ keyboardOpen: true, keyboardNumpad: numpad }),
|
||||||
closeKeyboard: () => set({ keyboardOpen: false }),
|
closeKeyboard: () => set({ keyboardOpen: false }),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;500;600;700;800&family=Quicksand:wght@400;500;600;700&display=swap');
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@@ -6,13 +6,13 @@
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
html {
|
html {
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
font-family: 'Nunito', 'Quicksand', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-dark-primary text-white;
|
@apply bg-dark-primary text-ink;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
@@ -20,8 +20,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 4px;
|
width: 6px;
|
||||||
height: 4px;
|
height: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
@apply bg-dark-border rounded;
|
@apply bg-dark-border rounded-full;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
@@ -40,9 +40,10 @@
|
|||||||
@layer components {
|
@layer components {
|
||||||
/* Button base */
|
/* Button base */
|
||||||
.btn {
|
.btn {
|
||||||
@apply flex items-center justify-center gap-2 px-4 py-2.5
|
@apply flex items-center justify-center gap-2 px-5 py-3
|
||||||
bg-dark-tertiary border border-dark-border rounded-xl
|
bg-dark-secondary border border-dark-border rounded-2xl
|
||||||
font-medium text-sm text-white
|
font-semibold text-base text-ink
|
||||||
|
shadow-card
|
||||||
transition-all duration-150 ease-out
|
transition-all duration-150 ease-out
|
||||||
hover:bg-dark-hover hover:border-dark-border-light
|
hover:bg-dark-hover hover:border-dark-border-light
|
||||||
active:scale-[0.98] touch-manipulation;
|
active:scale-[0.98] touch-manipulation;
|
||||||
@@ -50,30 +51,31 @@
|
|||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
@apply btn bg-accent border-accent text-white
|
@apply btn bg-accent border-accent text-white
|
||||||
hover:bg-accent-light hover:border-accent-light;
|
hover:bg-accent-dark hover:border-accent-dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-sm {
|
.btn-sm {
|
||||||
@apply px-2.5 py-1 text-xs rounded-lg;
|
@apply px-3 py-1.5 text-sm rounded-xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-icon {
|
.btn-icon {
|
||||||
@apply btn p-2.5;
|
@apply btn p-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Widget */
|
/* Widget - Skylight-style rounded card */
|
||||||
.widget {
|
.widget {
|
||||||
@apply bg-dark-secondary border border-dark-border rounded-2xl p-4
|
@apply bg-dark-secondary border border-dark-border rounded-3xl p-5
|
||||||
|
shadow-card
|
||||||
flex flex-col overflow-hidden;
|
flex flex-col overflow-hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.widget-title {
|
.widget-title {
|
||||||
@apply flex items-center gap-2 text-xs font-semibold uppercase tracking-wide
|
@apply flex items-center gap-2 text-sm font-bold uppercase tracking-wide
|
||||||
text-gray-400 mb-3;
|
text-ink-muted mb-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.widget-title svg {
|
.widget-title svg {
|
||||||
@apply w-4 h-4 opacity-70;
|
@apply w-5 h-5 opacity-70;
|
||||||
}
|
}
|
||||||
|
|
||||||
.widget-content {
|
.widget-content {
|
||||||
@@ -82,36 +84,36 @@
|
|||||||
|
|
||||||
/* Toggle switch */
|
/* Toggle switch */
|
||||||
.toggle {
|
.toggle {
|
||||||
@apply relative w-9 h-5 bg-dark-elevated rounded-full cursor-pointer
|
@apply relative w-11 h-6 bg-dark-tertiary border border-dark-border rounded-full cursor-pointer
|
||||||
transition-colors duration-200;
|
transition-colors duration-200;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle.active {
|
.toggle.active {
|
||||||
@apply bg-accent;
|
@apply bg-accent border-accent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-thumb {
|
.toggle-thumb {
|
||||||
@apply absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full
|
@apply absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full
|
||||||
transition-transform duration-200 shadow-sm;
|
transition-transform duration-200 shadow-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle.active .toggle-thumb {
|
.toggle.active .toggle-thumb {
|
||||||
@apply translate-x-4;
|
@apply translate-x-5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Status badge */
|
/* Status badge */
|
||||||
.status-badge {
|
.status-badge {
|
||||||
@apply flex items-center gap-2 px-3.5 py-2 bg-dark-tertiary rounded-full
|
@apply flex items-center gap-2 px-4 py-2 bg-dark-tertiary rounded-full
|
||||||
text-sm text-gray-400;
|
text-sm text-ink-muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot {
|
.status-dot {
|
||||||
@apply w-2 h-2 rounded-full;
|
@apply w-2.5 h-2.5 rounded-full;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot.connected {
|
.status-dot.connected {
|
||||||
@apply bg-status-success;
|
@apply bg-status-success;
|
||||||
box-shadow: 0 0 8px theme('colors.status.success');
|
box-shadow: 0 0 8px rgba(74, 157, 122, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot.disconnected {
|
.status-dot.disconnected {
|
||||||
@@ -128,21 +130,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.person-avatar {
|
.person-avatar {
|
||||||
@apply w-8 h-8 rounded-full overflow-hidden border-2 transition-all;
|
@apply w-9 h-9 rounded-full overflow-hidden border-2 transition-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.person-avatar.home {
|
.person-avatar.home {
|
||||||
@apply border-status-success;
|
@apply border-status-success;
|
||||||
box-shadow: 0 0 8px rgba(34, 197, 94, 0.3);
|
box-shadow: 0 0 8px rgba(74, 157, 122, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.person-avatar.away {
|
.person-avatar.away {
|
||||||
@apply border-gray-500 opacity-70;
|
@apply border-dark-border-light opacity-70;
|
||||||
}
|
}
|
||||||
|
|
||||||
.person-avatar.work {
|
.person-avatar.work {
|
||||||
@apply border-accent;
|
@apply border-accent;
|
||||||
box-shadow: 0 0 8px rgba(59, 130, 246, 0.3);
|
box-shadow: 0 0 8px rgba(127, 168, 148, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.person-avatar img {
|
.person-avatar img {
|
||||||
@@ -150,7 +152,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.person-location {
|
.person-location {
|
||||||
@apply text-[0.55rem] font-medium uppercase tracking-wide text-gray-500;
|
@apply text-[0.6rem] font-semibold uppercase tracking-wide text-ink-subtle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.person-location.home {
|
.person-location.home {
|
||||||
@@ -158,12 +160,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.person-location.work {
|
.person-location.work {
|
||||||
@apply text-accent;
|
@apply text-accent-dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Status icon */
|
/* Status icon */
|
||||||
.status-icon {
|
.status-icon {
|
||||||
@apply relative w-8 h-8 rounded-lg flex items-center justify-center
|
@apply relative w-9 h-9 rounded-xl flex items-center justify-center
|
||||||
cursor-pointer transition-transform hover:scale-110;
|
cursor-pointer transition-transform hover:scale-110;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,55 +176,56 @@
|
|||||||
.status-icon.package::after {
|
.status-icon.package::after {
|
||||||
content: '';
|
content: '';
|
||||||
@apply absolute -top-0.5 -right-0.5 w-2.5 h-2.5 bg-status-warning rounded-full
|
@apply absolute -top-0.5 -right-0.5 w-2.5 h-2.5 bg-status-warning rounded-full
|
||||||
border-2 border-dark-tertiary animate-pulse;
|
border-2 border-dark-secondary animate-pulse;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keypad */
|
/* Keypad */
|
||||||
.keypad-btn {
|
.keypad-btn {
|
||||||
@apply w-14 h-14 rounded-xl bg-dark-tertiary border border-dark-border
|
@apply w-16 h-16 rounded-2xl bg-dark-secondary border border-dark-border
|
||||||
text-xl font-medium text-white
|
text-2xl font-semibold text-ink
|
||||||
|
shadow-card
|
||||||
transition-all duration-150
|
transition-all duration-150
|
||||||
hover:border-accent active:bg-accent active:scale-95
|
hover:border-accent hover:bg-dark-hover active:bg-accent active:text-white active:scale-95
|
||||||
touch-manipulation;
|
touch-manipulation;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Temperature */
|
/* Temperature */
|
||||||
.temp-display {
|
.temp-display {
|
||||||
@apply text-5xl font-light tracking-tight;
|
@apply text-5xl font-light tracking-tight text-ink;
|
||||||
}
|
}
|
||||||
|
|
||||||
.temp-setpoint {
|
.temp-setpoint {
|
||||||
@apply text-center px-2.5 py-1.5 bg-dark-tertiary rounded-lg;
|
@apply text-center px-3 py-2 bg-dark-tertiary rounded-xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.temp-setpoint-label {
|
.temp-setpoint-label {
|
||||||
@apply text-[0.5rem] text-gray-500 uppercase tracking-wide mb-0.5;
|
@apply text-[0.55rem] text-ink-subtle uppercase tracking-wide mb-0.5 font-semibold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.temp-setpoint-value {
|
.temp-setpoint-value {
|
||||||
@apply text-lg font-semibold;
|
@apply text-lg font-bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.temp-setpoint-value.heat {
|
.temp-setpoint-value.heat {
|
||||||
@apply text-orange-400;
|
@apply text-status-warning;
|
||||||
}
|
}
|
||||||
|
|
||||||
.temp-setpoint-value.cool {
|
.temp-setpoint-value.cool {
|
||||||
@apply text-sky-400;
|
@apply text-accent-dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
.temp-btn {
|
.temp-btn {
|
||||||
@apply w-8 h-8 rounded-full bg-dark-tertiary border border-dark-border
|
@apply w-9 h-9 rounded-full bg-dark-secondary border border-dark-border
|
||||||
flex items-center justify-center text-base
|
flex items-center justify-center text-base
|
||||||
transition-all hover:bg-dark-hover;
|
transition-all hover:bg-dark-hover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.temp-btn.heat {
|
.temp-btn.heat {
|
||||||
@apply border-orange-400 text-orange-400 hover:bg-orange-400/15;
|
@apply border-status-warning text-status-warning hover:bg-status-warning/15;
|
||||||
}
|
}
|
||||||
|
|
||||||
.temp-btn.cool {
|
.temp-btn.cool {
|
||||||
@apply border-sky-400 text-sky-400 hover:bg-sky-400/15;
|
@apply border-accent-dark text-accent-dark hover:bg-accent/15;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Overlay */
|
/* Overlay */
|
||||||
@@ -233,9 +236,14 @@
|
|||||||
|
|
||||||
/* Compact rows */
|
/* Compact rows */
|
||||||
.compact-row {
|
.compact-row {
|
||||||
@apply flex items-center justify-between px-2.5 py-2 bg-dark-tertiary rounded-lg
|
@apply flex items-center justify-between px-3 py-2.5 bg-dark-tertiary rounded-xl
|
||||||
transition-colors hover:bg-dark-hover;
|
transition-colors hover:bg-dark-hover;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Legacy imperial card for ConnectionPrompt */
|
||||||
|
.card-imperial {
|
||||||
|
@apply bg-dark-secondary border border-dark-border rounded-3xl p-8 shadow-card-lg;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
@@ -256,4 +264,23 @@
|
|||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
50% { opacity: 0.5; }
|
50% { opacity: 0.5; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Theme remap (cool light): existing components use text-white and
|
||||||
|
* text-gray-* for dark-theme text. Rebind to cool slate ink so the UI
|
||||||
|
* is readable on the slate-100 bg without touching every component.
|
||||||
|
*/
|
||||||
|
.text-white { color: #0f172a; }
|
||||||
|
.text-gray-300 { color: #1e293b; }
|
||||||
|
.text-gray-400 { color: #475569; }
|
||||||
|
.text-gray-500 { color: #64748b; }
|
||||||
|
.text-gray-600 { color: #94a3b8; }
|
||||||
|
|
||||||
|
.bg-imperial-black { background-color: #f1f5f9; }
|
||||||
|
.bg-black { background-color: #0f172a; }
|
||||||
|
|
||||||
|
.text-ink { color: #0f172a; }
|
||||||
|
.text-ink-muted { color: #475569; }
|
||||||
|
.text-ink-subtle { color: #64748b; }
|
||||||
|
.text-ink-faint { color: #94a3b8; }
|
||||||
}
|
}
|
||||||
|
|||||||
8
src/vite-env.d.ts
vendored
8
src/vite-env.d.ts
vendored
@@ -12,6 +12,8 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_PRESENCE_CONFIDENCE_THRESHOLD: string;
|
readonly VITE_PRESENCE_CONFIDENCE_THRESHOLD: string;
|
||||||
readonly VITE_FRIGATE_STREAM_ENABLED: string;
|
readonly VITE_FRIGATE_STREAM_ENABLED: string;
|
||||||
readonly VITE_FRIGATE_RTSP_OUTPUT: string;
|
readonly VITE_FRIGATE_RTSP_OUTPUT: string;
|
||||||
|
readonly VITE_PHOTO_FRAME_IDLE_TIMEOUT: string;
|
||||||
|
readonly VITE_PHOTO_FRAME_INTERVAL: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
@@ -34,6 +36,7 @@ interface ElectronAPI {
|
|||||||
frigate: {
|
frigate: {
|
||||||
startStream: (rtspUrl: string) => Promise<boolean>;
|
startStream: (rtspUrl: string) => Promise<boolean>;
|
||||||
stopStream: () => Promise<boolean>;
|
stopStream: () => Promise<boolean>;
|
||||||
|
onPersonDetected: (callback: (camera: string) => void) => () => void;
|
||||||
};
|
};
|
||||||
app: {
|
app: {
|
||||||
quit: () => void;
|
quit: () => void;
|
||||||
@@ -44,6 +47,11 @@ interface ElectronAPI {
|
|||||||
getStoredToken: () => Promise<string | null>;
|
getStoredToken: () => Promise<string | null>;
|
||||||
getJellyfinApiKey: () => Promise<string | null>;
|
getJellyfinApiKey: () => Promise<string | null>;
|
||||||
};
|
};
|
||||||
|
photos: {
|
||||||
|
list: () => Promise<string[]>;
|
||||||
|
getDir: () => Promise<string>;
|
||||||
|
getUrl: (relative: string) => Promise<string | null>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Window {
|
interface Window {
|
||||||
|
|||||||
@@ -7,41 +7,49 @@ module.exports = {
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
// Modern dark theme
|
// Cool light theme (slate-based neutrals + blue accent).
|
||||||
|
// Token names kept as "dark.*" so existing components don't need class changes.
|
||||||
dark: {
|
dark: {
|
||||||
primary: '#0f0f0f',
|
primary: '#f1f5f9', // page bg - slate-100
|
||||||
secondary: '#171717',
|
secondary: '#ffffff', // card bg - white
|
||||||
tertiary: '#1f1f1f',
|
tertiary: '#e2e8f0', // subtle panels - slate-200
|
||||||
elevated: '#262626',
|
elevated: '#ffffff', // elevated cards
|
||||||
hover: '#2a2a2a',
|
hover: '#e2e8f0',
|
||||||
border: '#2e2e2e',
|
border: '#e2e8f0', // cool soft border
|
||||||
'border-light': '#3a3a3a',
|
'border-light': '#cbd5e1',
|
||||||
},
|
},
|
||||||
// Blue accent
|
// Cool pastel teal accent (picks up Becca's event color)
|
||||||
accent: {
|
accent: {
|
||||||
DEFAULT: '#3b82f6',
|
DEFAULT: '#14b8a6', // teal-500
|
||||||
light: '#60a5fa',
|
light: '#5eead4', // teal-300
|
||||||
dark: '#2563eb',
|
dark: '#0d9488', // teal-600
|
||||||
|
},
|
||||||
|
// Cool slate ink text scale
|
||||||
|
ink: {
|
||||||
|
DEFAULT: '#0f172a', // slate-900
|
||||||
|
muted: '#475569', // slate-600
|
||||||
|
subtle: '#64748b', // slate-500
|
||||||
|
faint: '#94a3b8', // slate-400
|
||||||
},
|
},
|
||||||
// Status colors
|
|
||||||
status: {
|
status: {
|
||||||
success: '#22c55e',
|
success: '#16a34a', // green-600
|
||||||
warning: '#f59e0b',
|
warning: '#d97706', // amber-600
|
||||||
error: '#ef4444',
|
error: '#dc2626', // red-600
|
||||||
},
|
},
|
||||||
// Legacy imperial colors (for gradual migration)
|
// Legacy imperial tokens retained so old references don't break
|
||||||
imperial: {
|
imperial: {
|
||||||
black: '#0a0a0a',
|
black: '#f1f5f9',
|
||||||
dark: '#1a1a1a',
|
dark: '#e2e8f0',
|
||||||
medium: '#2a2a2a',
|
medium: '#cbd5e1',
|
||||||
light: '#3a3a3a',
|
light: '#94a3b8',
|
||||||
red: '#cc0000',
|
red: '#dc2626',
|
||||||
'red-dark': '#990000',
|
'red-dark': '#b91c1c',
|
||||||
'red-light': '#ff3333',
|
'red-light': '#ef4444',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['Inter', '-apple-system', 'BlinkMacSystemFont', 'sans-serif'],
|
sans: ['Nunito', 'Quicksand', '-apple-system', 'BlinkMacSystemFont', 'sans-serif'],
|
||||||
|
display: ['Nunito', 'Quicksand', 'sans-serif'],
|
||||||
},
|
},
|
||||||
fontSize: {
|
fontSize: {
|
||||||
'touch': '1.125rem',
|
'touch': '1.125rem',
|
||||||
@@ -51,18 +59,23 @@ module.exports = {
|
|||||||
'touch-lg': '56px',
|
'touch-lg': '56px',
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
'xl': '0.75rem',
|
'xl': '1rem',
|
||||||
'2xl': '1rem',
|
'2xl': '1.5rem',
|
||||||
|
'3xl': '2rem',
|
||||||
},
|
},
|
||||||
boxShadow: {
|
boxShadow: {
|
||||||
'glow-green': '0 0 8px rgba(34, 197, 94, 0.3)',
|
'card': '0 2px 8px rgba(58, 50, 42, 0.06), 0 1px 2px rgba(58, 50, 42, 0.04)',
|
||||||
'glow-blue': '0 0 8px rgba(59, 130, 246, 0.3)',
|
'card-lg': '0 8px 24px rgba(58, 50, 42, 0.08), 0 2px 6px rgba(58, 50, 42, 0.05)',
|
||||||
'glow-orange': '0 0 8px rgba(249, 115, 22, 0.3)',
|
'glow-green': '0 0 12px rgba(127, 168, 148, 0.35)',
|
||||||
|
'glow-blue': '0 0 12px rgba(127, 168, 148, 0.35)',
|
||||||
|
'glow-orange': '0 0 12px rgba(217, 154, 74, 0.35)',
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
'fade-in': 'fade-in 0.2s ease-out',
|
'fade-in': 'fade-in 0.2s ease-out',
|
||||||
|
'fade-slow': 'fade-in 1.2s ease-out',
|
||||||
'slide-up': 'slide-up 0.3s ease-out',
|
'slide-up': 'slide-up 0.3s ease-out',
|
||||||
'pulse': 'pulse 2s ease-in-out infinite',
|
'pulse': 'pulse 2s ease-in-out infinite',
|
||||||
|
'ken-burns': 'ken-burns 20s ease-in-out infinite alternate',
|
||||||
},
|
},
|
||||||
keyframes: {
|
keyframes: {
|
||||||
'fade-in': {
|
'fade-in': {
|
||||||
@@ -77,6 +90,10 @@ module.exports = {
|
|||||||
'0%, 100%': { opacity: '1' },
|
'0%, 100%': { opacity: '1' },
|
||||||
'50%': { opacity: '0.5' },
|
'50%': { opacity: '0.5' },
|
||||||
},
|
},
|
||||||
|
'ken-burns': {
|
||||||
|
'0%': { transform: 'scale(1.0) translate(0, 0)' },
|
||||||
|
'100%': { transform: 'scale(1.08) translate(-1%, -1%)' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user