From 8b9e0df2b24474f29fbc5ae9d04485708d87976d Mon Sep 17 00:00:00 2001 From: chait Date: Sat, 19 Sep 2026 06:54:13 -0400 Subject: [PATCH] fixed page state while generating a message | fixed scrolling effect --- frontend/package.json | 1 + frontend/src/App.jsx | 650 +++--------------- frontend/src/appState.js | 18 - .../components/chat-history/ChatHistory.css | 259 +++---- .../components/chat-history/ChatHistory.jsx | 411 +++++------ .../src/components/chat-input/ChatInput.css | 283 ++++---- frontend/src/components/message/Message.css | 45 +- frontend/src/data/curriculum.js | 83 +++ frontend/src/data/curriculum.test.js | 15 + frontend/src/hooks/useChat.js | 301 ++++++++ frontend/src/lib/auth/SupabaseAuth.jsx | 14 +- frontend/src/lib/auth/backendAuth.js | 17 +- frontend/src/lib/chatApi.js | 17 + frontend/src/lib/readSseData.js | 29 + frontend/src/lib/readSseData.test.js | 31 + frontend/src/styles/global.css | 242 +++---- package.json | 1 + 17 files changed, 1190 insertions(+), 1227 deletions(-) delete mode 100644 frontend/src/appState.js create mode 100644 frontend/src/data/curriculum.js create mode 100644 frontend/src/data/curriculum.test.js create mode 100644 frontend/src/hooks/useChat.js create mode 100644 frontend/src/lib/chatApi.js create mode 100644 frontend/src/lib/readSseData.js create mode 100644 frontend/src/lib/readSseData.test.js diff --git a/frontend/package.json b/frontend/package.json index 1d4dbf6..c889271 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "vite build", + "test": "node --test src/**/*.test.js", "preview": "vite preview" }, "dependencies": { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d094666..71123f9 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,596 +1,140 @@ -/* ======================================== - App Component — padhle (Backend-Connected) - ======================================== */ - -import { useState, useRef, useCallback, useEffect } from "react"; +import { useCallback, useState } from "react"; 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 { useAuth } from "./lib/auth/SupabaseAuth"; import SignInModal from "./lib/auth/SignIn"; -import { resetAppState } from "./appState"; -const API_BASE = ""; // Uses Vite proxy → localhost:3001 in dev - -// Default selectors -const DEFAULTS = { - grade: "Choose standard", - subject: "choose-subject", - chapter: "choose-chapter", -}; - -/* ─── Selector data ─── */ - -const gradeItems = [ - { id: "grade-6", label: "Grade 6", icon: "backpack" }, - { id: "grade-7", label: "Grade 7", icon: "auto_stories" }, - { id: "grade-8", label: "Grade 8", icon: "menu_book" }, - { id: "grade-9", label: "Grade 9", icon: "science" }, - { id: "grade-10", label: "Grade 10", icon: "calculate" }, - { id: "grade-11", label: "Grade 11", icon: "psychology" }, - { id: "grade-12", label: "Grade 12", icon: "school" }, -]; - -const subjectItems = [ - { id: "math", label: "Mathematics" }, - { id: "science", label: "Science" }, - { id: "history", label: "History" }, - { id: "language", label: "Language Arts" }, - { id: "english", label: "English" }, - { id: "geography", label: "Geography" }, -]; - -const chapterData = { - math: [ - { id: "ch-1", label: "Chapter 1: Rational Numbers" }, - { id: "ch-2", label: "Chapter 2: Linear Equations" }, - { id: "ch-3", label: "Chapter 3: Coordinate Geometry" }, - { id: "ch-4", label: "Chapter 4: Algebra" }, - { id: "ch-5", label: "Chapter 5: Geometry" }, - { id: "ch-6", label: "Chapter 6: Trigonometry" }, - ], - science: [ - { id: "ch-1", label: "Chapter 1: Nutrition in Plants" }, - { id: "ch-2", label: "Chapter 2: Photosynthesis" }, - { id: "ch-3", label: "Chapter 3: Human Physiology" }, - { id: "ch-4", label: "Chapter 4: Chemistry Basics" }, - { id: "ch-5", label: "Chapter 5: Motion & Force" }, - { id: "ch-6", label: "Chapter 6: Electricity" }, - ], - history: [ - { id: "ch-1", label: "Chapter 1: Early Civilizations" }, - { id: "ch-2", label: "Chapter 2: Ancient India" }, - { id: "ch-3", label: "Chapter 3: Medieval Period" }, - { id: "ch-4", label: "Chapter 4: Modern India" }, - { id: "ch-5", label: "Chapter 5: World Wars" }, - { id: "ch-6", label: "Chapter 6: Independence Movement" }, - ], - language: [ - { id: "ch-1", label: "Chapter 1: Grammar Basics" }, - { id: "ch-2", label: "Chapter 2: Comprehension" }, - { id: "ch-3", label: "Chapter 3: Creative Writing" }, - { id: "ch-4", label: "Chapter 4: Literature" }, - { id: "ch-5", label: "Chapter 5: Vocabulary" }, - { id: "ch-6", label: "Chapter 6: Composition" }, - ], - english: [ - { id: "ch-1", label: "Chapter 1: Reading Skills" }, - { id: "ch-2", label: "Chapter 2: Writing Skills" }, - { id: "ch-3", label: "Chapter 3: Grammar" }, - { id: "ch-4", label: "Chapter 4: Poetry" }, - { id: "ch-5", label: "Chapter 5: Prose" }, - { id: "ch-6", label: "Chapter 6: Drama" }, - ], - geography: [ - { id: "ch-1", label: "Chapter 1: Earth & Universe" }, - { id: "ch-2", label: "Chapter 2: Landforms" }, - { id: "ch-3", label: "Chapter 3: Climate & Weather" }, - { id: "ch-4", label: "Chapter 4: Natural Resources" }, - { id: "ch-5", label: "Chapter 5: Maps & Atlas" }, - { id: "ch-6", label: "Chapter 6: Human Geography" }, - ], -}; +import { useAuth } from "./lib/auth/SupabaseAuth"; +import { useChat } from "./hooks/useChat"; +import { + chapterData, + DEFAULTS, + getChapterLabel, + getSubjectLabel, + gradeItems, + subjectItems, +} from "./data/curriculum"; function App() { - const { isAuthenticated, user, signIn, signUp, signOut, getToken } = - useAuth(); + const { isAuthenticated, user, signIn, signUp, signOut } = useAuth(); const navigate = useNavigate(); 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([]); - const [activeSubject, setActiveSubject] = useState(DEFAULTS.subject); + const [showSignIn, setShowSignIn] = useState(false); + const requestSignIn = useCallback(() => setShowSignIn(true), []); - /* ─── Subject label resolution (ID → display name) ─── */ - const getSubjectLabel = (id) => { - if (!id || id === "choose-subject") return "Choose Subject"; - return subjectItems.find((s) => s.id === id)?.label || id; - }; + const chat = useChat({ + isAuthenticated, + routeChatId, + navigate, + onSignInRequired: requestSignIn, + }); - /* ─── Chapter list resolution (subject ID → array) ─── */ - const getChaptersForSubject = (subjectId) => { - return chapterData[subjectId] || []; - }; + const { + messages, + activeChat, + activeGrade, + activeSubject, + activeChapter, + selectorStep, + sessions, + isLoadingSessions, + isTyping, + anonRemaining, + setActiveGrade, + setActiveSubject, + setActiveChapter, + setSelectorStep, + addError, + newChat, + selectChat, + deleteChat, + send, + } = chat; - /* ─── Chapter label resolution (ID → display name) ─── */ - const getChapterLabel = (chapterId) => { - if (!chapterId || chapterId === "choose-chapter") return "Choose Chapter"; - return chapterId; - }; - const [activeChat, setActiveChat] = useState(null); - const [activeGrade, setActiveGrade] = useState(DEFAULTS.grade); - const [activeChapter, setActiveChapter] = useState(DEFAULTS.chapter); - const [isTyping, setIsTyping] = useState(false); - const [sessions, setSessions] = useState([]); - const [isLoadingSessions, setIsLoadingSessions] = useState(false); - const [selectorStep, setSelectorStep] = useState("grade"); // grade → subject → chapter → chat - const [showSignIn, setShowSignIn] = useState(false); // modal is on-demand now - const [anonRemaining, setAnonRemaining] = useState(5); // anonymous free-message budget - const abortRef = useRef(null); - const hasMountedRef = useRef(false); // track first mount to avoid resetting on initial load - const isAnonymous = !isAuthenticated; - - // Reset all user-scoped state whenever authentication changes. - useEffect(() => { - if (!hasMountedRef.current) { - hasMountedRef.current = true; - if (isAuthenticated) loadSessions(); - return; - } - - if (abortRef.current) { - abortRef.current.abort(); - abortRef.current = null; - } - - const next = resetAppState(); - setMessages(next.messages); - setActiveChat(next.activeChat); - setActiveGrade(next.activeGrade); - setActiveSubject(next.activeSubject); - setActiveChapter(next.activeChapter); - setIsTyping(next.isTyping); - setSelectorStep(next.selectorStep); - setSessions(next.sessions); - setShowSignIn(false); - setAnonRemaining(5); // a fresh anonymous budget per auth transition (incl. sign-out) - - if (isAuthenticated) loadSessions(); - - // After an auth transition, drop any chat URL we only reached in-app - // (e.g. an anonymous trial, or a session opened before sign-out). A cold - // deep link (chatLoadHandledRef.current === null) is left alone so the - // deep-link effect below can restore it once auth restore completes. - if (chatLoadHandledRef.current === routeChatId) { - chatLoadHandledRef.current = null; - navigate("/"); - } - }, [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 - // simply starts fresh, matching the "refresh loses the conversation" rule). - useEffect(() => { - if (!routeChatId || !isAuthenticated) return; - if (chatLoadHandledRef.current === routeChatId) return; - chatLoadHandledRef.current = routeChatId; - handleChatSelect(routeChatId); - }, [routeChatId, isAuthenticated]); - - // ─── Auth handlers ─── - const handleAuthError = (msg) => { - setMessages((prev) => [ - ...prev, - { id: Date.now(), role: "assistant", text: `⚠️ ${msg}` }, - ]); - }; - - const loadSessions = async () => { - if (!isAuthenticated) return; // sessions require an account - setIsLoadingSessions(true); - try { - const token = await getToken(); - const headers = {}; - if (token) headers["Authorization"] = `Bearer ${token}`; - - const res = await fetch(`${API_BASE}/api/sessions`, { - headers, - credentials: "include", - }); - if (res.ok) { - const data = await res.json(); - setSessions(data); - } - } catch (err) { - console.error("Failed to load sessions:", err); - } finally { - setIsLoadingSessions(false); - } - }; - - const handleNewChat = () => { - // Abort any in-flight request - if (abortRef.current) { - abortRef.current.abort(); - abortRef.current = null; - } - - // Clear everything - setMessages([]); - setActiveChat(null); - setActiveGrade(DEFAULTS.grade); - setActiveSubject(DEFAULTS.subject); + const selectSubject = (id) => { + setActiveSubject(id); setActiveChapter(DEFAULTS.chapter); - setIsTyping(false); - - // Reset selector to grade step - setSelectorStep("grade"); - - // Refresh sessions list from backend - loadSessions(); - - // Back to the home URL - navigate("/"); - chatLoadHandledRef.current = null; + if (selectorStep !== "chat") setSelectorStep("subject"); }; - const handleChatSelect = async (sessionId) => { - if (!isAuthenticated) return; // anonymous users have no persisted sessions - // Abort any in-flight request - if (abortRef.current) { - abortRef.current.abort(); - abortRef.current = null; - } - - setIsTyping(true); - setMessages([]); - - try { - const token = await getToken(); - const headers = {}; - if (token) headers["Authorization"] = `Bearer ${token}`; - - const res = await fetch(`${API_BASE}/api/chat/${sessionId}`, { - headers, - credentials: "include", - }); - if (!res.ok) { - throw new Error("Session not found"); - } - const data = await res.json(); - - // Set the session's context (grade, subject, chapter) - if (data.session) { - setActiveGrade(data.session.grade || DEFAULTS.grade); - setActiveSubject(data.session.subject || DEFAULTS.subject); - setActiveChapter(data.session.chapter || DEFAULTS.chapter); - } - - // Move to chat view - setSelectorStep("chat"); - - // Load the messages - if (data.messages) { - setMessages(data.messages.map((m) => ({ ...m, streaming: false }))); - } - - setActiveChat(sessionId); - // Mark as handled so the deep-link effect doesn't re-fetch it. - chatLoadHandledRef.current = sessionId; - navigate(`/chat/${sessionId}`, { replace: true }); - } catch (err) { - console.error("Failed to load session:", err); - setMessages([ - { - id: Date.now(), - role: "assistant", - text: `⚠️ Could not load that chat. It may have been deleted.`, - }, - ]); - } finally { - setIsTyping(false); - } + const selectChapter = (chapter) => { + setActiveChapter(chapter); + if (selectorStep !== "chat") setSelectorStep("chapter"); }; - const handleDeleteChat = async (sessionId) => { - try { - const token = await getToken(); - const headers = {}; - if (token) headers["Authorization"] = `Bearer ${token}`; - - const res = await fetch(`${API_BASE}/api/sessions/${sessionId}`, { - method: "DELETE", - headers, - credentials: "include", - }); - if (res.ok) { - // If we were on the deleted chat, clear it - if (activeChat === sessionId) { - handleNewChat(); - } else { - loadSessions(); - } - } - } catch (err) { - console.error("Failed to delete session:", err); - } - }; - - const handleSend = useCallback( - async (text) => { - if (!text.trim() || isTyping) return; - // Hard client-side gate for anonymous users past the free budget. - if (isAnonymous && anonRemaining <= 0) { - setShowSignIn(true); - return; - } - - // Abort any previous in-flight request - if (abortRef.current) { - abortRef.current.abort(); - } - - // Add user message immediately - const userMsg = { id: Date.now(), role: "user", text }; - setMessages((prev) => [...prev, userMsg]); - setIsTyping(true); - - const fetchChatId = activeChat; - - // Build request body - const body = { - text: text.trim(), - chatId: fetchChatId, - grade: activeGrade === DEFAULTS.grade ? undefined : activeGrade, - subject: activeSubject === DEFAULTS.subject ? undefined : activeSubject, - chapter: activeChapter === DEFAULTS.chapter ? undefined : activeChapter, - }; - - // Open SSE connection - const controller = new AbortController(); - abortRef.current = controller; - - const token = await getToken(); - const headers = { "Content-Type": "application/json" }; - if (token) headers["Authorization"] = `Bearer ${token}`; - - fetch(`${API_BASE}/api/chat`, { - method: "POST", - headers, - credentials: "include", - body: JSON.stringify(body), - signal: controller.signal, - }) - .then(async (response) => { - if (!response.ok) { - // Anonymous budget exhausted server-side → show the sign-in gate. - if (response.status === 403) { - let data = null; - try { - data = await response.json(); - } catch { - /* non-JSON body */ - } - if (data?.code === "SIGNIN_REQUIRED") { - setAnonRemaining(0); - setIsTyping(false); - abortRef.current = null; - return; - } - } - throw new Error(`Server error: ${response.status}`); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - let assistantText = ""; - - // Create placeholder assistant message - const assistantId = Date.now() + 1; - setMessages((prev) => [ - ...prev, - { id: assistantId, role: "assistant", text: "", streaming: true }, - ]); - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - - for (const line of lines) { - if (!line.startsWith("data: ")) continue; - const jsonStr = line.slice(6); - if (jsonStr === "[DONE]") continue; - - try { - const data = JSON.parse(jsonStr); - if (data.type === "session") { - setActiveChat(data.id); - - if (isAuthenticated) { - // 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). - setAnonRemaining(data.remaining); - } else if (data.type === "chunk") { - assistantText += data.content; - setMessages((prev) => - prev.map((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, - ), - ); - } else if (data.type === "error") { - setMessages((prev) => - prev.map((m) => - m.id === assistantId - ? { - ...m, - text: `⚠️ Error: ${data.message}`, - streaming: false, - } - : m, - ), - ); - } - } catch { - // Skip malformed SSE frames - } - } - } - - setIsTyping(false); - abortRef.current = null; - - // 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) => { - if (err.name === "AbortError") return; // intentional abort - setIsTyping(false); - setMessages((prev) => [ - ...prev, - { - id: Date.now() + 1, - role: "assistant", - text: `⚠️ Connection error: ${err.message}. Make sure the backend is running on port 3001.`, - }, - ]); - abortRef.current = null; - }); - }, - [ - isTyping, - activeChat, - activeGrade, - activeSubject, - activeChapter, - isAnonymous, - anonRemaining, - isAuthenticated, - ], - ); - - const handlePromptClick = useCallback( - (promptText) => { - handleSend(promptText); - }, - [handleSend], - ); - - /* ─── Selector step handlers ─── */ - - const handleGradeSelect = (item) => { - setActiveGrade(item.label); - setSelectorStep("subject"); - }; - - const handleSubjectSelect = (item) => { - setActiveSubject(item.id); - setActiveChapter(DEFAULTS.chapter); - setSelectorStep("chapter"); - }; - - const handleChapterSelect = (item) => { - setActiveChapter(item.label); - setSelectorStep("chat"); - }; - - /* ─── Selector step data ─── */ - const selectorData = { grade: { title: "Select your standard", subtitle: "Choose the grade you are studying in", items: gradeItems, - selected: activeGrade, - onSelect: handleGradeSelect, + selectedValue: activeGrade, + compareField: "label", + onSelect: (item) => { + setActiveGrade(item.label); + setSelectorStep("subject"); + }, }, subject: { title: "Select your subject", - subtitle: `Chose by grade – ${activeGrade}`, + subtitle: `Chosen grade – ${activeGrade}`, items: subjectItems, selectedValue: activeSubject, - onSelect: handleSubjectSelect, compareField: "id", + onSelect: (item) => { + setActiveSubject(item.id); + setActiveChapter(DEFAULTS.chapter); + setSelectorStep("chapter"); + }, }, chapter: { title: "Select a chapter", subtitle: `Subject – ${getSubjectLabel(activeSubject)}`, - items: getChaptersForSubject(activeSubject), + items: chapterData[activeSubject] || [], selectedValue: activeChapter, - onSelect: handleChapterSelect, compareField: "label", + onSelect: (item) => { + setActiveChapter(item.label); + setSelectorStep("chat"); + }, }, }; - // ─── Render — the app is always accessible; sign-in is on-demand ─── + const authenticate = async (action, email, password) => { + try { + await action(email, password); + setShowSignIn(false); + } catch (error) { + addError(error.message || "Authentication failed"); + } + }; + + const isAnonymous = !isAuthenticated; + return ( <> {showSignIn && ( setShowSignIn(false)} - onSignIn={async (email, password) => { - try { - await signIn(email, password); - setShowSignIn(false); - } catch (err) { - handleAuthError(err.message || "Sign in failed"); - } - }} - onSignUp={async (email, password) => { - try { - await signUp(email, password); - setShowSignIn(false); - } catch (err) { - handleAuthError(err.message || "Sign up failed"); - } - }} - onError={handleAuthError} + onSignIn={(email, password) => authenticate(signIn, email, password)} + onSignUp={(email, password) => authenticate(signUp, email, password)} + onError={addError} /> )}
{ - setActiveSubject(id); - setActiveChapter(DEFAULTS.chapter); - if (selectorStep !== "chat") { - setSelectorStep("subject"); - } - }} + onSubjectChange={selectSubject} activeChat={activeChat} - onChatSelect={handleChatSelect} - onNewChat={handleNewChat} + onChatSelect={selectChat} + onNewChat={newChat} sessions={sessions} - onDeleteChat={handleDeleteChat} + onDeleteChat={deleteChat} isLoadingSessions={isLoadingSessions} showUpgrade={false} isAnonymous={isAnonymous} - onSignInRequest={() => setShowSignIn(true)} + onSignInRequest={requestSignIn} user={user} onSignOut={signOut} /> @@ -598,26 +142,15 @@ function App() {
setActiveGrade(g)} + onGradeChange={setActiveGrade} subjectItems={subjectItems} chapterData={chapterData} activeSubject={activeSubject} activeChapter={activeChapter} - onChapterChange={(ch) => { - setActiveChapter(ch); - if (selectorStep !== "chat") { - setSelectorStep("chapter"); - } - }} - onSubjectChange={(id) => { - setActiveSubject(id); - setActiveChapter(DEFAULTS.chapter); - if (selectorStep !== "chat") { - setSelectorStep("subject"); - } - }} + onChapterChange={selectChapter} + onSubjectChange={selectSubject} isAnonymous={isAnonymous} - onSignInRequest={() => setShowSignIn(true)} + onSignInRequest={requestSignIn} user={user} onSignOut={signOut} /> @@ -625,10 +158,7 @@ function App() {
setShowSignIn(true)} + onSignInRequest={requestSignIn} />
diff --git a/frontend/src/appState.js b/frontend/src/appState.js deleted file mode 100644 index ad95d4f..0000000 --- a/frontend/src/appState.js +++ /dev/null @@ -1,18 +0,0 @@ -export const DEFAULT_APP_STATE = Object.freeze({ - messages: [], - activeChat: null, - activeGrade: "Choose standard", - activeSubject: "choose-subject", - activeChapter: "choose-chapter", - isTyping: false, - sessions: [], - selectorStep: "grade", -}); - -export function resetAppState() { - return { - ...DEFAULT_APP_STATE, - messages: [], - sessions: [], - }; -} diff --git a/frontend/src/components/chat-history/ChatHistory.css b/frontend/src/components/chat-history/ChatHistory.css index 33bca4b..e9da3a5 100644 --- a/frontend/src/components/chat-history/ChatHistory.css +++ b/frontend/src/components/chat-history/ChatHistory.css @@ -2,211 +2,220 @@ ChatHistory Component — padhle (Refined Workspace) ======================================== */ -/* Main Chat History */ +/* Main Chat History — the only scroll container in the app shell. + min-height: 0 lets it shrink below content height so it scrolls + internally instead of stretching the page. */ .chat-history { - flex: 1; - overflow-y: auto; - background-color: var(--color-background); + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + background-color: var(--color-background); } /* Focus Column — centered content area */ .chat-history__focus { - max-width: var(--container-max); - margin: 0 auto; - padding: 48px 24px 128px; - display: flex; - flex-direction: column; - align-items: center; - min-height: 100%; + max-width: var(--container-max); + margin: 0 auto; + padding: 48px 24px 128px; + display: flex; + flex-direction: column; + align-items: center; + min-height: 100%; } /* Welcome Header */ .chat-history__welcome { - text-align: center; - margin-bottom: 48px; + text-align: center; + margin-bottom: 48px; } .chat-history__welcome-icon { - display: flex; - align-items: center; - justify-content: center; - position: relative; - margin-bottom: 16px; - color: var(--color-primary); + display: flex; + align-items: center; + justify-content: center; + position: relative; + margin-bottom: 16px; + color: var(--color-primary); } .chat-history__welcome-icon .material-symbols-outlined { - font-size: 48px; - line-height: 1; + font-size: 48px; + line-height: 1; } .chat-history__welcome-sparkle { - position: absolute; - top: -4px; - right: -16px; - font-size: 20px; - color: var(--color-primary); - font-weight: 700; + position: absolute; + top: -4px; + right: -16px; + font-size: 20px; + color: var(--color-primary); + font-weight: 700; } .chat-history__welcome-title { - font-family: var(--font-heading); - font-size: var(--font-size-display-lg); - font-weight: var(--font-weight-bold); - color: var(--color-primary); - letter-spacing: -0.02em; - line-height: var(--line-height-tight); - margin-bottom: 12px; + font-family: var(--font-heading); + font-size: var(--font-size-display-lg); + font-weight: var(--font-weight-bold); + color: var(--color-primary); + letter-spacing: -0.02em; + line-height: var(--line-height-tight); + margin-bottom: 12px; } .chat-history__welcome-subtitle { - font-size: var(--font-size-body-md); - color: var(--color-on-surface-variant); - max-width: 440px; - margin: 0 auto; - line-height: var(--line-height-relaxed); + font-size: var(--font-size-body-md); + color: var(--color-on-surface-variant); + max-width: 440px; + margin: 0 auto; + line-height: var(--line-height-relaxed); } /* Bento Grid */ .chat-history__bento { - display: grid; - grid-template-columns: 1fr; - gap: 16px; - width: 100%; - margin-bottom: 24px; + display: grid; + grid-template-columns: 1fr; + gap: 16px; + width: 100%; + margin-bottom: 24px; } @media (min-width: 768px) { - .chat-history__bento { - grid-template-columns: repeat(4, 1fr); - } + .chat-history__bento { + grid-template-columns: repeat(4, 1fr); + } } /* Bento Card */ .chat-history__bento-card { - position: relative; - text-align: left; - padding: 20px; - border: var(--border-thin); - border-radius: var(--radius-xl); - background-color: var(--color-surface-lowest); - cursor: pointer; - transition: border-color var(--transition-fast), box-shadow var(--transition-fast), transform var(--transition-fast); - display: flex; - flex-direction: column; - min-height: 120px; - overflow: hidden; + position: relative; + text-align: left; + padding: 20px; + border: var(--border-thin); + border-radius: var(--radius-xl); + background-color: var(--color-surface-lowest); + cursor: pointer; + transition: + border-color var(--transition-fast), + box-shadow var(--transition-fast), + transform var(--transition-fast); + display: flex; + flex-direction: column; + min-height: 120px; + overflow: hidden; } .chat-history__bento-card:hover { - border-color: var(--color-outline); - box-shadow: var(--shadow-card-hover); + border-color: var(--color-outline); + box-shadow: var(--shadow-card-hover); } .chat-history__bento-icon-wrap { - width: 40px; - height: 40px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 16px; + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 16px; } .chat-history__bento-icon-wrap .material-symbols-outlined { - font-size: 20px; - line-height: 1; + font-size: 20px; + line-height: 1; } .chat-history__bento-text { - flex: 1; - font-size: 14px; - line-height: 1.625; - color: var(--color-on-surface); - margin-bottom: 16px; + flex: 1; + font-size: 14px; + line-height: 1.625; + color: var(--color-on-surface); + margin-bottom: 16px; } .chat-history__bento-text-main { - color: var(--color-on-surface); + color: var(--color-on-surface); } .chat-history__bento-text-highlight { - font-weight: 600; + font-weight: 600; } .chat-history__bento-arrow { - position: absolute; - bottom: 20px; - right: 20px; - font-size: 20px; - line-height: 1; - color: var(--color-on-surface-variant); - opacity: 0.5; - transition: color var(--transition-fast), opacity var(--transition-fast); + position: absolute; + bottom: 20px; + right: 20px; + font-size: 20px; + line-height: 1; + color: var(--color-on-surface-variant); + opacity: 0.5; + transition: + color var(--transition-fast), + opacity var(--transition-fast); } .chat-history__bento-card:hover .chat-history__bento-arrow { - color: var(--color-primary); - opacity: 1; + color: var(--color-primary); + opacity: 1; } /* Info Panel */ .chat-history__info { - width: 100%; - border: var(--border-thin); - border-radius: var(--radius-xl); - padding: 24px; - display: flex; - align-items: flex-start; - gap: 16px; - background-color: var(--color-surface-lowest); - position: relative; - overflow: hidden; + width: 100%; + border: var(--border-thin); + border-radius: var(--radius-xl); + padding: 24px; + display: flex; + align-items: flex-start; + gap: 16px; + background-color: var(--color-surface-lowest); + position: relative; + overflow: hidden; } .chat-history__info-icon-wrap { - width: 48px; - height: 48px; - border-radius: 50%; - background-color: var(--color-surface-low); - display: flex; - align-items: center; - justify-content: center; - border: var(--border-thin); - border-color: rgba(0, 0, 0, 0.05); - flex-shrink: 0; + width: 48px; + height: 48px; + border-radius: 50%; + background-color: var(--color-surface-low); + display: flex; + align-items: center; + justify-content: center; + border: var(--border-thin); + border-color: rgba(0, 0, 0, 0.05); + flex-shrink: 0; } .chat-history__info-icon-wrap .material-symbols-outlined { - font-size: 22px; - line-height: 1; - color: var(--color-on-surface); + font-size: 22px; + line-height: 1; + color: var(--color-on-surface); } .chat-history__info-content { - flex: 1; + flex: 1; } .chat-history__info-title { - font-size: 15px; - font-weight: var(--font-weight-bold); - color: var(--color-primary); - margin-bottom: 4px; + font-size: 15px; + font-weight: var(--font-weight-bold); + color: var(--color-primary); + margin-bottom: 4px; } .chat-history__info-text { - font-size: 14px; - line-height: 1.4; - color: var(--color-on-surface-variant); + font-size: 14px; + line-height: 1.4; + color: var(--color-on-surface-variant); } /* Message List */ .chat-history__list { - display: flex; - flex-direction: column; - gap: 24px; - padding: 24px 0; - width: 100%; - max-width: var(--container-max); - margin: 0 auto; + display: flex; + flex-direction: column; + gap: 24px; + padding: 24px; + width: 100%; + max-width: var(--container-max); + margin: 0 auto; } diff --git a/frontend/src/components/chat-history/ChatHistory.jsx b/frontend/src/components/chat-history/ChatHistory.jsx index 937f082..aeec108 100644 --- a/frontend/src/components/chat-history/ChatHistory.jsx +++ b/frontend/src/components/chat-history/ChatHistory.jsx @@ -1,257 +1,194 @@ -/* ======================================== - 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 suggestions = [ + { + icon: "eco", + filled: true, + color: "--color-accent-green", + background: "--color-accent-green-bg", + before: "Explain how ", + highlight: "photosynthesis", + after: " works in simple terms", + }, + { + icon: "calculate", + color: "--color-accent-blue", + background: "--color-accent-blue-bg", + before: "Help me understand ", + highlight: "derivatives", + after: " in calculus", + }, + { + icon: "account_balance", + color: "--color-accent-orange", + background: "--color-accent-orange-bg", + before: "What caused the ", + highlight: "Industrial", + after: " Revolution?", + }, + { + icon: "edit", + filled: true, + color: "--color-accent-purple", + background: "--color-accent-purple-bg", + before: "Help me analyze themes in ", + highlight: "Romeo and Juliet", + after: "", + }, +]; + const ChatHistory = ({ - messages, - activeSubject, - activeGrade, - activeChapter, - onPromptClick, - isTyping, - selectorStep, - selectorData, + messages, + onPromptClick, + isTyping, + selectorStep, + selectorData, }) => { - const hasMessages = messages.length > 0; - const isSelector = selectorStep !== "chat"; + const historyRef = useRef(null); + const autoFollowRef = useRef(true); + const hasMessages = messages.length > 0; + const isStreaming = messages.some((message) => message.streaming); + const latestText = messages[messages.length - 1]?.text; - 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 scrollToBottom = () => { + const history = historyRef.current; + if (history) history.scrollTop = history.scrollHeight; + }; - const fullPromptTexts = suggestions.map( - (s) => s.text + s.keywords.join(" ") + s.textEnd, - ); + useEffect(() => { + autoFollowRef.current = true; + scrollToBottom(); + }, [messages.length]); - const streamingMsg = messages.find((m) => m.streaming); - const historyRef = useRef(null); + useEffect(() => { + if (autoFollowRef.current) scrollToBottom(); + }, [latestText]); - // Scroll only the chat pane, and only when a message is added. Streaming - // token updates must not move the page or restart a smooth-scroll animation. - useEffect(() => { - const history = historyRef.current; - if (history) history.scrollTop = history.scrollHeight; - }, [messages.length]); + const handleScroll = () => { + const history = historyRef.current; + if (!history) return; + autoFollowRef.current = + history.scrollHeight - history.scrollTop - history.clientHeight < 80; + }; - /* ─── Messages mode ─── */ - if (hasMessages) { - return ( -
-
- {messages.map((message) => ( - - ))} - {isTyping && !streamingMsg && ( - - )} -
-
- ); - } + if (hasMessages) { + return ( +
+
+ {messages.map((message) => ( + + ))} + {isTyping && !isStreaming && ( + + )} +
+
+ ); + } - /* ─── Selector mode (grade → subject → chapter) ─── */ - if (isSelector && selectorData) { - return ( -
- -
- ); - } + if (selectorStep !== "chat" && selectorData) { + return ( +
+ +
+ ); + } - /* ─── Welcome / suggestions mode ─── */ - return ( -
-
- {/* Welcome Header */} -
-
- - menu_book - - - auto_awesome - -
-

- Welcome to padhle -

-

- Your AI tutor for grades 6–12. - Ask anything! -

-
+ return ( +
+
+
+
+ + menu_book + + + auto_awesome + +
+

Welcome to padhle

+

+ Your AI tutor for grades 6–12. Ask anything! +

+
- {/* Bento Grid Suggestions */} -
- {suggestions.map((suggestion) => { - const colorVar = - suggestion.color.replace( - "--", - "var(", - ); - const bgColorVar = - suggestion.colorBg.replace( - "--", - "var(", - ); +
+ {suggestions.map((suggestion) => { + const color = `var(${suggestion.color})`; + const prompt = + suggestion.before + suggestion.highlight + suggestion.after; - return ( - - ); - })} -
- - {/* Info Panel */} -
-
- - lightbulb - -
-
-

- Focused on your learning -

-

- I'll answer questions - related to your selected - subject and chapter -
- using your textbook - content. -

-
-
-
+ return ( + + ); + })} +
+ +
+
+ lightbulb +
+
+

+ Focused on your learning +

+

+ I'll answer questions related to your selected subject and chapter +
+ using your textbook content. +

+
+
+
+
+ ); }; export default ChatHistory; diff --git a/frontend/src/components/chat-input/ChatInput.css b/frontend/src/components/chat-input/ChatInput.css index 31b0e25..17891e1 100644 --- a/frontend/src/components/chat-input/ChatInput.css +++ b/frontend/src/components/chat-input/ChatInput.css @@ -4,223 +4,236 @@ /* Main Input Bar */ .chat-input { - flex-shrink: 0; - width: 100%; - /* Gradient fade from background */ - background: linear-gradient(to top, var(--color-background) 50%, transparent); - padding-top: 24px; - padding-bottom: 32px; - padding-left: 24px; - padding-right: 24px; - display: flex; - justify-content: center; + flex-shrink: 0; + width: 100%; + /* Gradient fade from background */ + background: linear-gradient(to top, var(--color-background) 50%, transparent); + padding-top: 24px; + padding-bottom: 32px; + padding-left: 24px; + padding-right: 24px; + display: flex; + justify-content: center; } /* Container — centered content */ .chat-input__container { - width: 100%; - max-width: 800px; + width: 100%; + max-width: 800px; } /* Form */ .chat-input__form { - display: flex; - align-items: flex-end; - gap: 8px; - padding: 8px; - border: var(--border-thin); - border-radius: 32px; - background-color: var(--color-surface-lowest); - box-shadow: var(--shadow-input); - transition: border-color var(--transition-fast), box-shadow var(--transition-fast); + display: flex; + align-items: flex-end; + gap: 8px; + padding: 8px; + border: var(--border-thin); + border-radius: 32px; + background-color: var(--color-surface-lowest); + box-shadow: var(--shadow-input); + transition: + border-color var(--transition-fast), + box-shadow var(--transition-fast); } .chat-input__form:focus-within { - border-color: var(--color-primary); - box-shadow: var(--shadow-input-focus); + border-color: var(--color-primary); + box-shadow: var(--shadow-input-focus); } /* Attachment Button */ .chat-input__attach-btn { - display: flex; - align-items: center; - justify-content: center; - width: 44px; - height: 44px; - border: var(--border-thin); - border-radius: 50%; - background-color: var(--color-surface-lowest); - cursor: pointer; - color: var(--color-on-surface-variant); - transition: background-color var(--transition-fast), color var(--transition-fast); - flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border: var(--border-thin); + border-radius: 50%; + background-color: var(--color-surface-lowest); + cursor: pointer; + color: var(--color-on-surface-variant); + transition: + background-color var(--transition-fast), + color var(--transition-fast); + flex-shrink: 0; } .chat-input__attach-btn:hover { - background-color: var(--color-surface-low); - color: var(--color-primary); + background-color: var(--color-surface-low); + color: var(--color-primary); } .chat-input__attach-btn .material-symbols-outlined { - font-size: 20px; - line-height: 1; + font-size: 20px; + line-height: 1; } /* Textarea */ .chat-input__field { - flex: 1; - font-family: var(--font-body); - font-size: 15px; - font-weight: var(--font-weight-regular); - line-height: 1.5; - color: var(--color-primary); - background-color: transparent; - border: none; - padding: 8px 4px; - resize: none; - outline: none; - min-height: 44px; - max-height: 128px; + flex: 1; + font-family: var(--font-body); + font-size: 15px; + font-weight: var(--font-weight-regular); + line-height: 1.5; + color: var(--color-primary); + background-color: transparent; + border: none; + padding: 8px 4px; + resize: none; + outline: none; + min-height: 44px; + max-height: 128px; +} + +.chat-input__field:focus-visible { + outline: none; } .chat-input__field::placeholder { - color: var(--color-outline); - font-weight: var(--font-weight-regular); + color: var(--color-outline); + font-weight: var(--font-weight-regular); } .chat-input__field:disabled { - opacity: 0.5; - cursor: not-allowed; + opacity: 0.5; + cursor: not-allowed; } /* Actions Container */ .chat-input__actions { - display: flex; - align-items: center; - gap: 4px; - flex-shrink: 0; + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; } /* Mic Button */ .chat-input__mic-btn { - display: flex; - align-items: center; - justify-content: center; - width: 36px; - height: 36px; - border: none; - border-radius: 50%; - background: transparent; - cursor: pointer; - color: var(--color-on-surface-variant); - transition: background-color var(--transition-fast), color var(--transition-fast); - flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: none; + border-radius: 50%; + background: transparent; + cursor: pointer; + color: var(--color-on-surface-variant); + transition: + background-color var(--transition-fast), + color var(--transition-fast); + flex-shrink: 0; } .chat-input__mic-btn:hover { - background-color: var(--color-surface-low); - color: var(--color-primary); + background-color: var(--color-surface-low); + color: var(--color-primary); } .chat-input__mic-btn .material-symbols-outlined { - font-size: 20px; - line-height: 1; + font-size: 20px; + line-height: 1; } /* Send Button */ .chat-input__send-btn { - display: flex; - align-items: center; - justify-content: center; - width: 40px; - height: 40px; - border: none; - border-radius: 50%; - background: transparent; - cursor: not-allowed; - color: var(--color-on-surface-variant); - opacity: 0.3; - transition: opacity var(--transition-fast), background-color var(--transition-fast), color var(--transition-fast); - flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border: none; + border-radius: 50%; + background: transparent; + cursor: not-allowed; + color: var(--color-on-surface-variant); + opacity: 0.3; + transition: + opacity var(--transition-fast), + background-color var(--transition-fast), + color var(--transition-fast); + flex-shrink: 0; } .chat-input__send-btn--active { - background-color: var(--color-primary); - color: var(--color-on-primary); - cursor: pointer; - opacity: 1; + background-color: var(--color-primary); + color: var(--color-on-primary); + cursor: pointer; + opacity: 1; } .chat-input__send-btn--active:hover { - opacity: 0.9; + opacity: 0.9; } .chat-input__send-btn:disabled { - cursor: not-allowed; - opacity: 0.3; + cursor: not-allowed; + opacity: 0.3; } .chat-input__send-btn .material-symbols-outlined { - font-size: 20px; - line-height: 1; + font-size: 20px; + line-height: 1; } /* Disclaimer */ .chat-input__disclaimer { - font-size: var(--font-size-label-sm); - font-weight: var(--font-weight-medium); - color: var(--color-on-surface-variant); - text-align: center; - margin-top: 12px; + font-size: var(--font-size-label-sm); + font-weight: var(--font-weight-medium); + color: var(--color-on-surface-variant); + text-align: center; + margin-top: 12px; } /* Exhausted (anonymous free messages used) — sign-in gate */ .chat-input__exhausted { - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - gap: 4px; - padding: 28px 24px; - border: var(--border-thin); - border-radius: 20px; - background-color: var(--color-surface-lowest); - box-shadow: var(--shadow-input); + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 4px; + padding: 28px 24px; + border: var(--border-thin); + border-radius: 20px; + background-color: var(--color-surface-lowest); + box-shadow: var(--shadow-input); } .chat-input__exhausted-icon { - font-size: 28px; - color: var(--color-primary); - margin-bottom: 6px; + font-size: 28px; + color: var(--color-primary); + margin-bottom: 6px; } .chat-input__exhausted-title { - margin: 0; - font-family: var(--font-heading); - font-size: 1.05rem; - font-weight: var(--font-weight-semibold); - color: var(--color-on-surface); + margin: 0; + font-family: var(--font-heading); + font-size: 1.05rem; + font-weight: var(--font-weight-semibold); + color: var(--color-on-surface); } .chat-input__exhausted-sub { - margin: 0 0 14px; - font-size: var(--font-size-label-md); - color: var(--color-on-surface-variant); + margin: 0 0 14px; + font-size: var(--font-size-label-md); + color: var(--color-on-surface-variant); } .chat-input__exhausted-btn { - padding: 10px 22px; - border: none; - border-radius: 999px; - background-color: var(--color-primary); - color: var(--color-on-primary); - font-family: var(--font-body); - font-size: var(--font-size-label-md); - font-weight: var(--font-weight-semibold); - cursor: pointer; - transition: opacity var(--transition-fast); + padding: 10px 22px; + border: none; + border-radius: 999px; + background-color: var(--color-primary); + color: var(--color-on-primary); + font-family: var(--font-body); + font-size: var(--font-size-label-md); + font-weight: var(--font-weight-semibold); + cursor: pointer; + transition: opacity var(--transition-fast); } .chat-input__exhausted-btn:hover { - opacity: 0.9; + opacity: 0.9; } diff --git a/frontend/src/components/message/Message.css b/frontend/src/components/message/Message.css index cb0e7dd..9cb3732 100644 --- a/frontend/src/components/message/Message.css +++ b/frontend/src/components/message/Message.css @@ -81,6 +81,7 @@ font-size: 15px; line-height: var(--line-height-relaxed); white-space: pre-wrap; + overflow-wrap: anywhere; font-weight: var(--font-weight-regular); color: var(--color-on-surface); } @@ -93,9 +94,15 @@ .message__text.markdown-body { white-space: normal; } -.markdown-body > *:first-child { margin-top: 0; } -.markdown-body > *:last-child { margin-bottom: 0; } -.markdown-body p { margin: 0 0 12px; } +.markdown-body > *:first-child { + margin-top: 0; +} +.markdown-body > *:last-child { + margin-bottom: 0; +} +.markdown-body p { + margin: 0 0 12px; +} .markdown-body h1, .markdown-body h2, .markdown-body h3, @@ -105,16 +112,26 @@ font-weight: 700; line-height: 1.3; } -.markdown-body h1 { font-size: 22px; } -.markdown-body h2 { font-size: 19px; } -.markdown-body h3 { font-size: 16px; } -.markdown-body h4 { font-size: 15px; } +.markdown-body h1 { + font-size: 22px; +} +.markdown-body h2 { + font-size: 19px; +} +.markdown-body h3 { + font-size: 16px; +} +.markdown-body h4 { + font-size: 15px; +} .markdown-body ul, .markdown-body ol { margin: 0 0 12px; padding-left: 22px; } -.markdown-body li { margin-bottom: 4px; } +.markdown-body li { + margin-bottom: 4px; +} .markdown-body blockquote { margin: 0 0 12px; padding: 6px 14px; @@ -169,7 +186,9 @@ text-decoration: underline; } /* KaTeX math inherits font size */ -.markdown-body .katex { font-size: 1.05em; } +.markdown-body .katex { + font-size: 1.05em; +} /* --- ACTION BUTTONS --- */ .message__actions { @@ -191,7 +210,9 @@ background-color: var(--color-surface-low); cursor: pointer; color: var(--color-on-surface-variant); - transition: background-color var(--transition-fast), color var(--transition-fast); + transition: + background-color var(--transition-fast), + color var(--transition-fast); } .message__action:hover { @@ -244,7 +265,9 @@ } @keyframes typingBounce { - 0%, 80%, 100% { + 0%, + 80%, + 100% { transform: scale(0.6); opacity: 0.4; } diff --git a/frontend/src/data/curriculum.js b/frontend/src/data/curriculum.js new file mode 100644 index 0000000..66c90a0 --- /dev/null +++ b/frontend/src/data/curriculum.js @@ -0,0 +1,83 @@ +export const DEFAULTS = { + grade: "Choose standard", + subject: "choose-subject", + chapter: "choose-chapter", +}; + +export const gradeItems = [ + { id: "grade-6", label: "Grade 6", icon: "backpack" }, + { id: "grade-7", label: "Grade 7", icon: "auto_stories" }, + { id: "grade-8", label: "Grade 8", icon: "menu_book" }, + { id: "grade-9", label: "Grade 9", icon: "science" }, + { id: "grade-10", label: "Grade 10", icon: "calculate" }, + { id: "grade-11", label: "Grade 11", icon: "psychology" }, + { id: "grade-12", label: "Grade 12", icon: "school" }, +]; + +export const subjectItems = [ + { id: "math", label: "Mathematics" }, + { id: "science", label: "Science" }, + { id: "history", label: "History" }, + { id: "language", label: "Language Arts" }, + { id: "english", label: "English" }, + { id: "geography", label: "Geography" }, +]; + +export const chapterData = { + math: [ + { id: "ch-1", label: "Chapter 1: Rational Numbers" }, + { id: "ch-2", label: "Chapter 2: Linear Equations" }, + { id: "ch-3", label: "Chapter 3: Coordinate Geometry" }, + { id: "ch-4", label: "Chapter 4: Algebra" }, + { id: "ch-5", label: "Chapter 5: Geometry" }, + { id: "ch-6", label: "Chapter 6: Trigonometry" }, + ], + science: [ + { id: "ch-1", label: "Chapter 1: Nutrition in Plants" }, + { id: "ch-2", label: "Chapter 2: Photosynthesis" }, + { id: "ch-3", label: "Chapter 3: Human Physiology" }, + { id: "ch-4", label: "Chapter 4: Chemistry Basics" }, + { id: "ch-5", label: "Chapter 5: Motion & Force" }, + { id: "ch-6", label: "Chapter 6: Electricity" }, + ], + history: [ + { id: "ch-1", label: "Chapter 1: Early Civilizations" }, + { id: "ch-2", label: "Chapter 2: Ancient India" }, + { id: "ch-3", label: "Chapter 3: Medieval Period" }, + { id: "ch-4", label: "Chapter 4: Modern India" }, + { id: "ch-5", label: "Chapter 5: World Wars" }, + { id: "ch-6", label: "Chapter 6: Independence Movement" }, + ], + language: [ + { id: "ch-1", label: "Chapter 1: Grammar Basics" }, + { id: "ch-2", label: "Chapter 2: Comprehension" }, + { id: "ch-3", label: "Chapter 3: Creative Writing" }, + { id: "ch-4", label: "Chapter 4: Literature" }, + { id: "ch-5", label: "Chapter 5: Vocabulary" }, + { id: "ch-6", label: "Chapter 6: Composition" }, + ], + english: [ + { id: "ch-1", label: "Chapter 1: Reading Skills" }, + { id: "ch-2", label: "Chapter 2: Writing Skills" }, + { id: "ch-3", label: "Chapter 3: Grammar" }, + { id: "ch-4", label: "Chapter 4: Poetry" }, + { id: "ch-5", label: "Chapter 5: Prose" }, + { id: "ch-6", label: "Chapter 6: Drama" }, + ], + geography: [ + { id: "ch-1", label: "Chapter 1: Earth & Universe" }, + { id: "ch-2", label: "Chapter 2: Landforms" }, + { id: "ch-3", label: "Chapter 3: Climate & Weather" }, + { id: "ch-4", label: "Chapter 4: Natural Resources" }, + { id: "ch-5", label: "Chapter 5: Maps & Atlas" }, + { id: "ch-6", label: "Chapter 6: Human Geography" }, + ], +}; + +export const getSubjectLabel = (id) => + !id || id === DEFAULTS.subject + ? "Choose Subject" + : subjectItems.find((subject) => subject.id === id)?.label || id; + +export const getChapterLabel = (chapter) => + !chapter || chapter === DEFAULTS.chapter ? "Choose Chapter" : chapter; diff --git a/frontend/src/data/curriculum.test.js b/frontend/src/data/curriculum.test.js new file mode 100644 index 0000000..dd3aa39 --- /dev/null +++ b/frontend/src/data/curriculum.test.js @@ -0,0 +1,15 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + chapterData, + DEFAULTS, + getChapterLabel, + getSubjectLabel, +} from "./curriculum.js"; + +test("resolves curriculum labels and defaults", () => { + assert.equal(getSubjectLabel("science"), "Science"); + assert.equal(getSubjectLabel(DEFAULTS.subject), "Choose Subject"); + assert.equal(getChapterLabel(DEFAULTS.chapter), "Choose Chapter"); + assert.ok(chapterData.science.length > 0); +}); diff --git a/frontend/src/hooks/useChat.js b/frontend/src/hooks/useChat.js new file mode 100644 index 0000000..966b5f2 --- /dev/null +++ b/frontend/src/hooks/useChat.js @@ -0,0 +1,301 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { chatApi } from "../lib/chatApi"; +import { readSseData } from "../lib/readSseData"; +import { DEFAULTS } from "../data/curriculum"; + +const errorMessage = (text) => ({ + id: crypto.randomUUID(), + role: "assistant", + text: `⚠️ ${text}`, +}); + +export function useChat({ + isAuthenticated, + routeChatId, + navigate, + onSignInRequired, +}) { + const [messages, setMessages] = useState([]); + const [activeChat, setActiveChat] = useState(null); + const [activeGrade, setActiveGrade] = useState(DEFAULTS.grade); + const [activeSubject, setActiveSubject] = useState(DEFAULTS.subject); + const [activeChapter, setActiveChapter] = useState(DEFAULTS.chapter); + const [selectorStep, setSelectorStep] = useState("grade"); + const [sessions, setSessions] = useState([]); + const [isLoadingSessions, setIsLoadingSessions] = useState(false); + const [isTyping, setIsTyping] = useState(false); + const [anonRemaining, setAnonRemaining] = useState(5); + + const requestRef = useRef(null); + const handledRouteRef = useRef(null); + const mountedRef = useRef(false); + + const abortRequest = useCallback(() => { + requestRef.current?.abort(); + requestRef.current = null; + }, []); + + const loadSessions = useCallback(async () => { + if (!isAuthenticated) return; + setIsLoadingSessions(true); + try { + const response = await chatApi.listSessions(); + if (response.ok) setSessions(await response.json()); + } catch (error) { + console.error("Failed to load sessions:", error); + } finally { + setIsLoadingSessions(false); + } + }, [isAuthenticated]); + + const resetChat = useCallback(() => { + abortRequest(); + setMessages([]); + setActiveChat(null); + setActiveGrade(DEFAULTS.grade); + setActiveSubject(DEFAULTS.subject); + setActiveChapter(DEFAULTS.chapter); + setSelectorStep("grade"); + setIsTyping(false); + }, [abortRequest]); + + const newChat = useCallback(() => { + resetChat(); + handledRouteRef.current = null; + navigate("/"); + loadSessions(); + }, [loadSessions, navigate, resetChat]); + + const selectChat = useCallback( + async (sessionId) => { + if (!isAuthenticated) return; + + abortRequest(); + const controller = new AbortController(); + requestRef.current = controller; + setIsTyping(true); + setMessages([]); + + try { + const response = await chatApi.getSession(sessionId, controller.signal); + if (!response.ok) throw new Error("Session not found"); + + const data = await response.json(); + const session = data.session || {}; + setActiveGrade(session.grade || DEFAULTS.grade); + setActiveSubject(session.subject || DEFAULTS.subject); + setActiveChapter(session.chapter || DEFAULTS.chapter); + setMessages( + (data.messages || []).map((message) => ({ + ...message, + streaming: false, + })), + ); + setSelectorStep("chat"); + setActiveChat(sessionId); + handledRouteRef.current = sessionId; + navigate(`/chat/${encodeURIComponent(sessionId)}`, { replace: true }); + } catch (error) { + if (error.name !== "AbortError") { + console.error("Failed to load session:", error); + setMessages([ + errorMessage("Could not load that chat. It may have been deleted."), + ]); + } + } finally { + if (requestRef.current === controller) { + requestRef.current = null; + setIsTyping(false); + } + } + }, + [abortRequest, isAuthenticated, navigate], + ); + + const deleteChat = useCallback( + async (sessionId) => { + try { + const response = await chatApi.deleteSession(sessionId); + if (!response.ok) return; + if (activeChat === sessionId) newChat(); + else loadSessions(); + } catch (error) { + console.error("Failed to delete session:", error); + } + }, + [activeChat, loadSessions, newChat], + ); + + const send = useCallback( + async (value) => { + const text = value.trim(); + if (!text || isTyping) return; + if (!isAuthenticated && anonRemaining <= 0) { + onSignInRequired(); + return; + } + + abortRequest(); + const controller = new AbortController(); + requestRef.current = controller; + const assistantId = crypto.randomUUID(); + + setMessages((current) => [ + ...current, + { id: crypto.randomUUID(), role: "user", text }, + { id: assistantId, role: "assistant", text: "", streaming: true }, + ]); + setIsTyping(true); + + try { + const response = await chatApi.send( + { + text, + chatId: activeChat, + grade: activeGrade === DEFAULTS.grade ? undefined : activeGrade, + subject: + activeSubject === DEFAULTS.subject ? undefined : activeSubject, + chapter: + activeChapter === DEFAULTS.chapter ? undefined : activeChapter, + }, + controller.signal, + ); + + if (!response.ok) { + const data = await response.json().catch(() => null); + if (response.status === 403 && data?.code === "SIGNIN_REQUIRED") { + setAnonRemaining(0); + onSignInRequired(); + return; + } + throw new Error(`Server error: ${response.status}`); + } + + let assistantText = ""; + for await (const event of readSseData(response.body)) { + if (event.type === "session") { + setActiveChat(event.id); + if (isAuthenticated) { + handledRouteRef.current = event.id; + window.history.replaceState( + window.history.state, + "", + `/chat/${encodeURIComponent(event.id)}`, + ); + } + } else if (event.type === "limit") { + setAnonRemaining(event.remaining); + } else if (event.type === "chunk") { + assistantText += event.content; + setMessages((current) => + current.map((message) => + message.id === assistantId + ? { ...message, text: assistantText } + : message, + ), + ); + } else if (event.type === "error") { + assistantText = `⚠️ Error: ${event.message}`; + } + } + + setMessages((current) => + current.map((message) => + message.id === assistantId + ? { ...message, text: assistantText, streaming: false } + : message, + ), + ); + if (isAuthenticated) loadSessions(); + } catch (error) { + if (error.name !== "AbortError") { + setMessages((current) => + current.map((message) => + message.id === assistantId + ? { + ...message, + text: `⚠️ Connection error: ${error.message}. Make sure the backend is running on port 3001.`, + streaming: false, + } + : message, + ), + ); + } + } finally { + if (requestRef.current === controller) { + requestRef.current = null; + setIsTyping(false); + } + } + }, + [ + abortRequest, + activeChat, + activeChapter, + activeGrade, + activeSubject, + anonRemaining, + isAuthenticated, + isTyping, + loadSessions, + onSignInRequired, + ], + ); + + const addError = useCallback((message) => { + setMessages((current) => [...current, errorMessage(message)]); + }, []); + + useEffect(() => { + if (!mountedRef.current) { + mountedRef.current = true; + loadSessions(); + return; + } + + resetChat(); + setSessions([]); + setAnonRemaining(5); + loadSessions(); + + if (handledRouteRef.current) { + handledRouteRef.current = null; + navigate("/"); + } + }, [isAuthenticated, loadSessions, navigate, resetChat]); + + useEffect(() => { + if ( + routeChatId && + isAuthenticated && + handledRouteRef.current !== routeChatId + ) { + handledRouteRef.current = routeChatId; + selectChat(routeChatId); + } + }, [isAuthenticated, routeChatId, selectChat]); + + useEffect(() => abortRequest, [abortRequest]); + + return { + messages, + activeChat, + activeGrade, + activeSubject, + activeChapter, + selectorStep, + sessions, + isLoadingSessions, + isTyping, + anonRemaining, + setActiveGrade, + setActiveSubject, + setActiveChapter, + setSelectorStep, + addError, + newChat, + selectChat, + deleteChat, + send, + }; +} diff --git a/frontend/src/lib/auth/SupabaseAuth.jsx b/frontend/src/lib/auth/SupabaseAuth.jsx index ef656ca..93eb28c 100644 --- a/frontend/src/lib/auth/SupabaseAuth.jsx +++ b/frontend/src/lib/auth/SupabaseAuth.jsx @@ -36,21 +36,9 @@ export function AuthProvider({ children }) { setUser(null); }; - const getToken = async () => { - // Token lives in httpOnly cookie — not accessible to JavaScript - return null; - }; - return ( {children} diff --git a/frontend/src/lib/auth/backendAuth.js b/frontend/src/lib/auth/backendAuth.js index 8c428c8..7b2077c 100644 --- a/frontend/src/lib/auth/backendAuth.js +++ b/frontend/src/lib/auth/backendAuth.js @@ -8,7 +8,7 @@ const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:3001"; async function authRequest(endpoint, options = {}) { const url = `${API_BASE}${endpoint}`; - + const res = await fetch(url, { ...options, credentials: "include", // ← sends cookies automatically @@ -32,7 +32,7 @@ export const auth = { method: "POST", body: JSON.stringify({ email, password }), }); - + if (!res.ok) { throw new Error(data.error || "Signup failed"); } @@ -48,7 +48,7 @@ export const auth = { method: "POST", body: JSON.stringify({ email, password }), }); - + if (!res.ok) { throw new Error(data.error || "Sign in failed"); } @@ -70,19 +70,10 @@ export const auth = { */ async me() { const { res, data } = await authRequest("/api/auth/me"); - + if (!res.ok) { return { user: null }; } return data; }, - - /** - * Check if user is authenticated. - * Backend verifies the httpOnly cookie. - */ - async check() { - const { user } = await auth.me(); - return !!user; - }, }; diff --git a/frontend/src/lib/chatApi.js b/frontend/src/lib/chatApi.js new file mode 100644 index 0000000..c8d16e1 --- /dev/null +++ b/frontend/src/lib/chatApi.js @@ -0,0 +1,17 @@ +const request = (path, options) => + fetch(path, { credentials: "include", ...options }); + +export const chatApi = { + listSessions: () => request("/api/sessions"), + getSession: (id, signal) => + request(`/api/chat/${encodeURIComponent(id)}`, { signal }), + deleteSession: (id) => + request(`/api/sessions/${encodeURIComponent(id)}`, { method: "DELETE" }), + send: (body, signal) => + request("/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal, + }), +}; diff --git a/frontend/src/lib/readSseData.js b/frontend/src/lib/readSseData.js new file mode 100644 index 0000000..6e4066c --- /dev/null +++ b/frontend/src/lib/readSseData.js @@ -0,0 +1,29 @@ +export async function* readSseData(body) { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + buffer += decoder.decode(value, { stream: !done }); + + const lines = buffer.split("\n"); + buffer = done ? "" : lines.pop() || ""; + + for (const rawLine of lines) { + const line = rawLine.trimEnd(); + if (!line.startsWith("data: ")) continue; + + const value = line.slice(6); + if (value === "[DONE]") continue; + + try { + yield JSON.parse(value); + } catch { + // Ignore malformed frames; one bad event must not end the stream. + } + } + + if (done) return; + } +} diff --git a/frontend/src/lib/readSseData.test.js b/frontend/src/lib/readSseData.test.js new file mode 100644 index 0000000..bc69442 --- /dev/null +++ b/frontend/src/lib/readSseData.test.js @@ -0,0 +1,31 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readSseData } from "./readSseData.js"; + +const streamFrom = (chunks) => + new ReadableStream({ + start(controller) { + for (const chunk of chunks) + controller.enqueue(new TextEncoder().encode(chunk)); + controller.close(); + }, + }); + +test("reads fragmented SSE data and ignores invalid frames", async () => { + const body = streamFrom([ + 'data: {"type":"sess', + 'ion","id":"abc"}\n', + "event: ignored\n", + "data: not-json\n", + 'data: {"type":"chunk","content":"Hi"}\n', + "data: [DONE]\n", + ]); + + const events = []; + for await (const event of readSseData(body)) events.push(event); + + assert.deepEqual(events, [ + { type: "session", id: "abc" }, + { type: "chunk", content: "Hi" }, + ]); +}); diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 5043762..a142f36 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -6,177 +6,189 @@ *, *::before, *::after { - margin: 0; - padding: 0; - box-sizing: border-box; + margin: 0; + padding: 0; + box-sizing: border-box; } /* Root Variables — Material Design 3 tokens from EduAI */ :root { - /* Surface Colors */ - --color-background: #fcf8fa; - --color-surface: #ffffff; - --color-surface-lowest: #ffffff; - --color-surface-low: #f9fafb; - --color-surface-container: #f3f4f6; - --color-surface-high: #e5e7eb; + /* Surface Colors */ + --color-background: #fcf8fa; + --color-surface: #ffffff; + --color-surface-lowest: #ffffff; + --color-surface-low: #f9fafb; + --color-surface-container: #f3f4f6; + --color-surface-high: #e5e7eb; - /* Outline Colors */ - --color-outline: #d1d5db; - --color-outline-variant: #e5e5e5; + /* Outline Colors */ + --color-outline: #d1d5db; + --color-outline-variant: #e5e5e5; - /* Text Colors */ - --color-on-surface: #111827; - --color-on-surface-variant: #6b7280; - --color-primary: #000000; - --color-on-primary: #ffffff; - --color-primary-fixed: #dce2f7; - --color-primary-container: #141b2b; + /* Text Colors */ + --color-on-surface: #111827; + --color-on-surface-variant: #6b7280; + --color-primary: #000000; + --color-on-primary: #ffffff; + --color-primary-fixed: #dce2f7; + --color-primary-container: #141b2b; - /* Accent Colors for Suggestion Cards */ - --color-accent-green: #2e7d32; - --color-accent-green-bg: #e8f5e9; - --color-accent-blue: #1565c0; - --color-accent-blue-bg: #e3f2fd; - --color-accent-orange: #e65100; - --color-accent-orange-bg: #fff3e0; - --color-accent-purple: #6a1b9a; - --color-accent-purple-bg: #f3e5f5; + /* Accent Colors for Suggestion Cards */ + --color-accent-green: #2e7d32; + --color-accent-green-bg: #e8f5e9; + --color-accent-blue: #1565c0; + --color-accent-blue-bg: #e3f2fd; + --color-accent-orange: #e65100; + --color-accent-orange-bg: #fff3e0; + --color-accent-purple: #6a1b9a; + --color-accent-purple-bg: #f3e5f5; - /* Fonts */ - --font-heading: "Montserrat", sans-serif; - --font-body: "Inter", system-ui, sans-serif; - --font-mono: "JetBrains Mono", "Fira Code", monospace; + /* Fonts */ + --font-heading: "Montserrat", sans-serif; + --font-body: "Inter", system-ui, sans-serif; + --font-mono: "JetBrains Mono", "Fira Code", monospace; - /* Font Sizes */ - --font-size-body-md: 16px; - --font-size-body-lg: 18px; - --font-size-headline-md: 24px; - --font-size-headline-sm: 20px; - --font-size-label-md: 14px; - --font-size-label-sm: 12px; - --font-size-label-xs: 11px; - --font-size-display-lg: 40px; + /* Font Sizes */ + --font-size-body-md: 16px; + --font-size-body-lg: 18px; + --font-size-headline-md: 24px; + --font-size-headline-sm: 20px; + --font-size-label-md: 14px; + --font-size-label-sm: 12px; + --font-size-label-xs: 11px; + --font-size-display-lg: 40px; - /* Font Weights */ - --font-weight-regular: 400; - --font-weight-medium: 500; - --font-weight-semibold: 600; - --font-weight-bold: 700; + /* Font Weights */ + --font-weight-regular: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; - /* Line Heights */ - --line-height-tight: 1.25; - --line-height-normal: 1.5; - --line-height-relaxed: 1.625; + /* Line Heights */ + --line-height-tight: 1.25; + --line-height-normal: 1.5; + --line-height-relaxed: 1.625; - /* Border */ - --border-thin: 1px solid var(--color-outline); - --border-regular: 1.5px solid var(--color-outline); - --border-thick: 2px solid var(--color-outline); + /* Border */ + --border-thin: 1px solid var(--color-outline); + --border-regular: 1.5px solid var(--color-outline); + --border-thick: 2px solid var(--color-outline); - /* Border Radius */ - --radius-sm: 6px; - --radius-md: 10px; - --radius-lg: 14px; - --radius-xl: 20px; - --radius-pill: 9999px; + /* Border Radius */ + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; + --radius-xl: 20px; + --radius-pill: 9999px; - /* Spacing */ - --spacing-xs: 4px; - --spacing-sm: 8px; - --spacing-md: 16px; - --spacing-lg: 24px; - --spacing-xl: 32px; - --spacing-2xl: 48px; + /* Spacing */ + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 16px; + --spacing-lg: 24px; + --spacing-xl: 32px; + --spacing-2xl: 48px; - /* Transition */ - --transition-fast: 150ms ease; - --transition-normal: 200ms ease; - --transition-slow: 300ms ease; + /* Transition */ + --transition-fast: 150ms ease; + --transition-normal: 200ms ease; + --transition-slow: 300ms ease; - /* Layout */ - --sidebar-width: 280px; - --container-max: 800px; - --content-padding: 24px; + /* Layout */ + --sidebar-width: 280px; + --container-max: 800px; + --content-padding: 24px; - /* Shadows */ - --shadow-card: 0 1px 3px 0 rgba(0, 0, 0, 0.05); - --shadow-card-hover: 0 2px 8px 0 rgba(0, 0, 0, 0.08); - --shadow-input: 0 2px 8px 0 rgba(0, 0, 0, 0.04); - --shadow-input-focus: 0 2px 12px 0 rgba(0, 0, 0, 0.08); + /* Shadows */ + --shadow-card: 0 1px 3px 0 rgba(0, 0, 0, 0.05); + --shadow-card-hover: 0 2px 8px 0 rgba(0, 0, 0, 0.08); + --shadow-input: 0 2px 8px 0 rgba(0, 0, 0, 0.04); + --shadow-input-focus: 0 2px 12px 0 rgba(0, 0, 0, 0.08); +} + +/* App viewport — the shell owns scrolling, never the document. + A definite height here is what lets .chat-history become a real + scroll container instead of growing the page. */ +html, +body, +#root { + height: 100%; } /* Body */ body { - font-family: var(--font-body); - font-weight: var(--font-weight-regular); - font-size: var(--font-size-body-md); - background-color: var(--color-background); - color: var(--color-on-surface); - line-height: var(--line-height-normal); - min-height: 100vh; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; + font-family: var(--font-body); + font-weight: var(--font-weight-regular); + font-size: var(--font-size-body-md); + background-color: var(--color-background); + color: var(--color-on-surface); + line-height: var(--line-height-normal); + overflow: hidden; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } /* Page Frame — sidebar + main layout */ .page { - display: flex; - min-height: 100vh; - width: 100%; + display: flex; + height: 100%; + min-height: 0; + width: 100%; } /* Main Content Area */ .page__main { - flex: 1; - display: flex; - flex-direction: column; - margin-left: var(--sidebar-width); - min-width: 0; + flex: 1; + display: flex; + flex-direction: column; + margin-left: var(--sidebar-width); + min-width: 0; + min-height: 0; } /* Main Content Wrapper (scrollable chat area) */ .main-content { - flex: 1; - display: flex; - flex-direction: column; - overflow-y: auto; - background-color: var(--color-background); + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + background-color: var(--color-background); } /* Container — centered content */ .container { - max-width: var(--container-max); - margin: 0 auto; - width: 100%; + max-width: var(--container-max); + margin: 0 auto; + width: 100%; } /* Scrollbar */ ::-webkit-scrollbar { - width: 6px; + width: 6px; } ::-webkit-scrollbar-track { - background: transparent; + background: transparent; } ::-webkit-scrollbar-thumb { - background-color: var(--color-outline-variant); - border-radius: 3px; + background-color: var(--color-outline-variant); + border-radius: 3px; } ::-webkit-scrollbar-thumb:hover { - background-color: var(--color-outline); + background-color: var(--color-outline); } /* Focus outline */ :focus-visible { - outline: 2px solid var(--color-primary); - outline-offset: 2px; + outline: 2px solid var(--color-primary); + outline-offset: 2px; } /* Selection */ ::selection { - background-color: var(--color-primary-fixed); - color: var(--color-primary-container); + background-color: var(--color-primary-fixed); + color: var(--color-primary-container); } diff --git a/package.json b/package.json index 96e6fe6..1af83db 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "dev:frontend": "cd frontend && vite", "dev:backend": "cd backend && npm run dev", "build": "cd frontend && vite build", + "test": "npm --prefix frontend test", "preview": "vite preview" }, "dependencies": {