padhle - post migration
This commit is contained in:
+154
-75
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user