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