Files
padhle/frontend/src/App.jsx
T

654 lines
21 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ========================================
App Component — padhle (Backend-Connected)
======================================== */
import { useState, useRef, useCallback, useEffect } 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" },
],
};
function App() {
const { isAuthenticated, user, signIn, signUp, signOut, getToken } =
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);
/* ─── 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;
};
/* ─── Chapter list resolution (subject ID → array) ─── */
const getChaptersForSubject = (subjectId) => {
return chapterData[subjectId] || [];
};
/* ─── 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);
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;
};
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 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,
},
subject: {
title: "Select your subject",
subtitle: `Chose by grade – ${activeGrade}`,
items: subjectItems,
selectedValue: activeSubject,
onSelect: handleSubjectSelect,
compareField: "id",
},
chapter: {
title: "Select a chapter",
subtitle: `Subject – ${getSubjectLabel(activeSubject)}`,
items: getChaptersForSubject(activeSubject),
selectedValue: activeChapter,
onSelect: handleChapterSelect,
compareField: "label",
},
};
// ─── Render — the app is always accessible; sign-in is on-demand ───
return (
<>
{showSignIn && (
<SignInModal
onClose={() => 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}
/>
)}
<div className="page">
<Sidebar
activeSubject={activeSubject}
onSubjectChange={(id) => {
setActiveSubject(id);
setActiveChapter(DEFAULTS.chapter);
if (selectorStep !== "chat") {
setSelectorStep("subject");
}
}}
activeChat={activeChat}
onChatSelect={handleChatSelect}
onNewChat={handleNewChat}
sessions={sessions}
onDeleteChat={handleDeleteChat}
isLoadingSessions={isLoadingSessions}
showUpgrade={false}
isAnonymous={isAnonymous}
onSignInRequest={() => setShowSignIn(true)}
user={user}
onSignOut={signOut}
/>
<div className="page__main">
<TopNav
activeGrade={activeGrade}
onGradeChange={(g) => setActiveGrade(g)}
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");
}
}}
isAnonymous={isAnonymous}
onSignInRequest={() => setShowSignIn(true)}
user={user}
onSignOut={signOut}
/>
<div className="main-content">
<ChatHistory
messages={messages}
activeSubject={activeSubject}
activeGrade={activeGrade}
activeChapter={activeChapter}
onPromptClick={handlePromptClick}
isTyping={isTyping}
selectorStep={selectorStep}
selectorData={
selectorStep === "chat" ? null : selectorData[selectorStep]
}
/>
</div>
<ChatInput
onSend={handleSend}
placeholder={`Ask anything about ${activeGrade} ${getSubjectLabel(activeSubject)} – ${getChapterLabel(activeChapter)}...`}
disabled={isTyping}
exhausted={isAnonymous && anonRemaining <= 0}
onSignInRequest={() => setShowSignIn(true)}
/>
</div>
</div>
</>
);
}
export default App;