Skip to content

Commit d575e2f

Browse files
committed
feat(ui): Further improve UX of AI Assistant
1 parent 649eb4e commit d575e2f

2 files changed

Lines changed: 178 additions & 2 deletions

File tree

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import React, { useEffect, useRef, useState } from "react";
2+
import { createPortal } from "react-dom";
3+
import { Box } from "@mui/material";
4+
import { ReactComponent as SplashLogo } from "../assets/icons/valetudo_splash.svg";
5+
6+
type Size = { width: number; height: number };
7+
8+
const ValetudoBounce = ({ onClose }: { onClose: () => void }): React.ReactElement => {
9+
const logoRef = useRef<HTMLDivElement>(null);
10+
const position = useRef({ x: 0, y: 0 });
11+
const velocity = useRef({ dx: 0, dy: 0 });
12+
const logoSize = useRef<Size>({ width: 0, height: 0 });
13+
14+
const [isInitialized, setIsInitialized] = useState(false);
15+
16+
useEffect(() => {
17+
const handleKeyDown = (event: KeyboardEvent) => {
18+
if (event.key === "Escape") {
19+
onClose();
20+
}
21+
};
22+
window.addEventListener("keydown", handleKeyDown);
23+
return () => {
24+
window.removeEventListener("keydown", handleKeyDown);
25+
};
26+
}, [onClose]);
27+
28+
useEffect(() => {
29+
if (!logoRef.current) {
30+
return;
31+
}
32+
33+
const rect = logoRef.current.getBoundingClientRect();
34+
logoSize.current = { width: rect.width, height: rect.height };
35+
36+
position.current = {
37+
x: Math.random() * (window.innerWidth - logoSize.current.width),
38+
y: Math.random() * (window.innerHeight - logoSize.current.height),
39+
};
40+
41+
const calculateAndSetVelocity = () => {
42+
const diagonal = Math.sqrt(window.innerWidth ** 2 + window.innerHeight ** 2);
43+
const velocityScale = diagonal / 1600;
44+
const speed = Math.max(1, 1.5 * velocityScale);
45+
46+
const currentMagnitude = Math.sqrt(velocity.current.dx ** 2 + velocity.current.dy ** 2);
47+
if (currentMagnitude > 0 && speed > 0) { // Keep direction, adjust speed
48+
velocity.current.dx = (velocity.current.dx / currentMagnitude) * speed;
49+
velocity.current.dy = (velocity.current.dy / currentMagnitude) * speed;
50+
} else {
51+
const angle = Math.random() * 2 * Math.PI;
52+
velocity.current = {
53+
dx: Math.cos(angle) * speed,
54+
dy: Math.sin(angle) * speed,
55+
};
56+
}
57+
};
58+
59+
calculateAndSetVelocity();
60+
setIsInitialized(true);
61+
62+
let animationFrameId: number;
63+
const animate = () => {
64+
if (!logoRef.current) {
65+
return;
66+
}
67+
68+
position.current.x += velocity.current.dx;
69+
position.current.y += velocity.current.dy;
70+
71+
if (position.current.x <= 0) {
72+
velocity.current.dx *= -1;
73+
position.current.x = 0;
74+
} else if (position.current.x + logoSize.current.width >= window.innerWidth) {
75+
velocity.current.dx *= -1;
76+
position.current.x = window.innerWidth - logoSize.current.width;
77+
}
78+
79+
if (position.current.y <= 0) {
80+
velocity.current.dy *= -1;
81+
position.current.y = 0;
82+
} else if (position.current.y + logoSize.current.height >= window.innerHeight) {
83+
velocity.current.dy *= -1;
84+
position.current.y = window.innerHeight - logoSize.current.height;
85+
}
86+
87+
logoRef.current.style.transform = `translate(${position.current.x}px, ${position.current.y}px)`;
88+
animationFrameId = requestAnimationFrame(animate);
89+
};
90+
animationFrameId = requestAnimationFrame(animate);
91+
92+
let debounceTimer: ReturnType<typeof setTimeout>;
93+
const handleResize = () => {
94+
clearTimeout(debounceTimer);
95+
debounceTimer = setTimeout(() => {
96+
if (logoRef.current) {
97+
const newRect = logoRef.current.getBoundingClientRect();
98+
logoSize.current = { width: newRect.width, height: newRect.height };
99+
100+
calculateAndSetVelocity();
101+
102+
position.current.x = Math.max(0, Math.min(position.current.x, window.innerWidth - logoSize.current.width));
103+
position.current.y = Math.max(0, Math.min(position.current.y, window.innerHeight - logoSize.current.height));
104+
}
105+
}, 250);
106+
};
107+
108+
window.addEventListener("resize", handleResize);
109+
110+
return () => {
111+
cancelAnimationFrame(animationFrameId);
112+
window.removeEventListener("resize", handleResize);
113+
clearTimeout(debounceTimer);
114+
};
115+
}, []);
116+
117+
return createPortal(
118+
<Box
119+
sx={{
120+
position: "fixed",
121+
top: 0,
122+
left: 0,
123+
width: "100vw",
124+
height: "100vh",
125+
bgcolor: "black",
126+
zIndex: 1500,
127+
overflow: "hidden",
128+
cursor: "pointer",
129+
}}
130+
onClick={onClose}
131+
>
132+
<Box
133+
ref={logoRef}
134+
sx={{
135+
position: "absolute",
136+
top: 0,
137+
left: 0,
138+
width: "20vmin",
139+
minWidth: "120px",
140+
maxWidth: "250px",
141+
color: "white",
142+
visibility: isInitialized ? "visible" : "hidden",
143+
"& svg": {
144+
display: "block",
145+
width: "100%",
146+
height: "auto",
147+
},
148+
}}
149+
>
150+
<SplashLogo />
151+
</Box>
152+
</Box>,
153+
document.body
154+
);
155+
};
156+
157+
export default ValetudoBounce;

frontend/src/valetudo/ValetudoAI.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
import React, {useState, useEffect, useRef} from "react";
22
import {Box, Paper, TextField, IconButton, Typography, Avatar, CircularProgress} from "@mui/material";
3-
import {SmartToy as AiIcon, AccountCircle as UserIcon, Send as SendIcon, Replay as ReplayIcon} from "@mui/icons-material";
3+
import {
4+
SmartToy as AiIcon,
5+
AccountCircle as UserIcon,
6+
Send as SendIcon,
7+
Replay as ReplayIcon,
8+
} from "@mui/icons-material";
49
import PaperContainer from "../components/PaperContainer";
510
import DetailPageHeaderRow from "../components/DetailPageHeaderRow";
611
import ElizaBot from "eliza-as-promised";
12+
import ValetudoBounce from "../components/ValetudoBounce";
713

814
interface AiChatMessage {
915
sender: "user" | "ai";
@@ -16,6 +22,7 @@ const ValetudoAI = (): React.ReactElement => {
1622
const [elizaInstance, setElizaInstance] = useState<ElizaBot | null>(null);
1723
const [isLoading, setIsLoading] = useState(true);
1824
const [isFinished, setIsFinished] = useState(false);
25+
const [showEgg, setShowEgg] = useState(false);
1926

2027
const messagesEndRef = useRef<null | HTMLDivElement>(null);
2128
const inputRef = useRef<HTMLInputElement>(null);
@@ -38,7 +45,18 @@ const ValetudoAI = (): React.ReactElement => {
3845

3946

4047
const handleSend = async () => {
41-
if (!inputValue.trim() || !elizaInstance || isLoading || isFinished) {
48+
const trimmedInput = inputValue.trim();
49+
50+
if (trimmedInput.toLowerCase() === "movienight") {
51+
setShowEgg(true);
52+
53+
setInputValue("");
54+
inputRef.current?.blur();
55+
56+
return;
57+
}
58+
59+
if (!trimmedInput || !elizaInstance || isLoading || isFinished) {
4260
return;
4361
}
4462
setTimeout(() => inputRef.current?.focus(), 0); // Keeps the soft keyboard visible on mobile
@@ -196,6 +214,7 @@ const ValetudoAI = (): React.ReactElement => {
196214
</Box>
197215
</Box>
198216
</Box>
217+
{showEgg && <ValetudoBounce onClose={() => setShowEgg(false)} />}
199218
</PaperContainer>
200219
);
201220
};

0 commit comments

Comments
 (0)