padhle - post migration

This commit is contained in:
2026-09-15 04:08:55 -04:00
parent 534b51b41b
commit b2d8109019
509 changed files with 147378 additions and 303 deletions
+154 -75
View File
@@ -3,6 +3,7 @@
======================================== */
import { useState, useRef, useCallback, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import Sidebar from "./components/sidebar/Sidebar";
import TopNav from "./components/top-nav/TopNav";
import ChatHistory from "./components/chat-history/ChatHistory";
@@ -94,6 +95,10 @@ const chapterData = {
function App() {
const { isAuthenticated, user, signIn, signUp, signOut, getToken } = useAuth();
const navigate = useNavigate();
const { chatId: routeChatId } = useParams();
// 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);
@@ -120,8 +125,11 @@ function App() {
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(() => {
@@ -145,9 +153,30 @@ function App() {
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();
}, [isAuthenticated]);
// 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, routeChatId]);
// 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]);
@@ -160,6 +189,7 @@ function App() {
};
const loadSessions = async () => {
if (!isAuthenticated) return; // sessions require an account
setIsLoadingSessions(true);
try {
const token = await getToken();
@@ -198,9 +228,14 @@ function App() {
// 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();
@@ -237,6 +272,9 @@ function App() {
}
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([
@@ -278,6 +316,11 @@ function App() {
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) {
@@ -317,6 +360,21 @@ function App() {
})
.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}`);
}
@@ -350,7 +408,19 @@ function App() {
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 });
chatLoadHandledRef.current = 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) =>
@@ -391,8 +461,8 @@ function App() {
setIsTyping(false);
abortRef.current = null;
// Refresh sessions to update timestamps
loadSessions();
// Refresh sessions to update timestamps (signed-in users only)
if (isAuthenticated) loadSessions();
})
.catch((err) => {
if (err.name === "AbortError") return; // intentional abort
@@ -408,7 +478,7 @@ function App() {
abortRef.current = null;
});
},
[isTyping, activeChat, activeGrade, activeSubject, activeChapter]
[isTyping, activeChat, activeGrade, activeSubject, activeChapter, isAnonymous, anonRemaining, isAuthenticated]
);
const handlePromptClick = useCallback(
@@ -464,65 +534,35 @@ function App() {
},
};
// ─── Not authenticated → show sign-in modal ───
if (!isAuthenticated) {
return (
<SignInModal
onSignIn={async (email, password) => {
try {
await signIn(email, password);
} catch (err) {
handleAuthError(err.message || "Sign in failed");
}
}}
onSignUp={async (email, password) => {
try {
await signUp(email, password);
} catch (err) {
handleAuthError(err.message || "Sign up failed");
}
}}
onError={handleAuthError}
/>
);
}
// ─── Render — the app is always accessible; sign-in is on-demand ───
return (
<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}
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");
<>
{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);
@@ -530,30 +570,69 @@ function App() {
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="main-content">
<ChatHistory
messages={messages}
activeSubject={activeSubject}
<div className="page__main">
<TopNav
activeGrade={activeGrade}
onGradeChange={(g) => setActiveGrade(g)}
subjectItems={subjectItems}
chapterData={chapterData}
activeSubject={activeSubject}
activeChapter={activeChapter}
onPromptClick={handlePromptClick}
isTyping={isTyping}
selectorStep={selectorStep}
selectorData={selectorStep !== "chat" ? selectorData[selectorStep] : null}
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" ? selectorData[selectorStep] : null}
/>
</div>
<ChatInput
onSend={handleSend}
placeholder={`Ask anything about ${activeGrade} ${getSubjectLabel(activeSubject)} – ${getChapterLabel(activeChapter)}...`}
disabled={isTyping}
exhausted={isAnonymous && anonRemaining <= 0}
onSignInRequest={() => setShowSignIn(true)}
/>
</div>
<ChatInput
onSend={handleSend}
placeholder={`Ask anything about ${activeGrade} ${getSubjectLabel(activeSubject)} – ${getChapterLabel(activeChapter)}...`}
disabled={isTyping}
/>
</div>
</div>
</>
);
}