Add photo frame idle mode and switch to Skylight-style theme
- After 5 min of no touch/motion, dashboard hides behind a fullscreen photo slideshow with centered time and date overlay - Photos loaded from PHOTOS_PATH env var (defaults to ~/Pictures/dashboard) via IPC + file:// URLs; traversal-guarded, recursive up to 2 levels - Motion or touch exits idle back to dashboard - Theme repainted warm cream / sage / stone ink with Nunito font and rounded cards; dark tokens kept so component classes still resolve - Adds PHOTO_FRAME.md with Samba cifs mount + systemd env instructions
This commit is contained in:
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.
|
||||||
|
|||||||
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.
|
||||||
@@ -5,14 +5,23 @@ 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 { MotionDetector } from './services/MotionDetector';
|
||||||
|
import { PhotoManager } from './services/PhotoManager';
|
||||||
|
|
||||||
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 motionDetector: MotionDetector | null = null;
|
||||||
|
let photoManager: PhotoManager | 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 +39,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,
|
||||||
@@ -71,6 +80,10 @@ function createWindow(): void {
|
|||||||
|
|
||||||
motionDetector.start();
|
motionDetector.start();
|
||||||
|
|
||||||
|
// Photo frame slideshow source
|
||||||
|
photoManager = new PhotoManager(resolvePhotosDir());
|
||||||
|
console.log(`PhotoManager: watching ${photoManager.getDir()}`);
|
||||||
|
|
||||||
// Handle window close
|
// Handle window close
|
||||||
mainWindow.on('closed', () => {
|
mainWindow.on('closed', () => {
|
||||||
mainWindow = null;
|
mainWindow = null;
|
||||||
@@ -174,6 +187,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();
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export interface ElectronAPI {
|
|||||||
startStream: (rtspUrl: string) => Promise<boolean>;
|
startStream: (rtspUrl: string) => Promise<boolean>;
|
||||||
stopStream: () => Promise<boolean>;
|
stopStream: () => Promise<boolean>;
|
||||||
};
|
};
|
||||||
|
motion: {
|
||||||
|
onDetected: (callback: () => void) => () => void;
|
||||||
|
};
|
||||||
app: {
|
app: {
|
||||||
quit: () => void;
|
quit: () => void;
|
||||||
toggleFullscreen: () => void;
|
toggleFullscreen: () => void;
|
||||||
@@ -26,6 +29,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 = {
|
||||||
@@ -53,6 +61,13 @@ const electronAPI: ElectronAPI = {
|
|||||||
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'),
|
||||||
},
|
},
|
||||||
|
motion: {
|
||||||
|
onDetected: (callback: () => void) => {
|
||||||
|
const handler = (_event: IpcRendererEvent) => callback();
|
||||||
|
ipcRenderer.on('motion:detected', handler);
|
||||||
|
return () => ipcRenderer.removeListener('motion:detected', handler);
|
||||||
|
},
|
||||||
|
},
|
||||||
app: {
|
app: {
|
||||||
quit: () => ipcRenderer.invoke('app:quit'),
|
quit: () => ipcRenderer.invoke('app:quit'),
|
||||||
toggleFullscreen: () => ipcRenderer.invoke('app:toggleFullscreen'),
|
toggleFullscreen: () => ipcRenderer.invoke('app:toggleFullscreen'),
|
||||||
@@ -62,6 +77,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);
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,9 @@ 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';
|
||||||
|
import { useIdle } from '@/hooks/useIdle';
|
||||||
// Motion detection now runs in Electron main process (MotionDetector.ts)
|
// Motion detection now runs in Electron main process (MotionDetector.ts)
|
||||||
// import { useSimpleMotion } from '@/hooks/useSimpleMotion';
|
// import { useSimpleMotion } from '@/hooks/useSimpleMotion';
|
||||||
import { useHAStore, useEntityAttribute } from '@/stores/haStore';
|
import { useHAStore, useEntityAttribute } from '@/stores/haStore';
|
||||||
@@ -182,6 +184,7 @@ export default function App() {
|
|||||||
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 { isOpen: cameraOverlayOpen } = useCameraOverlay();
|
const { isOpen: cameraOverlayOpen } = useCameraOverlay();
|
||||||
|
const isIdle = useIdle(env.photoFrameIdleTimeout);
|
||||||
|
|
||||||
// Person detection alert state
|
// Person detection alert state
|
||||||
const personDetectionEntities = useSettingsStore((state) => state.config.personDetectionEntities);
|
const personDetectionEntities = useSettingsStore((state) => state.config.personDetectionEntities);
|
||||||
@@ -334,6 +337,7 @@ export default function App() {
|
|||||||
{mediaOverlayOpen && <JellyfinOverlay />}
|
{mediaOverlayOpen && <JellyfinOverlay />}
|
||||||
{cameraOverlayOpen && <CameraOverlay />}
|
{cameraOverlayOpen && <CameraOverlay />}
|
||||||
{settingsOpen && <SettingsPanel />}
|
{settingsOpen && <SettingsPanel />}
|
||||||
|
{isIdle && !alertCamera && <PhotoFrame intervalMs={env.photoFrameInterval} />}
|
||||||
{alertCamera && <PersonAlert cameraName={alertCamera} onClose={closePersonAlert} />}
|
{alertCamera && <PersonAlert cameraName={alertCamera} onClose={closePersonAlert} />}
|
||||||
<GlobalKeyboard />
|
<GlobalKeyboard />
|
||||||
</>
|
</>
|
||||||
|
|||||||
174
src/components/photoframe/PhotoFrame.tsx
Normal file
174
src/components/photoframe/PhotoFrame.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
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-cover animate-ken-burns"
|
||||||
|
style={{ opacity: 1 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{currentSrc && (
|
||||||
|
<img
|
||||||
|
key={`cur-${index}`}
|
||||||
|
src={currentSrc}
|
||||||
|
alt=""
|
||||||
|
className="absolute inset-0 w-full h-full object-cover animate-ken-burns"
|
||||||
|
style={{
|
||||||
|
opacity: 0,
|
||||||
|
animation: `photo-fade-in ${transitionMs}ms ease-out forwards, ken-burns 20s ease-in-out infinite alternate`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Gradient + centered clock/date overlay (Skylight style) */}
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-56 bg-gradient-to-t from-black/75 via-black/30 to-transparent" />
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-14 flex justify-center">
|
||||||
|
<div className="px-10 py-5 rounded-3xl bg-black/25 backdrop-blur-md border border-white/10 text-white text-center drop-shadow-2xl">
|
||||||
|
<div className="text-7xl md:text-8xl font-light tracking-tight leading-none">{timeStr}</div>
|
||||||
|
<div className="text-2xl md:text-3xl mt-3 capitalize font-light opacity-95">{dateStr}</div>
|
||||||
|
</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'),
|
||||||
|
|||||||
50
src/hooks/useIdle.ts
Normal file
50
src/hooks/useIdle.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
|
|
||||||
|
type ElectronAPILike = {
|
||||||
|
motion?: { onDetected: (cb: () => void) => () => void };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks touch/mouse/keyboard activity. After `timeoutMs` of no activity,
|
||||||
|
* flips the UI into idle mode (photo frame). Any activity exits idle.
|
||||||
|
* Motion detected by Electron's MotionDetector also cancels idle.
|
||||||
|
*/
|
||||||
|
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 reset = () => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
if (useUIStore.getState().isIdle) setIdle(false);
|
||||||
|
timer = setTimeout(() => setIdle(true), timeoutMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const events: Array<keyof DocumentEventMap> = [
|
||||||
|
'touchstart',
|
||||||
|
'mousedown',
|
||||||
|
'mousemove',
|
||||||
|
'keydown',
|
||||||
|
'wheel',
|
||||||
|
];
|
||||||
|
for (const e of events) document.addEventListener(e, reset, { passive: true });
|
||||||
|
|
||||||
|
const api = (window as unknown as { electronAPI?: ElectronAPILike }).electronAPI;
|
||||||
|
const unsubMotion = api?.motion?.onDetected?.(() => reset());
|
||||||
|
|
||||||
|
reset();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
for (const e of events) document.removeEventListener(e, reset);
|
||||||
|
if (unsubMotion) unsubMotion();
|
||||||
|
};
|
||||||
|
}, [timeoutMs, setIdle]);
|
||||||
|
|
||||||
|
return isIdle;
|
||||||
|
}
|
||||||
@@ -21,6 +21,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;
|
||||||
@@ -71,6 +75,7 @@ export const useUIStore = create<UIState>((set) => ({
|
|||||||
alarmoAction: null,
|
alarmoAction: null,
|
||||||
settingsOpen: false,
|
settingsOpen: false,
|
||||||
screenOn: true,
|
screenOn: true,
|
||||||
|
isIdle: false,
|
||||||
keyboardOpen: false,
|
keyboardOpen: false,
|
||||||
keyboardNumpad: false,
|
keyboardNumpad: false,
|
||||||
|
|
||||||
@@ -137,6 +142,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,25 @@
|
|||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
50% { opacity: 0.5; }
|
50% { opacity: 0.5; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Theme remap: existing components use text-white and text-gray-* for
|
||||||
|
* dark-theme text. For the Skylight light theme, rebind those utilities
|
||||||
|
* to the warm ink scale so every component becomes readable on cream bg
|
||||||
|
* without touching hundreds of component files.
|
||||||
|
*/
|
||||||
|
.text-white { color: #3a322a; }
|
||||||
|
.text-gray-300 { color: #4a4239; }
|
||||||
|
.text-gray-400 { color: #6b5f51; }
|
||||||
|
.text-gray-500 { color: #8a7d6e; }
|
||||||
|
.text-gray-600 { color: #a0937f; }
|
||||||
|
|
||||||
|
/* Dark backgrounds that were nearly-black in the old theme */
|
||||||
|
.bg-imperial-black { background-color: #faf6f0; }
|
||||||
|
.bg-black { background-color: #2a2520; }
|
||||||
|
|
||||||
|
.text-ink { color: #3a322a; }
|
||||||
|
.text-ink-muted { color: #6b5f51; }
|
||||||
|
.text-ink-subtle { color: #8a7d6e; }
|
||||||
|
.text-ink-faint { color: #b0a494; }
|
||||||
}
|
}
|
||||||
|
|||||||
10
src/vite-env.d.ts
vendored
10
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 {
|
||||||
@@ -44,6 +46,14 @@ 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>;
|
||||||
|
};
|
||||||
|
motion: {
|
||||||
|
onDetected: (callback: () => void) => () => void;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Window {
|
interface Window {
|
||||||
|
|||||||
@@ -7,41 +7,49 @@ module.exports = {
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
// Modern dark theme
|
// Skylight-style warm light theme.
|
||||||
|
// Token names kept as "dark.*" so existing components don't need class changes.
|
||||||
dark: {
|
dark: {
|
||||||
primary: '#0f0f0f',
|
primary: '#faf6f0', // page bg - warm cream
|
||||||
secondary: '#171717',
|
secondary: '#ffffff', // card bg - white
|
||||||
tertiary: '#1f1f1f',
|
tertiary: '#f5ede1', // subtle panels / compact rows
|
||||||
elevated: '#262626',
|
elevated: '#ffffff', // elevated cards
|
||||||
hover: '#2a2a2a',
|
hover: '#ede2cf', // hover states
|
||||||
border: '#2e2e2e',
|
border: '#ebe0cc', // soft warm borders
|
||||||
'border-light': '#3a3a3a',
|
'border-light': '#d9cab0',
|
||||||
},
|
},
|
||||||
// Blue accent
|
// Sage accent (Skylight uses muted greens/peach, not blue)
|
||||||
accent: {
|
accent: {
|
||||||
DEFAULT: '#3b82f6',
|
DEFAULT: '#7fa894', // sage
|
||||||
light: '#60a5fa',
|
light: '#a0c2b1',
|
||||||
dark: '#2563eb',
|
dark: '#5e8a76',
|
||||||
|
},
|
||||||
|
// Warm ink text scale
|
||||||
|
ink: {
|
||||||
|
DEFAULT: '#3a322a', // primary dark text
|
||||||
|
muted: '#6b5f51',
|
||||||
|
subtle: '#8a7d6e',
|
||||||
|
faint: '#b0a494',
|
||||||
},
|
},
|
||||||
// Status colors
|
|
||||||
status: {
|
status: {
|
||||||
success: '#22c55e',
|
success: '#4a9d7a', // muted green
|
||||||
warning: '#f59e0b',
|
warning: '#d99a4a', // warm amber
|
||||||
error: '#ef4444',
|
error: '#c75b5b', // muted brick
|
||||||
},
|
},
|
||||||
// Legacy imperial colors (for gradual migration)
|
// Legacy imperial tokens retained so old references don't break
|
||||||
imperial: {
|
imperial: {
|
||||||
black: '#0a0a0a',
|
black: '#faf6f0',
|
||||||
dark: '#1a1a1a',
|
dark: '#f5ede1',
|
||||||
medium: '#2a2a2a',
|
medium: '#ebe0cc',
|
||||||
light: '#3a3a3a',
|
light: '#d9cab0',
|
||||||
red: '#cc0000',
|
red: '#c75b5b',
|
||||||
'red-dark': '#990000',
|
'red-dark': '#a84545',
|
||||||
'red-light': '#ff3333',
|
'red-light': '#d97777',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
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