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
+58
View File
@@ -12,6 +12,7 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.18.2",
"rehype-katex": "^7.0.1",
"remark-math": "^6.0.0"
},
@@ -1506,6 +1507,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -2842,6 +2856,44 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
"integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz",
"integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
"license": "MIT",
"dependencies": {
"react-router": "7.18.2"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/rehype-katex": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz",
@@ -2988,6 +3040,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+1
View File
@@ -13,6 +13,7 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.18.2",
"rehype-katex": "^7.0.1",
"remark-math": "^6.0.0"
},
+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>
</>
);
}
@@ -173,3 +173,54 @@
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);
}
.chat-input__exhausted-icon {
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);
}
.chat-input__exhausted-sub {
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);
}
.chat-input__exhausted-btn:hover {
opacity: 0.9;
}
@@ -5,7 +5,7 @@
import { useState, useRef, useEffect } from "react";
import "./ChatInput.css";
const ChatInput = ({ onSend, placeholder, disabled }) => {
const ChatInput = ({ onSend, placeholder, disabled, exhausted = false, onSignInRequest }) => {
const [text, setText] = useState("");
const textareaRef = useRef(null);
@@ -36,6 +36,33 @@ const ChatInput = ({ onSend, placeholder, disabled }) => {
}
};
// Anonymous trial budget spent → replace the input with the sign-in gate.
if (exhausted) {
return (
<div className="chat-input">
<div className="chat-input__container">
<div className="chat-input__exhausted">
<span className="material-symbols-outlined chat-input__exhausted-icon">lock</span>
<p className="chat-input__exhausted-title">You've used your 5 free messages</p>
<p className="chat-input__exhausted-sub">
Sign in to keep chatting your chats will be saved to your account.
</p>
<button
type="button"
className="chat-input__exhausted-btn"
onClick={onSignInRequest}
>
Sign in to continue
</button>
</div>
<p className="chat-input__disclaimer">
Padhle AI can make mistakes. Consider verifying important information.
</p>
</div>
</div>
);
}
return (
<div className="chat-input">
<div className="chat-input__container">
+22 -11
View File
@@ -14,6 +14,8 @@ const Sidebar = ({
onDeleteChat,
isLoadingSessions,
showUpgrade = true,
isAnonymous = false,
onSignInRequest,
user,
onSignOut,
}) => {
@@ -124,18 +126,27 @@ const Sidebar = ({
{/* Footer */}
<div className="sidebar__footer">
{user && (
<div className="sidebar__user">
<span className="sidebar__user-avatar">
{user.email?.charAt(0).toUpperCase() || "U"}
</span>
<span className="sidebar__user-email">{user.email}</span>
</div>
{isAnonymous ? (
<button className="sidebar__footer-btn" onClick={onSignInRequest}>
<span className="material-symbols-outlined sidebar__footer-icon">login</span>
<span>Sign in</span>
</button>
) : (
<>
{user && (
<div className="sidebar__user">
<span className="sidebar__user-avatar">
{user.email?.charAt(0).toUpperCase() || "U"}
</span>
<span className="sidebar__user-email">{user.email}</span>
</div>
)}
<button className="sidebar__footer-btn" onClick={onSignOut}>
<span className="material-symbols-outlined sidebar__footer-icon">logout</span>
<span>Sign out</span>
</button>
</>
)}
<button className="sidebar__footer-btn" onClick={onSignOut}>
<span className="material-symbols-outlined sidebar__footer-icon">logout</span>
<span>Sign out</span>
</button>
{/* Upgrade to Pro Card */}
{showUpgrade && (
@@ -211,3 +211,32 @@
font-weight: var(--font-weight-medium);
color: var(--color-primary);
}
/* Sign-in button (anonymous users) */
.topnav__signin-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border: var(--border-thin);
border-radius: 999px;
background-color: var(--color-surface-lowest);
cursor: pointer;
color: var(--color-primary);
transition: background-color var(--transition-fast), box-shadow var(--transition-fast);
}
.topnav__signin-btn:hover {
background-color: var(--color-surface-low);
box-shadow: var(--shadow-card-hover);
}
.topnav__signin-btn .material-symbols-outlined {
font-size: 18px;
}
.topnav__signin-label {
font-family: var(--font-body);
font-size: var(--font-size-label-md);
font-weight: var(--font-weight-semibold);
}
+24 -12
View File
@@ -5,7 +5,7 @@
import { useState, useRef, useEffect } from "react";
import "./TopNav.css";
const TopNav = ({ subjectItems, chapterData, activeGrade, onGradeChange, activeSubject, onSubjectChange, activeChapter, onChapterChange, user, onSignOut }) => {
const TopNav = ({ subjectItems, chapterData, activeGrade, onGradeChange, activeSubject, onSubjectChange, activeChapter, onChapterChange, user, onSignOut, isAnonymous = false, onSignInRequest }) => {
const [openDropdown, setOpenDropdown] = useState(null);
const gradeRef = useRef(null);
const subjectRef = useRef(null);
@@ -190,17 +190,29 @@ const TopNav = ({ subjectItems, chapterData, activeGrade, onGradeChange, activeS
<span className="material-symbols-outlined">notifications_none</span>
</button>
{/* User Avatar — Sign out on click */}
<button
className="topnav__avatar-btn"
onClick={onSignOut}
aria-label="Sign out"
title="Sign out"
>
<span className="topnav__avatar-initial">
{user?.email?.charAt(0).toUpperCase() || "U"}
</span>
</button>
{/* User Avatar — Sign out on click (signed in) / Sign in (anonymous) */}
{isAnonymous ? (
<button
className="topnav__avatar-btn topnav__signin-btn"
onClick={onSignInRequest}
aria-label="Sign in"
title="Sign in"
>
<span className="material-symbols-outlined">login</span>
<span className="topnav__signin-label">Sign in</span>
</button>
) : (
<button
className="topnav__avatar-btn"
onClick={onSignOut}
aria-label="Sign out"
title="Sign out"
>
<span className="topnav__avatar-initial">
{user?.email?.charAt(0).toUpperCase() || "U"}
</span>
</button>
)}
</div>
</header>
);
+47
View File
@@ -14,6 +14,7 @@
}
.signin-card {
position: relative;
background: var(--color-surface-lowest, #fff);
border-radius: 16px;
padding: 40px 36px;
@@ -117,3 +118,49 @@
text-align: center;
opacity: 0.7;
}
/* Close button (on-demand modal) */
.signin-close {
position: absolute;
top: 14px;
right: 14px;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: transparent;
color: var(--color-on-surface-variant);
cursor: pointer;
transition: background-color var(--transition-fast), color var(--transition-fast);
}
.signin-close:hover {
background-color: var(--color-surface-low);
color: var(--color-primary);
}
.signin-close .material-symbols-outlined {
font-size: 20px;
}
/* "Maybe later" — dismiss without signing in */
.signin-later {
display: block;
margin: 16px auto 0;
padding: 6px 12px;
border: none;
background: transparent;
font-family: var(--font-body);
font-size: var(--font-size-label-md);
font-weight: var(--font-weight-medium);
color: var(--color-on-surface-variant);
cursor: pointer;
transition: color var(--transition-fast);
}
.signin-later:hover {
color: var(--color-primary);
}
+18 -2
View File
@@ -1,7 +1,7 @@
import { useState } from "react";
import "./SignIn.css";
const SignInModal = ({ onSignIn, onSignUp, onError }) => {
const SignInModal = ({ onSignIn, onSignUp, onError, onClose }) => {
const [mode, setMode] = useState("signin");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
@@ -26,8 +26,18 @@ const SignInModal = ({ onSignIn, onSignUp, onError }) => {
};
return (
<div className="signin-overlay">
<div className="signin-overlay" onClick={onClose ? (e) => { if (e.target === e.currentTarget) onClose(); } : undefined}>
<div className="signin-card">
{onClose && (
<button
type="button"
className="signin-close"
onClick={onClose}
aria-label="Close sign in"
>
<span className="material-symbols-outlined">close</span>
</button>
)}
<h2 className="signin-title">
{mode === "signin" ? "Sign in to padhle" : "Create your account"}
</h2>
@@ -89,6 +99,12 @@ const SignInModal = ({ onSignIn, onSignUp, onError }) => {
<p className="signin-note">
By continuing, you agree to padhle's Terms of Service.
</p>
{onClose && (
<button type="button" className="signin-later" onClick={onClose}>
Maybe later
</button>
)}
</div>
</div>
);
+11 -1
View File
@@ -4,6 +4,7 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import App from "./App.jsx";
import { AuthProvider } from "./lib/auth/SupabaseAuth.jsx";
import "./styles/global.css";
@@ -11,7 +12,16 @@ import "./styles/global.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<AuthProvider>
<App />
<BrowserRouter>
<Routes>
{/* Both paths render the same App shell; the chat id is read via
useParams() to support deep links like /chat/:chatId. A catch-all
keeps unknown paths on the app (Vite SPA fallback serves index.html). */}
<Route path="/" element={<App />} />
<Route path="/chat/:chatId" element={<App />} />
<Route path="*" element={<App />} />
</Routes>
</BrowserRouter>
</AuthProvider>
</React.StrictMode>
);