- Main app swapped from warm cream/sage to cool slate light theme with blue accent; dark-* tokens and text-white/gray overrides remapped in tailwind.config.js and index.css - Calendar events now render in Google's 11-color palette (Tomato through Graphite), deterministically hashed from the event summary so the same event always gets the same color - PhotoFrame uses object-contain (whole photo shown, letterboxed) instead of object-cover; clock + date moved to lower-right, same white color, text-shadow for readability over any photo - EMAIL_UPLOAD.md / PHOTO_FRAME.md / iCloud sync script and systemd timer remain unchanged
173 lines
5.0 KiB
TypeScript
173 lines
5.0 KiB
TypeScript
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>
|
|
);
|
|
}
|