diff --git a/.gitignore b/.gitignore index 9275b8b..c5dccb8 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,5 @@ logs/ *.njsproj *.sln *.sw? +.vercel +.env* diff --git a/backend/src/app.js b/backend/src/app.js index fa4d3bd..faca497 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -17,23 +17,26 @@ import supabaseAuth, { optionalAuth } from "./middleware/supabaseAuth.js"; dotenv.config(); const app = express(); -const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173"; +const CORS_ORIGINS = (process.env.CORS_ORIGIN || "http://localhost:5173") + .split(",") + .map((origin) => origin.trim()) + .filter(Boolean); // Trust proxy hop count so req.ip (and therefore rate limiting) sees the real // client IP behind a reverse proxy. Off by default; set TRUST_PROXY=1 in prod. if (process.env.TRUST_PROXY) { - app.set("trust proxy", process.env.TRUST_PROXY); + app.set("trust proxy", process.env.TRUST_PROXY); } // Middleware app.use(securityHeaders); app.use(cookieParser()); app.use(express.json()); -app.use(cors({ origin: CORS_ORIGIN, credentials: true })); +app.use(cors({ origin: CORS_ORIGINS, credentials: true })); // Health check app.get("/health", (_req, res) => { - res.json({ status: "ok", provider: process.env.AI_PROVIDER }); + res.json({ status: "ok", provider: process.env.AI_PROVIDER }); }); // Auth routes — public endpoints (signup, signin, signout, me) @@ -47,16 +50,16 @@ app.use("/api/sessions", sessionsLimiter, supabaseAuth, sessionRoutes); // 404 handler app.use((_req, res) => { - res.status(404).json({ error: "Endpoint not found" }); + res.status(404).json({ error: "Endpoint not found" }); }); // Global error handler — never leak stack traces or internals to the client // (Express's default handler emits HTML stack dumps in non-production). // eslint-disable-next-line no-unused-vars app.use((err, _req, res, _next) => { - console.error("Unhandled error:", err?.message); - if (res.headersSent) return; - res.status(500).json({ error: "Internal server error" }); + console.error("Unhandled error:", err?.message); + if (res.headersSent) return; + res.status(500).json({ error: "Internal server error" }); }); -export default app; \ No newline at end of file +export default app; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f8ddcc4..d094666 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3,12 +3,11 @@ ======================================== */ import { useState, useRef, useCallback, useEffect } from "react"; -import { useNavigate, useParams } from "react-router-dom"; +import { useMatch, useNavigate } from "react-router-dom"; import Sidebar from "./components/sidebar/Sidebar"; import TopNav from "./components/top-nav/TopNav"; import ChatHistory from "./components/chat-history/ChatHistory"; import ChatInput from "./components/chat-input/ChatInput"; -import SelectorFlow from "./components/selector-flow/SelectorFlow"; import { useAuth } from "./lib/auth/SupabaseAuth"; import SignInModal from "./lib/auth/SignIn"; import { resetAppState } from "./appState"; @@ -94,9 +93,10 @@ const chapterData = { }; function App() { - const { isAuthenticated, user, signIn, signUp, signOut, getToken } = useAuth(); + const { isAuthenticated, user, signIn, signUp, signOut, getToken } = + useAuth(); const navigate = useNavigate(); - const { chatId: routeChatId } = useParams(); + const routeChatId = useMatch("/chat/:chatId")?.params.chatId ?? null; // Guards deep-link loading so it only fires once per route/authenticated change. const chatLoadHandledRef = useRef(null); const [messages, setMessages] = useState([]); @@ -166,7 +166,7 @@ function App() { chatLoadHandledRef.current = null; navigate("/"); } - }, [isAuthenticated, navigate, routeChatId]); + }, [isAuthenticated, navigate]); // Deep link: landing on /chat/:chatId loads that conversation (signed-in // users only — anonymous trials are in-memory, so a refreshed trial URL @@ -178,8 +178,6 @@ function App() { handleChatSelect(routeChatId); }, [routeChatId, isAuthenticated]); - - // ─── Auth handlers ─── const handleAuthError = (msg) => { setMessages((prev) => [ @@ -196,7 +194,10 @@ function App() { const headers = {}; if (token) headers["Authorization"] = `Bearer ${token}`; - const res = await fetch(`${API_BASE}/api/sessions`, { headers, credentials: "include" }); + const res = await fetch(`${API_BASE}/api/sessions`, { + headers, + credentials: "include", + }); if (res.ok) { const data = await res.json(); setSessions(data); @@ -250,7 +251,10 @@ function App() { const headers = {}; if (token) headers["Authorization"] = `Bearer ${token}`; - const res = await fetch(`${API_BASE}/api/chat/${sessionId}`, { headers, credentials: "include" }); + const res = await fetch(`${API_BASE}/api/chat/${sessionId}`, { + headers, + credentials: "include", + }); if (!res.ok) { throw new Error("Session not found"); } @@ -338,9 +342,9 @@ function App() { const body = { text: text.trim(), chatId: fetchChatId, - grade: activeGrade !== DEFAULTS.grade ? activeGrade : undefined, - subject: activeSubject !== DEFAULTS.subject ? activeSubject : undefined, - chapter: activeChapter !== DEFAULTS.chapter ? activeChapter : undefined, + grade: activeGrade === DEFAULTS.grade ? undefined : activeGrade, + subject: activeSubject === DEFAULTS.subject ? undefined : activeSubject, + chapter: activeChapter === DEFAULTS.chapter ? undefined : activeChapter, }; // Open SSE connection @@ -406,17 +410,19 @@ function App() { try { const data = JSON.parse(jsonStr); if (data.type === "session") { - // Remember which session this conversation belongs to so - // follow-up messages reuse it (history stays together). - // For anonymous users this is the in-memory trial id. setActiveChat(data.id); - // Only reflect signed-in sessions in the URL. An anonymous - // trial id is a capability (no server ownership check), so - // baking it into the URL would leak it via history/logs/ - // referer — keep it in in-memory state and the POST body only. + if (isAuthenticated) { - navigate(`/chat/${data.id}`, { replace: true }); + // Persist the deep-link without dispatching a router + // navigation. React Router navigation rerenders the route + // tree during the active stream; history.replaceState only + // updates the address bar, so chat state remains untouched. chatLoadHandledRef.current = data.id; + window.history.replaceState( + window.history.state, + "", + `/chat/${encodeURIComponent(data.id)}`, + ); } } else if (data.type === "limit") { // Anonymous free-message budget remaining (server truth). @@ -425,19 +431,15 @@ function App() { assistantText += data.content; setMessages((prev) => prev.map((m) => - m.id === assistantId - ? { ...m, text: assistantText } - : m - ) + m.id === assistantId ? { ...m, text: assistantText } : m, + ), ); } else if (data.type === "done") { // Mark as non-streaming setMessages((prev) => prev.map((m) => - m.id === assistantId - ? { ...m, streaming: false } - : m - ) + m.id === assistantId ? { ...m, streaming: false } : m, + ), ); } else if (data.type === "error") { setMessages((prev) => @@ -448,8 +450,8 @@ function App() { text: `⚠️ Error: ${data.message}`, streaming: false, } - : m - ) + : m, + ), ); } } catch { @@ -461,7 +463,8 @@ function App() { setIsTyping(false); abortRef.current = null; - // Refresh sessions to update timestamps (signed-in users only) + // Refresh the sidebar without changing routes. The active session id + // already lives in state and is reused by the next message. if (isAuthenticated) loadSessions(); }) .catch((err) => { @@ -478,14 +481,23 @@ function App() { abortRef.current = null; }); }, - [isTyping, activeChat, activeGrade, activeSubject, activeChapter, isAnonymous, anonRemaining, isAuthenticated] + [ + isTyping, + activeChat, + activeGrade, + activeSubject, + activeChapter, + isAnonymous, + anonRemaining, + isAuthenticated, + ], ); const handlePromptClick = useCallback( (promptText) => { handleSend(promptText); }, - [handleSend] + [handleSend], ); /* ─── Selector step handlers ─── */ @@ -619,7 +631,9 @@ function App() { onPromptClick={handlePromptClick} isTyping={isTyping} selectorStep={selectorStep} - selectorData={selectorStep !== "chat" ? selectorData[selectorStep] : null} + selectorData={ + selectorStep === "chat" ? null : selectorData[selectorStep] + } /> diff --git a/frontend/src/components/chat-history/ChatHistory.jsx b/frontend/src/components/chat-history/ChatHistory.jsx index 85334f8..937f082 100644 --- a/frontend/src/components/chat-history/ChatHistory.jsx +++ b/frontend/src/components/chat-history/ChatHistory.jsx @@ -2,167 +2,256 @@ ChatHistory Component — padhle (Refined Workspace) ======================================== */ +import { useEffect, useRef } from "react"; import Message from "../message/Message"; import SelectorFlow from "../selector-flow/SelectorFlow"; import "./ChatHistory.css"; const ChatHistory = ({ - messages, - activeSubject, - activeGrade, - activeChapter, - onPromptClick, - isTyping, - selectorStep, - selectorData, + messages, + activeSubject, + activeGrade, + activeChapter, + onPromptClick, + isTyping, + selectorStep, + selectorData, }) => { - const hasMessages = messages.length > 0; - const isSelector = selectorStep !== "chat"; + const hasMessages = messages.length > 0; + const isSelector = selectorStep !== "chat"; - const suggestions = [ - { - id: 1, - icon: "eco", - iconFilled: true, - color: "--color-accent-green", - colorBg: "--color-accent-green-bg", - keywords: ["photosynthesis"], - text: "Explain how ", - textEnd: " works in simple terms", - }, - { - id: 2, - icon: "calculate", - iconFilled: false, - color: "--color-accent-blue", - colorBg: "--color-accent-blue-bg", - keywords: ["derivatives"], - text: "Help me understand ", - textEnd: " in calculus", - }, - { - id: 3, - icon: "account_balance", - iconFilled: false, - color: "--color-accent-orange", - colorBg: "--color-accent-orange-bg", - keywords: ["Industrial"], - text: "What caused the ", - textEnd: " Revolution?", - }, - { - id: 4, - icon: "edit", - iconFilled: true, - color: "--color-accent-purple", - colorBg: "--color-accent-purple-bg", - keywords: ["Romeo and Juliet"], - text: "Help me analyze themes in ", - textEnd: "", - }, - ]; + const suggestions = [ + { + id: 1, + icon: "eco", + iconFilled: true, + color: "--color-accent-green", + colorBg: "--color-accent-green-bg", + keywords: ["photosynthesis"], + text: "Explain how ", + textEnd: " works in simple terms", + }, + { + id: 2, + icon: "calculate", + iconFilled: false, + color: "--color-accent-blue", + colorBg: "--color-accent-blue-bg", + keywords: ["derivatives"], + text: "Help me understand ", + textEnd: " in calculus", + }, + { + id: 3, + icon: "account_balance", + iconFilled: false, + color: "--color-accent-orange", + colorBg: "--color-accent-orange-bg", + keywords: ["Industrial"], + text: "What caused the ", + textEnd: " Revolution?", + }, + { + id: 4, + icon: "edit", + iconFilled: true, + color: "--color-accent-purple", + colorBg: "--color-accent-purple-bg", + keywords: ["Romeo and Juliet"], + text: "Help me analyze themes in ", + textEnd: "", + }, + ]; - const fullPromptTexts = suggestions.map((s) => s.text + s.keywords.join(" ") + s.textEnd); + const fullPromptTexts = suggestions.map( + (s) => s.text + s.keywords.join(" ") + s.textEnd, + ); - const streamingMsg = messages.find((m) => m.streaming); + const streamingMsg = messages.find((m) => m.streaming); + const historyRef = useRef(null); - /* ─── Messages mode ─── */ - if (hasMessages) { - return ( -
Your AI tutor for grades 6–12. Ask anything!
-+ Your AI tutor for grades 6–12. + Ask anything! +
+
- I'll answer questions related to your selected subject and chapter
-
- using your textbook content.
-