fixed page state while generating a message

This commit is contained in:
2026-09-19 05:42:26 -04:00
parent b2d8109019
commit 616282cb35
8 changed files with 411 additions and 228 deletions
+2
View File
@@ -30,3 +30,5 @@ logs/
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
.vercel
.env*
+11 -8
View File
@@ -17,23 +17,26 @@ import supabaseAuth, { optionalAuth } from "./middleware/supabaseAuth.js";
dotenv.config(); dotenv.config();
const app = express(); const app = express();
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173"; const CORS_ORIGINS = (process.env.CORS_ORIGIN || "http://localhost:5173")
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);
// Trust proxy hop count so req.ip (and therefore rate limiting) sees the real // Trust proxy hop count so req.ip (and therefore rate limiting) sees the real
// client IP behind a reverse proxy. Off by default; set TRUST_PROXY=1 in prod. // client IP behind a reverse proxy. Off by default; set TRUST_PROXY=1 in prod.
if (process.env.TRUST_PROXY) { if (process.env.TRUST_PROXY) {
app.set("trust proxy", process.env.TRUST_PROXY); app.set("trust proxy", process.env.TRUST_PROXY);
} }
// Middleware // Middleware
app.use(securityHeaders); app.use(securityHeaders);
app.use(cookieParser()); app.use(cookieParser());
app.use(express.json()); app.use(express.json());
app.use(cors({ origin: CORS_ORIGIN, credentials: true })); app.use(cors({ origin: CORS_ORIGINS, credentials: true }));
// Health check // Health check
app.get("/health", (_req, res) => { app.get("/health", (_req, res) => {
res.json({ status: "ok", provider: process.env.AI_PROVIDER }); res.json({ status: "ok", provider: process.env.AI_PROVIDER });
}); });
// Auth routes — public endpoints (signup, signin, signout, me) // Auth routes — public endpoints (signup, signin, signout, me)
@@ -47,16 +50,16 @@ app.use("/api/sessions", sessionsLimiter, supabaseAuth, sessionRoutes);
// 404 handler // 404 handler
app.use((_req, res) => { app.use((_req, res) => {
res.status(404).json({ error: "Endpoint not found" }); res.status(404).json({ error: "Endpoint not found" });
}); });
// Global error handler — never leak stack traces or internals to the client // Global error handler — never leak stack traces or internals to the client
// (Express's default handler emits HTML stack dumps in non-production). // (Express's default handler emits HTML stack dumps in non-production).
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
app.use((err, _req, res, _next) => { app.use((err, _req, res, _next) => {
console.error("Unhandled error:", err?.message); console.error("Unhandled error:", err?.message);
if (res.headersSent) return; if (res.headersSent) return;
res.status(500).json({ error: "Internal server error" }); res.status(500).json({ error: "Internal server error" });
}); });
export default app; export default app;
+48 -34
View File
@@ -3,12 +3,11 @@
======================================== */ ======================================== */
import { useState, useRef, useCallback, useEffect } from "react"; import { useState, useRef, useCallback, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useMatch, useNavigate } from "react-router-dom";
import Sidebar from "./components/sidebar/Sidebar"; import Sidebar from "./components/sidebar/Sidebar";
import TopNav from "./components/top-nav/TopNav"; import TopNav from "./components/top-nav/TopNav";
import ChatHistory from "./components/chat-history/ChatHistory"; import ChatHistory from "./components/chat-history/ChatHistory";
import ChatInput from "./components/chat-input/ChatInput"; import ChatInput from "./components/chat-input/ChatInput";
import SelectorFlow from "./components/selector-flow/SelectorFlow";
import { useAuth } from "./lib/auth/SupabaseAuth"; import { useAuth } from "./lib/auth/SupabaseAuth";
import SignInModal from "./lib/auth/SignIn"; import SignInModal from "./lib/auth/SignIn";
import { resetAppState } from "./appState"; import { resetAppState } from "./appState";
@@ -94,9 +93,10 @@ const chapterData = {
}; };
function App() { function App() {
const { isAuthenticated, user, signIn, signUp, signOut, getToken } = useAuth(); const { isAuthenticated, user, signIn, signUp, signOut, getToken } =
useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const { chatId: routeChatId } = useParams(); const routeChatId = useMatch("/chat/:chatId")?.params.chatId ?? null;
// Guards deep-link loading so it only fires once per route/authenticated change. // Guards deep-link loading so it only fires once per route/authenticated change.
const chatLoadHandledRef = useRef(null); const chatLoadHandledRef = useRef(null);
const [messages, setMessages] = useState([]); const [messages, setMessages] = useState([]);
@@ -166,7 +166,7 @@ function App() {
chatLoadHandledRef.current = null; chatLoadHandledRef.current = null;
navigate("/"); navigate("/");
} }
}, [isAuthenticated, navigate, routeChatId]); }, [isAuthenticated, navigate]);
// Deep link: landing on /chat/:chatId loads that conversation (signed-in // Deep link: landing on /chat/:chatId loads that conversation (signed-in
// users only — anonymous trials are in-memory, so a refreshed trial URL // users only — anonymous trials are in-memory, so a refreshed trial URL
@@ -178,8 +178,6 @@ function App() {
handleChatSelect(routeChatId); handleChatSelect(routeChatId);
}, [routeChatId, isAuthenticated]); }, [routeChatId, isAuthenticated]);
// ─── Auth handlers ─── // ─── Auth handlers ───
const handleAuthError = (msg) => { const handleAuthError = (msg) => {
setMessages((prev) => [ setMessages((prev) => [
@@ -196,7 +194,10 @@ function App() {
const headers = {}; const headers = {};
if (token) headers["Authorization"] = `Bearer ${token}`; if (token) headers["Authorization"] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}/api/sessions`, { headers, credentials: "include" }); const res = await fetch(`${API_BASE}/api/sessions`, {
headers,
credentials: "include",
});
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setSessions(data); setSessions(data);
@@ -250,7 +251,10 @@ function App() {
const headers = {}; const headers = {};
if (token) headers["Authorization"] = `Bearer ${token}`; if (token) headers["Authorization"] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}/api/chat/${sessionId}`, { headers, credentials: "include" }); const res = await fetch(`${API_BASE}/api/chat/${sessionId}`, {
headers,
credentials: "include",
});
if (!res.ok) { if (!res.ok) {
throw new Error("Session not found"); throw new Error("Session not found");
} }
@@ -338,9 +342,9 @@ function App() {
const body = { const body = {
text: text.trim(), text: text.trim(),
chatId: fetchChatId, chatId: fetchChatId,
grade: activeGrade !== DEFAULTS.grade ? activeGrade : undefined, grade: activeGrade === DEFAULTS.grade ? undefined : activeGrade,
subject: activeSubject !== DEFAULTS.subject ? activeSubject : undefined, subject: activeSubject === DEFAULTS.subject ? undefined : activeSubject,
chapter: activeChapter !== DEFAULTS.chapter ? activeChapter : undefined, chapter: activeChapter === DEFAULTS.chapter ? undefined : activeChapter,
}; };
// Open SSE connection // Open SSE connection
@@ -406,17 +410,19 @@ function App() {
try { try {
const data = JSON.parse(jsonStr); const data = JSON.parse(jsonStr);
if (data.type === "session") { 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); 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) { if (isAuthenticated) {
navigate(`/chat/${data.id}`, { replace: true }); // 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; chatLoadHandledRef.current = data.id;
window.history.replaceState(
window.history.state,
"",
`/chat/${encodeURIComponent(data.id)}`,
);
} }
} else if (data.type === "limit") { } else if (data.type === "limit") {
// Anonymous free-message budget remaining (server truth). // Anonymous free-message budget remaining (server truth).
@@ -425,19 +431,15 @@ function App() {
assistantText += data.content; assistantText += data.content;
setMessages((prev) => setMessages((prev) =>
prev.map((m) => prev.map((m) =>
m.id === assistantId m.id === assistantId ? { ...m, text: assistantText } : m,
? { ...m, text: assistantText } ),
: m
)
); );
} else if (data.type === "done") { } else if (data.type === "done") {
// Mark as non-streaming // Mark as non-streaming
setMessages((prev) => setMessages((prev) =>
prev.map((m) => prev.map((m) =>
m.id === assistantId m.id === assistantId ? { ...m, streaming: false } : m,
? { ...m, streaming: false } ),
: m
)
); );
} else if (data.type === "error") { } else if (data.type === "error") {
setMessages((prev) => setMessages((prev) =>
@@ -448,8 +450,8 @@ function App() {
text: `⚠️ Error: ${data.message}`, text: `⚠️ Error: ${data.message}`,
streaming: false, streaming: false,
} }
: m : m,
) ),
); );
} }
} catch { } catch {
@@ -461,7 +463,8 @@ function App() {
setIsTyping(false); setIsTyping(false);
abortRef.current = null; abortRef.current = null;
// Refresh sessions to update timestamps (signed-in users only) // Refresh the sidebar without changing routes. The active session id
// already lives in state and is reused by the next message.
if (isAuthenticated) loadSessions(); if (isAuthenticated) loadSessions();
}) })
.catch((err) => { .catch((err) => {
@@ -478,14 +481,23 @@ function App() {
abortRef.current = null; abortRef.current = null;
}); });
}, },
[isTyping, activeChat, activeGrade, activeSubject, activeChapter, isAnonymous, anonRemaining, isAuthenticated] [
isTyping,
activeChat,
activeGrade,
activeSubject,
activeChapter,
isAnonymous,
anonRemaining,
isAuthenticated,
],
); );
const handlePromptClick = useCallback( const handlePromptClick = useCallback(
(promptText) => { (promptText) => {
handleSend(promptText); handleSend(promptText);
}, },
[handleSend] [handleSend],
); );
/* ─── Selector step handlers ─── */ /* ─── Selector step handlers ─── */
@@ -619,7 +631,9 @@ function App() {
onPromptClick={handlePromptClick} onPromptClick={handlePromptClick}
isTyping={isTyping} isTyping={isTyping}
selectorStep={selectorStep} selectorStep={selectorStep}
selectorData={selectorStep !== "chat" ? selectorData[selectorStep] : null} selectorData={
selectorStep === "chat" ? null : selectorData[selectorStep]
}
/> />
</div> </div>
@@ -2,167 +2,256 @@
ChatHistory Component — padhle (Refined Workspace) ChatHistory Component — padhle (Refined Workspace)
======================================== */ ======================================== */
import { useEffect, useRef } from "react";
import Message from "../message/Message"; import Message from "../message/Message";
import SelectorFlow from "../selector-flow/SelectorFlow"; import SelectorFlow from "../selector-flow/SelectorFlow";
import "./ChatHistory.css"; import "./ChatHistory.css";
const ChatHistory = ({ const ChatHistory = ({
messages, messages,
activeSubject, activeSubject,
activeGrade, activeGrade,
activeChapter, activeChapter,
onPromptClick, onPromptClick,
isTyping, isTyping,
selectorStep, selectorStep,
selectorData, selectorData,
}) => { }) => {
const hasMessages = messages.length > 0; const hasMessages = messages.length > 0;
const isSelector = selectorStep !== "chat"; const isSelector = selectorStep !== "chat";
const suggestions = [ const suggestions = [
{ {
id: 1, id: 1,
icon: "eco", icon: "eco",
iconFilled: true, iconFilled: true,
color: "--color-accent-green", color: "--color-accent-green",
colorBg: "--color-accent-green-bg", colorBg: "--color-accent-green-bg",
keywords: ["photosynthesis"], keywords: ["photosynthesis"],
text: "Explain how ", text: "Explain how ",
textEnd: " works in simple terms", textEnd: " works in simple terms",
}, },
{ {
id: 2, id: 2,
icon: "calculate", icon: "calculate",
iconFilled: false, iconFilled: false,
color: "--color-accent-blue", color: "--color-accent-blue",
colorBg: "--color-accent-blue-bg", colorBg: "--color-accent-blue-bg",
keywords: ["derivatives"], keywords: ["derivatives"],
text: "Help me understand ", text: "Help me understand ",
textEnd: " in calculus", textEnd: " in calculus",
}, },
{ {
id: 3, id: 3,
icon: "account_balance", icon: "account_balance",
iconFilled: false, iconFilled: false,
color: "--color-accent-orange", color: "--color-accent-orange",
colorBg: "--color-accent-orange-bg", colorBg: "--color-accent-orange-bg",
keywords: ["Industrial"], keywords: ["Industrial"],
text: "What caused the ", text: "What caused the ",
textEnd: " Revolution?", textEnd: " Revolution?",
}, },
{ {
id: 4, id: 4,
icon: "edit", icon: "edit",
iconFilled: true, iconFilled: true,
color: "--color-accent-purple", color: "--color-accent-purple",
colorBg: "--color-accent-purple-bg", colorBg: "--color-accent-purple-bg",
keywords: ["Romeo and Juliet"], keywords: ["Romeo and Juliet"],
text: "Help me analyze themes in ", text: "Help me analyze themes in ",
textEnd: "", textEnd: "",
}, },
]; ];
const fullPromptTexts = suggestions.map((s) => s.text + s.keywords.join(" ") + s.textEnd); const fullPromptTexts = suggestions.map(
(s) => s.text + s.keywords.join(" ") + s.textEnd,
);
const streamingMsg = messages.find((m) => m.streaming); const streamingMsg = messages.find((m) => m.streaming);
const historyRef = useRef(null);
/* ─── Messages mode ─── */ // Scroll only the chat pane, and only when a message is added. Streaming
if (hasMessages) { // token updates must not move the page or restart a smooth-scroll animation.
return ( useEffect(() => {
<div className="chat-history"> const history = historyRef.current;
<div className="chat-history__list"> if (history) history.scrollTop = history.scrollHeight;
{messages.map((message) => ( }, [messages.length]);
<Message key={message.id} message={message} />
))}
{isTyping && !streamingMsg && (
<Message message={{ id: "typing", role: "assistant", text: "", streaming: true }} />
)}
<div ref={(el) => el && el.scrollIntoView({ behavior: "smooth", block: "end" })} />
</div>
</div>
);
}
/* ─── Selector mode (grade → subject → chapter) ─── */ /* ─── Messages mode ─── */
if (isSelector && selectorData) { if (hasMessages) {
return ( return (
<div className="chat-history"> <div className="chat-history" ref={historyRef}>
<SelectorFlow <div className="chat-history__list">
title={selectorData.title} {messages.map((message) => (
subtitle={selectorData.subtitle} <Message
selected={selectorData.selected} key={message.id}
items={selectorData.items} message={message}
onSelect={selectorData.onSelect} />
step={selectorStep} ))}
/> {isTyping && !streamingMsg && (
</div> <Message
); message={{
} id: "typing",
role: "assistant",
text: "",
streaming: true,
}}
/>
)}
</div>
</div>
);
}
/* ─── Welcome / suggestions mode ─── */ /* ─── Selector mode (grade → subject → chapter) ─── */
return ( if (isSelector && selectorData) {
<div className="chat-history"> return (
<div className="chat-history__focus"> <div className="chat-history">
{/* Welcome Header */} <SelectorFlow
<div className="chat-history__welcome"> title={selectorData.title}
<div className="chat-history__welcome-icon"> subtitle={selectorData.subtitle}
<span className="material-symbols-outlined" style={{ fontWeight: 200 }}>menu_book</span> selected={selectorData.selected}
<span className="material-symbols-outlined chat-history__welcome-sparkle">auto_awesome</span> items={selectorData.items}
</div> onSelect={selectorData.onSelect}
<h2 className="chat-history__welcome-title">Welcome to padhle</h2> step={selectorStep}
<p className="chat-history__welcome-subtitle">Your AI tutor for grades 6–12. Ask anything!</p> />
</div> </div>
);
}
{/* Bento Grid Suggestions */} /* ─── Welcome / suggestions mode ─── */
<div className="chat-history__bento"> return (
{suggestions.map((suggestion) => { <div className="chat-history">
const colorVar = suggestion.color.replace("--", "var("); <div className="chat-history__focus">
const bgColorVar = suggestion.colorBg.replace("--", "var("); {/* Welcome Header */}
<div className="chat-history__welcome">
<div className="chat-history__welcome-icon">
<span
className="material-symbols-outlined"
style={{
fontWeight: 200,
}}
>
menu_book
</span>
<span className="material-symbols-outlined chat-history__welcome-sparkle">
auto_awesome
</span>
</div>
<h2 className="chat-history__welcome-title">
Welcome to padhle
</h2>
<p className="chat-history__welcome-subtitle">
Your AI tutor for grades 6–12.
Ask anything!
</p>
</div>
return ( {/* Bento Grid Suggestions */}
<button <div className="chat-history__bento">
key={suggestion.id} {suggestions.map((suggestion) => {
className="chat-history__bento-card" const colorVar =
onClick={() => { suggestion.color.replace(
const text = fullPromptTexts[suggestion.id - 1]; "--",
onPromptClick(text); "var(",
}} );
> const bgColorVar =
<div className="chat-history__bento-icon-wrap" style={{ backgroundColor: `var(${bgColorVar})` }}> suggestion.colorBg.replace(
<span className="material-symbols-outlined" style={{ color: `var(${colorVar})`, fontWeight: suggestion.iconFilled ? 700 : 200 }}> "--",
{suggestion.icon} "var(",
</span> );
return (
<button
key={
suggestion.id
}
className="chat-history__bento-card"
onClick={() => {
const text =
fullPromptTexts[
suggestion.id -
1
];
onPromptClick(
text,
);
}}
>
<div
className="chat-history__bento-icon-wrap"
style={{
backgroundColor: `var(${bgColorVar})`,
}}
>
<span
className="material-symbols-outlined"
style={{
color: `var(${colorVar})`,
fontWeight: suggestion.iconFilled
? 700
: 200,
}}
>
{
suggestion.icon
}
</span>
</div>
<div className="chat-history__bento-text">
<span className="chat-history__bento-text-main">
{
suggestion.text
}
</span>
<span
className="chat-history__bento-text-highlight"
style={{
color: `var(${colorVar})`,
fontWeight: 600,
}}
>
{
suggestion
.keywords[0]
}
</span>
{
suggestion.textEnd
}
</div>
<span className="material-symbols-outlined chat-history__bento-arrow">
arrow_forward
</span>
</button>
);
})}
</div>
{/* Info Panel */}
<div className="chat-history__info">
<div className="chat-history__info-icon-wrap">
<span className="material-symbols-outlined">
lightbulb
</span>
</div>
<div className="chat-history__info-content">
<h4 className="chat-history__info-title">
Focused on your learning
</h4>
<p className="chat-history__info-text">
I'll answer questions
related to your selected
subject and chapter
<br />
using your textbook
content.
</p>
</div>
</div>
</div>
</div> </div>
<div className="chat-history__bento-text"> );
<span className="chat-history__bento-text-main">{suggestion.text}</span>
<span className="chat-history__bento-text-highlight" style={{ color: `var(${colorVar})`, fontWeight: 600 }}>
{suggestion.keywords[0]}
</span>
{suggestion.textEnd}
</div>
<span className="material-symbols-outlined chat-history__bento-arrow">arrow_forward</span>
</button>
);
})}
</div>
{/* Info Panel */}
<div className="chat-history__info">
<div className="chat-history__info-icon-wrap">
<span className="material-symbols-outlined">lightbulb</span>
</div>
<div className="chat-history__info-content">
<h4 className="chat-history__info-title">Focused on your learning</h4>
<p className="chat-history__info-text">
I'll answer questions related to your selected subject and chapter
<br />
using your textbook content.
</p>
</div>
</div>
</div>
</div>
);
}; };
export default ChatHistory; export default ChatHistory;
+47 -33
View File
@@ -2,7 +2,6 @@
Message Component — padhle (Refined Workspace) Message Component — padhle (Refined Workspace)
======================================== */ ======================================== */
import { useEffect, useRef } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkMath from "remark-math"; import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex"; import rehypeKatex from "rehype-katex";
@@ -12,61 +11,76 @@ import "./Message.css";
const Message = ({ message }) => { const Message = ({ message }) => {
const { role, text, streaming } = message; const { role, text, streaming } = message;
const isUser = role === "user"; const isUser = role === "user";
const endRef = useRef(null);
// Auto-scroll to bottom when text updates
useEffect(() => {
if (endRef.current) {
endRef.current.scrollIntoView({ behavior: "smooth", block: "end" });
}
}, [text, streaming]);
return ( return (
<div className={`message ${isUser ? "message--user" : "message--assistant"}`}> <div
className={`message ${isUser ? "message--user" : "message--assistant"}`}
>
{!isUser && ( {!isUser && (
<div className="message__avatar"> <div className="message__avatar">
<div className="message__avatar-icon"> <div className="message__avatar-icon">
<span className="material-symbols-outlined" style={{ fontWeight: 700 }}>school</span> <span
className="material-symbols-outlined"
style={{ fontWeight: 700 }}
>
school
</span>
</div> </div>
</div> </div>
)} )}
<div className="message__bubble"> <div className="message__bubble">
<div className={`message__text ${!isUser ? "markdown-body" : ""}`}> <div className={`message__text ${isUser ? "" : "markdown-body"}`}>
{isUser ? ( {isUser
text ? text
) : ( : text && (
text && ( <ReactMarkdown
<ReactMarkdown remarkPlugins={[remarkMath]}
remarkPlugins={[remarkMath]} rehypePlugins={[rehypeKatex]}
rehypePlugins={[rehypeKatex]} >
> {text}
{text} </ReactMarkdown>
</ReactMarkdown> )}
) {streaming && (
<span className="message__typing-dots">
<span className="message__typing-dot"></span>
<span className="message__typing-dot"></span>
<span className="message__typing-dot"></span>
</span>
)} )}
{streaming && <span className="message__typing-dots">
<span className="message__typing-dot"></span>
<span className="message__typing-dot"></span>
<span className="message__typing-dot"></span>
</span>}
</div> </div>
</div> </div>
{/* Action buttons for assistant messages */} {/* Action buttons for assistant messages */}
{!isUser && text && ( {!isUser && text && (
<div className="message__actions"> <div className="message__actions">
<button className="message__action" aria-label="Helpful" title="Helpful"> <button
className="message__action"
aria-label="Helpful"
title="Helpful"
>
<span className="material-symbols-outlined">thumb_up</span> <span className="material-symbols-outlined">thumb_up</span>
</button> </button>
<button className="message__action" aria-label="Save note" title="Save Note"> <button
className="message__action"
aria-label="Save note"
title="Save Note"
>
<span className="material-symbols-outlined">bookmark_border</span> <span className="material-symbols-outlined">bookmark_border</span>
</button> </button>
<button className="message__action message__action--primary" aria-label="Explain deeper" title="Explain Deeper"> <button
<span className="material-symbols-outlined" style={{ fontWeight: 700 }}>menu_book</span> className="message__action message__action--primary"
aria-label="Explain deeper"
title="Explain Deeper"
>
<span
className="material-symbols-outlined"
style={{ fontWeight: 700 }}
>
menu_book
</span>
</button> </button>
</div> </div>
)} )}
<div ref={endRef} />
</div> </div>
); );
}; };
+2 -6
View File
@@ -14,14 +14,10 @@ ReactDOM.createRoot(document.getElementById("root")).render(
<AuthProvider> <AuthProvider>
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
{/* Both paths render the same App shell; the chat id is read via {/* One route owns the app shell so URL changes never remount it. */}
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 />} /> <Route path="*" element={<App />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</AuthProvider> </AuthProvider>
</React.StrictMode> </React.StrictMode>,
); );
+37
View File
@@ -0,0 +1,37 @@
│ 🔧 Development Tools │
├─────────┬────────────────────────────┤
│ Studio │ http://http://192.168.50.153/:54323 │
│ Mailpit │ http://http://192.168.50.153/:54324 │
│ MCP │ http://http://192.168.50.153/:54321/mcp │
╰─────────┴────────────────────────────╯
╭──────────────────────────────────────────────────────╮
│ 🌐 APIs │
├────────────────┬─────────────────────────────────────┤
│ Project URL │ http://http://192.168.50.153/:54321 │
│ REST │ http://http://192.168.50.153/:54321/rest/v1 │
│ GraphQL │ http://http://192.168.50.153/:54321/graphql/v1 │
│ Edge Functions │ http://http://192.168.50.153/:54321/functions/v1 │
╰────────────────┴─────────────────────────────────────╯
╭───────────────────────────────────────────────────────────────╮
│ ⛁ Database │
├─────┬─────────────────────────────────────────────────────────┤
│ URL │ postgresql://postgres:postgres@http://192.168.50.153/:54322/postgres │
╰─────┴─────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────╮
│ 🔑 Authentication Keys │
├─────────────┬────────────────────────────────────────────────┤
│ Publishable │ sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH │
│ Secret │ sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz │
╰─────────────┴────────────────────────────────────────────────╯
╭───────────────────────────────────────────────────────────────────────────────╮
│ 📦 Storage (S3) │
├────────────┬──────────────────────────────────────────────────────────────────┤
│ URL │ http://http://192.168.50.153/:54321/storage/v1/s3 │
│ Access Key │ 625729a08b95bf1b7ff351a663f3a23c │
│ Secret Key │ 850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907 │
│ Region │ local │
╰────────────┴──────────
+28
View File
@@ -0,0 +1,28 @@
{
"services": {
"frontend": {
"root": ".",
"framework": "vite"
},
"backend": {
"root": "backend",
"entrypoint": "src/app.js"
}
},
"rewrites": [
{
"source": "/api(/.*)?",
"destination": {
"type": "service",
"service": "backend"
}
},
{
"source": "/(.*)",
"destination": {
"type": "service",
"service": "frontend"
}
}
]
}