diff --git a/.cursor/rules/frontend-rule.mdc b/.cursor/rules/frontend-rule.mdc index f79bf900b..58995e83e 100644 --- a/.cursor/rules/frontend-rule.mdc +++ b/.cursor/rules/frontend-rule.mdc @@ -1,7 +1,5 @@ --- -description: When designing a frontend or frontend components. -globs: -alwaysApply: false +alwaysApply: true --- ### Rules for Frontend diff --git a/.gitignore b/.gitignore index 8b7ec41e3..715a48d0e 100644 --- a/.gitignore +++ b/.gitignore @@ -125,6 +125,8 @@ celerybeat.pid # Environments .env +.env.local +frontend/.env.local .venv env/ venv/ @@ -163,3 +165,7 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ .vercel +node_modules/ +.next/ +frontend/node_modules/ +frontend/.next/ diff --git a/api/index.py b/api/index.py index aa692d294..63e0c3c5d 100644 --- a/api/index.py +++ b/api/index.py @@ -3,6 +3,7 @@ from pydantic import BaseModel from openai import OpenAI import os +import random from dotenv import load_dotenv load_dotenv() @@ -17,7 +18,23 @@ allow_headers=["*"] ) -client = OpenAI(api_key=os.getenv("OPENAI_API_KEY")) +def mock_coach_reply(user_message: str) -> str: + """Return a friendly coaching response without calling external APIs.""" + starter_lines = [ + "You are doing better than you think.", + "Progress beats perfection every time.", + "Small steps done consistently create big results." + ] + action_lines = [ + "Pick one tiny next step you can finish in 10 minutes.", + "Take one slow breath, then tackle the easiest part first.", + "Write down one clear goal for the next hour and focus only on that." + ] + return ( + f"{random.choice(starter_lines)} " + f"{random.choice(action_lines)} " + f"You said: \"{user_message}\"" + ) class ChatRequest(BaseModel): message: str @@ -28,13 +45,22 @@ def root(): @app.post("/api/chat") def chat(request: ChatRequest): + mock_mode_enabled = os.getenv("MOCK_MODE", "true").lower() in {"1", "true", "yes"} + + if mock_mode_enabled: + return {"reply": mock_coach_reply(request.message)} + if not os.getenv("OPENAI_API_KEY"): - raise HTTPException(status_code=500, detail="OPENAI_API_KEY not configured") - + raise HTTPException( + status_code=500, + detail="OPENAI_API_KEY not configured. Set MOCK_MODE=true for no-cost mode." + ) + try: user_message = request.message + client = OpenAI(api_key=os.getenv("OPENAI_API_KEY")) response = client.chat.completions.create( - model="gpt-5", + model="gpt-4.1-mini", messages=[ {"role": "system", "content": "You are a supportive mental coach."}, {"role": "user", "content": user_message} diff --git a/frontend/README.md b/frontend/README.md index 56347bab6..4d314e13c 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,3 +1,29 @@ -### Front End +### Front End: Bouncing Balls + AI Coach -Please populate this README with instructions on how to run the application! \ No newline at end of file +This frontend is a Next.js app that renders a screensaver-style animation of colorful balls bouncing around the viewport, and it integrates with the backend endpoint at `/api/chat`. + +### Run Locally (Frontend + Backend) + +1. Start the backend first from the repo root: + - `uv sync` + - `export OPENAI_API_KEY=your_key_here` + - `uv run uvicorn api.index:app --reload` +2. In a separate terminal, open this folder: + - `cd frontend` +3. Install dependencies: + - `npm install` +4. Set frontend API base URL: + - `echo "NEXT_PUBLIC_API_BASE_URL=http://localhost:8000" > .env.local` +5. Start the development server: + - `npm run dev` +6. Open: + - [http://localhost:3000](http://localhost:3000) + +You can now send a prompt in the **AI Coach** panel and receive backend-generated responses. + +### Production Build Check + +- `npm run build` +- `npm run start` + +The app is built with Next.js and is compatible with Vercel deployment. \ No newline at end of file diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 000000000..25d19ff72 --- /dev/null +++ b/frontend/app/globals.css @@ -0,0 +1,148 @@ +:root { + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + width: 100%; + height: 100%; + overflow: hidden; + font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; + background: radial-gradient(circle at center, #151b33 0%, #090d1b 55%, #030711 100%); + color: #eef2ff; +} + +.screen { + position: relative; + width: 100vw; + height: 100vh; + overflow: hidden; +} + +.ball { + position: absolute; + border-radius: 50%; + opacity: 0.88; + box-shadow: + 0 0 18px rgba(255, 255, 255, 0.23), + 0 0 42px currentColor; +} + +.overlay { + position: absolute; + left: 1.2rem; + top: 1.2rem; + z-index: 10; + background: rgba(5, 9, 20, 0.52); + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: 12px; + padding: 0.75rem 1rem; + backdrop-filter: blur(6px); +} + +.overlay h1 { + margin: 0 0 0.2rem 0; + font-size: 1.05rem; + letter-spacing: 0.02em; +} + +.overlay p { + margin: 0; + font-size: 0.88rem; + color: #cbd5e1; +} + +.chatPanel { + position: absolute; + right: 1.2rem; + bottom: 1.2rem; + width: min(420px, calc(100vw - 2.4rem)); + z-index: 10; + background: rgba(5, 9, 20, 0.74); + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: 14px; + padding: 0.9rem; + backdrop-filter: blur(8px); +} + +.chatPanel h2 { + margin: 0; + font-size: 1rem; +} + +.chatHint { + margin: 0.4rem 0 0.85rem 0; + color: #cbd5e1; + font-size: 0.84rem; +} + +.chatForm { + display: flex; + flex-direction: column; + gap: 0.45rem; +} + +.chatForm label { + font-size: 0.82rem; + color: #e2e8f0; +} + +.chatForm textarea { + width: 100%; + min-height: 88px; + resize: vertical; + border-radius: 10px; + border: 1px solid #475569; + background: rgba(15, 23, 42, 0.93); + color: #f8fafc; + padding: 0.6rem 0.7rem; + font: inherit; +} + +.chatForm textarea:focus { + outline: 2px solid #38bdf8; + outline-offset: 1px; +} + +.chatForm button { + align-self: flex-start; + border: none; + border-radius: 9px; + background: #38bdf8; + color: #082f49; + padding: 0.45rem 0.8rem; + font-weight: 600; + cursor: pointer; +} + +.chatForm button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.replyBox { + margin-top: 0.8rem; + border-top: 1px solid rgba(148, 163, 184, 0.3); + padding-top: 0.7rem; +} + +.replyBox strong { + font-size: 0.84rem; + color: #e2e8f0; +} + +.replyBox p { + margin: 0.35rem 0 0 0; + font-size: 0.9rem; + line-height: 1.35; + color: #f1f5f9; +} + +.errorText { + color: #fca5a5 !important; +} diff --git a/frontend/app/layout.js b/frontend/app/layout.js new file mode 100644 index 000000000..a3177f5be --- /dev/null +++ b/frontend/app/layout.js @@ -0,0 +1,14 @@ +import "./globals.css"; + +export const metadata = { + title: "Bouncing Balls Screensaver", + description: "A relaxing screensaver-style app with bouncing balls." +}; + +export default function RootLayout({ children }) { + return ( + + {children} + + ); +} diff --git a/frontend/app/page.js b/frontend/app/page.js new file mode 100644 index 000000000..744c7cdc4 --- /dev/null +++ b/frontend/app/page.js @@ -0,0 +1,188 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; + +const BALL_COUNT = 24; +const MAX_SPEED = 2.3; +const MIN_RADIUS = 10; +const MAX_RADIUS = 28; + +function randomBetween(min, max) { + return Math.random() * (max - min) + min; +} + +function createBall(width, height, color) { + const radius = randomBetween(MIN_RADIUS, MAX_RADIUS); + const safeWidth = Math.max(width, radius * 2 + 1); + const safeHeight = Math.max(height, radius * 2 + 1); + + return { + x: randomBetween(radius, safeWidth - radius), + y: randomBetween(radius, safeHeight - radius), + vx: randomBetween(-MAX_SPEED, MAX_SPEED) || 1.1, + vy: randomBetween(-MAX_SPEED, MAX_SPEED) || -1.1, + radius, + color + }; +} + +export default function Page() { + const containerRef = useRef(null); + const animationFrameRef = useRef(null); + const [size, setSize] = useState({ width: 0, height: 0 }); + const [inputMessage, setInputMessage] = useState(""); + const [coachReply, setCoachReply] = useState( + "Say hi to the backend-powered AI coach." + ); + const [isLoading, setIsLoading] = useState(false); + const [requestError, setRequestError] = useState(""); + + const palette = useMemo( + () => ["#00E5FF", "#A855F7", "#22C55E", "#F97316", "#F43F5E", "#38BDF8"], + [] + ); + + const [balls, setBalls] = useState([]); + + useEffect(() => { + const updateSize = () => { + if (!containerRef.current) return; + const { clientWidth, clientHeight } = containerRef.current; + setSize({ width: clientWidth, height: clientHeight }); + }; + + updateSize(); + window.addEventListener("resize", updateSize); + return () => window.removeEventListener("resize", updateSize); + }, []); + + useEffect(() => { + if (!size.width || !size.height) return; + + setBalls( + Array.from({ length: BALL_COUNT }, (_, index) => + createBall(size.width, size.height, palette[index % palette.length]) + ) + ); + }, [palette, size.height, size.width]); + + useEffect(() => { + if (!size.width || !size.height || balls.length === 0) return; + + const animate = () => { + setBalls((prevBalls) => + prevBalls.map((ball) => { + let { x, y, vx, vy, radius } = ball; + + x += vx; + y += vy; + + if (x - radius <= 0 || x + radius >= size.width) { + vx *= -1; + x = Math.min(Math.max(radius, x), size.width - radius); + } + + if (y - radius <= 0 || y + radius >= size.height) { + vy *= -1; + y = Math.min(Math.max(radius, y), size.height - radius); + } + + return { ...ball, x, y, vx, vy }; + }) + ); + + animationFrameRef.current = requestAnimationFrame(animate); + }; + + animationFrameRef.current = requestAnimationFrame(animate); + + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + }, [balls.length, size.height, size.width]); + + const handleSubmit = async (event) => { + event.preventDefault(); + const trimmedMessage = inputMessage.trim(); + + if (!trimmedMessage || isLoading) return; + + const apiBaseUrl = + process.env.NEXT_PUBLIC_API_BASE_URL?.trim() || "http://localhost:8000"; + + setIsLoading(true); + setRequestError(""); + + try { + const response = await fetch(`${apiBaseUrl}/api/chat`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ message: trimmedMessage }) + }); + + const payload = await response.json(); + + if (!response.ok) { + throw new Error(payload.detail || "The backend request failed."); + } + + setCoachReply(payload.reply || "The coach responded with no message."); + setInputMessage(""); + } catch (error) { + setRequestError( + error instanceof Error + ? error.message + : "Could not reach the backend. Is it running?" + ); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ {balls.map((ball, idx) => ( + + ))} +
+

Bouncing Balls

+

Screensaver animation + backend-connected AI coach.

+
+
+

AI Coach

+

Powered by the FastAPI backend at `/api/chat`.

+
+ +