fixed page state while generating a message | fixed scrolling effect
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"test": "node --test src/**/*.test.js",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
+90
-560
@@ -1,596 +1,140 @@
|
||||
/* ========================================
|
||||
App Component — padhle (Backend-Connected)
|
||||
======================================== */
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useMatch, useNavigate } from "react-router-dom";
|
||||
import Sidebar from "./components/sidebar/Sidebar";
|
||||
import TopNav from "./components/top-nav/TopNav";
|
||||
import ChatHistory from "./components/chat-history/ChatHistory";
|
||||
import ChatInput from "./components/chat-input/ChatInput";
|
||||
import { useAuth } from "./lib/auth/SupabaseAuth";
|
||||
import SignInModal from "./lib/auth/SignIn";
|
||||
import { resetAppState } from "./appState";
|
||||
const API_BASE = ""; // Uses Vite proxy → localhost:3001 in dev
|
||||
|
||||
// Default selectors
|
||||
const DEFAULTS = {
|
||||
grade: "Choose standard",
|
||||
subject: "choose-subject",
|
||||
chapter: "choose-chapter",
|
||||
};
|
||||
|
||||
/* ─── Selector data ─── */
|
||||
|
||||
const gradeItems = [
|
||||
{ id: "grade-6", label: "Grade 6", icon: "backpack" },
|
||||
{ id: "grade-7", label: "Grade 7", icon: "auto_stories" },
|
||||
{ id: "grade-8", label: "Grade 8", icon: "menu_book" },
|
||||
{ id: "grade-9", label: "Grade 9", icon: "science" },
|
||||
{ id: "grade-10", label: "Grade 10", icon: "calculate" },
|
||||
{ id: "grade-11", label: "Grade 11", icon: "psychology" },
|
||||
{ id: "grade-12", label: "Grade 12", icon: "school" },
|
||||
];
|
||||
|
||||
const subjectItems = [
|
||||
{ id: "math", label: "Mathematics" },
|
||||
{ id: "science", label: "Science" },
|
||||
{ id: "history", label: "History" },
|
||||
{ id: "language", label: "Language Arts" },
|
||||
{ id: "english", label: "English" },
|
||||
{ id: "geography", label: "Geography" },
|
||||
];
|
||||
|
||||
const chapterData = {
|
||||
math: [
|
||||
{ id: "ch-1", label: "Chapter 1: Rational Numbers" },
|
||||
{ id: "ch-2", label: "Chapter 2: Linear Equations" },
|
||||
{ id: "ch-3", label: "Chapter 3: Coordinate Geometry" },
|
||||
{ id: "ch-4", label: "Chapter 4: Algebra" },
|
||||
{ id: "ch-5", label: "Chapter 5: Geometry" },
|
||||
{ id: "ch-6", label: "Chapter 6: Trigonometry" },
|
||||
],
|
||||
science: [
|
||||
{ id: "ch-1", label: "Chapter 1: Nutrition in Plants" },
|
||||
{ id: "ch-2", label: "Chapter 2: Photosynthesis" },
|
||||
{ id: "ch-3", label: "Chapter 3: Human Physiology" },
|
||||
{ id: "ch-4", label: "Chapter 4: Chemistry Basics" },
|
||||
{ id: "ch-5", label: "Chapter 5: Motion & Force" },
|
||||
{ id: "ch-6", label: "Chapter 6: Electricity" },
|
||||
],
|
||||
history: [
|
||||
{ id: "ch-1", label: "Chapter 1: Early Civilizations" },
|
||||
{ id: "ch-2", label: "Chapter 2: Ancient India" },
|
||||
{ id: "ch-3", label: "Chapter 3: Medieval Period" },
|
||||
{ id: "ch-4", label: "Chapter 4: Modern India" },
|
||||
{ id: "ch-5", label: "Chapter 5: World Wars" },
|
||||
{ id: "ch-6", label: "Chapter 6: Independence Movement" },
|
||||
],
|
||||
language: [
|
||||
{ id: "ch-1", label: "Chapter 1: Grammar Basics" },
|
||||
{ id: "ch-2", label: "Chapter 2: Comprehension" },
|
||||
{ id: "ch-3", label: "Chapter 3: Creative Writing" },
|
||||
{ id: "ch-4", label: "Chapter 4: Literature" },
|
||||
{ id: "ch-5", label: "Chapter 5: Vocabulary" },
|
||||
{ id: "ch-6", label: "Chapter 6: Composition" },
|
||||
],
|
||||
english: [
|
||||
{ id: "ch-1", label: "Chapter 1: Reading Skills" },
|
||||
{ id: "ch-2", label: "Chapter 2: Writing Skills" },
|
||||
{ id: "ch-3", label: "Chapter 3: Grammar" },
|
||||
{ id: "ch-4", label: "Chapter 4: Poetry" },
|
||||
{ id: "ch-5", label: "Chapter 5: Prose" },
|
||||
{ id: "ch-6", label: "Chapter 6: Drama" },
|
||||
],
|
||||
geography: [
|
||||
{ id: "ch-1", label: "Chapter 1: Earth & Universe" },
|
||||
{ id: "ch-2", label: "Chapter 2: Landforms" },
|
||||
{ id: "ch-3", label: "Chapter 3: Climate & Weather" },
|
||||
{ id: "ch-4", label: "Chapter 4: Natural Resources" },
|
||||
{ id: "ch-5", label: "Chapter 5: Maps & Atlas" },
|
||||
{ id: "ch-6", label: "Chapter 6: Human Geography" },
|
||||
],
|
||||
};
|
||||
import { useAuth } from "./lib/auth/SupabaseAuth";
|
||||
import { useChat } from "./hooks/useChat";
|
||||
import {
|
||||
chapterData,
|
||||
DEFAULTS,
|
||||
getChapterLabel,
|
||||
getSubjectLabel,
|
||||
gradeItems,
|
||||
subjectItems,
|
||||
} from "./data/curriculum";
|
||||
|
||||
function App() {
|
||||
const { isAuthenticated, user, signIn, signUp, signOut, getToken } =
|
||||
useAuth();
|
||||
const { isAuthenticated, user, signIn, signUp, signOut } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const routeChatId = useMatch("/chat/:chatId")?.params.chatId ?? null;
|
||||
// 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);
|
||||
const [showSignIn, setShowSignIn] = useState(false);
|
||||
const requestSignIn = useCallback(() => setShowSignIn(true), []);
|
||||
|
||||
/* ─── Subject label resolution (ID → display name) ─── */
|
||||
const getSubjectLabel = (id) => {
|
||||
if (!id || id === "choose-subject") return "Choose Subject";
|
||||
return subjectItems.find((s) => s.id === id)?.label || id;
|
||||
};
|
||||
const chat = useChat({
|
||||
isAuthenticated,
|
||||
routeChatId,
|
||||
navigate,
|
||||
onSignInRequired: requestSignIn,
|
||||
});
|
||||
|
||||
/* ─── Chapter list resolution (subject ID → array) ─── */
|
||||
const getChaptersForSubject = (subjectId) => {
|
||||
return chapterData[subjectId] || [];
|
||||
};
|
||||
const {
|
||||
messages,
|
||||
activeChat,
|
||||
activeGrade,
|
||||
activeSubject,
|
||||
activeChapter,
|
||||
selectorStep,
|
||||
sessions,
|
||||
isLoadingSessions,
|
||||
isTyping,
|
||||
anonRemaining,
|
||||
setActiveGrade,
|
||||
setActiveSubject,
|
||||
setActiveChapter,
|
||||
setSelectorStep,
|
||||
addError,
|
||||
newChat,
|
||||
selectChat,
|
||||
deleteChat,
|
||||
send,
|
||||
} = chat;
|
||||
|
||||
/* ─── Chapter label resolution (ID → display name) ─── */
|
||||
const getChapterLabel = (chapterId) => {
|
||||
if (!chapterId || chapterId === "choose-chapter") return "Choose Chapter";
|
||||
return chapterId;
|
||||
};
|
||||
const [activeChat, setActiveChat] = useState(null);
|
||||
const [activeGrade, setActiveGrade] = useState(DEFAULTS.grade);
|
||||
const [activeChapter, setActiveChapter] = useState(DEFAULTS.chapter);
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
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(() => {
|
||||
if (!hasMountedRef.current) {
|
||||
hasMountedRef.current = true;
|
||||
if (isAuthenticated) loadSessions();
|
||||
return;
|
||||
}
|
||||
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
abortRef.current = null;
|
||||
}
|
||||
|
||||
const next = resetAppState();
|
||||
setMessages(next.messages);
|
||||
setActiveChat(next.activeChat);
|
||||
setActiveGrade(next.activeGrade);
|
||||
setActiveSubject(next.activeSubject);
|
||||
setActiveChapter(next.activeChapter);
|
||||
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();
|
||||
|
||||
// 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]);
|
||||
|
||||
// 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]);
|
||||
|
||||
// ─── Auth handlers ───
|
||||
const handleAuthError = (msg) => {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: Date.now(), role: "assistant", text: `⚠️ ${msg}` },
|
||||
]);
|
||||
};
|
||||
|
||||
const loadSessions = async () => {
|
||||
if (!isAuthenticated) return; // sessions require an account
|
||||
setIsLoadingSessions(true);
|
||||
try {
|
||||
const token = await getToken();
|
||||
const headers = {};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/sessions`, {
|
||||
headers,
|
||||
credentials: "include",
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSessions(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load sessions:", err);
|
||||
} finally {
|
||||
setIsLoadingSessions(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewChat = () => {
|
||||
// Abort any in-flight request
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
abortRef.current = null;
|
||||
}
|
||||
|
||||
// Clear everything
|
||||
setMessages([]);
|
||||
setActiveChat(null);
|
||||
setActiveGrade(DEFAULTS.grade);
|
||||
setActiveSubject(DEFAULTS.subject);
|
||||
const selectSubject = (id) => {
|
||||
setActiveSubject(id);
|
||||
setActiveChapter(DEFAULTS.chapter);
|
||||
setIsTyping(false);
|
||||
|
||||
// Reset selector to grade step
|
||||
setSelectorStep("grade");
|
||||
|
||||
// Refresh sessions list from backend
|
||||
loadSessions();
|
||||
|
||||
// Back to the home URL
|
||||
navigate("/");
|
||||
chatLoadHandledRef.current = null;
|
||||
if (selectorStep !== "chat") setSelectorStep("subject");
|
||||
};
|
||||
|
||||
const handleChatSelect = async (sessionId) => {
|
||||
if (!isAuthenticated) return; // anonymous users have no persisted sessions
|
||||
// Abort any in-flight request
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
abortRef.current = null;
|
||||
}
|
||||
|
||||
setIsTyping(true);
|
||||
setMessages([]);
|
||||
|
||||
try {
|
||||
const token = await getToken();
|
||||
const headers = {};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/chat/${sessionId}`, {
|
||||
headers,
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
const data = await res.json();
|
||||
|
||||
// Set the session's context (grade, subject, chapter)
|
||||
if (data.session) {
|
||||
setActiveGrade(data.session.grade || DEFAULTS.grade);
|
||||
setActiveSubject(data.session.subject || DEFAULTS.subject);
|
||||
setActiveChapter(data.session.chapter || DEFAULTS.chapter);
|
||||
}
|
||||
|
||||
// Move to chat view
|
||||
setSelectorStep("chat");
|
||||
|
||||
// Load the messages
|
||||
if (data.messages) {
|
||||
setMessages(data.messages.map((m) => ({ ...m, streaming: false })));
|
||||
}
|
||||
|
||||
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([
|
||||
{
|
||||
id: Date.now(),
|
||||
role: "assistant",
|
||||
text: `⚠️ Could not load that chat. It may have been deleted.`,
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
setIsTyping(false);
|
||||
}
|
||||
const selectChapter = (chapter) => {
|
||||
setActiveChapter(chapter);
|
||||
if (selectorStep !== "chat") setSelectorStep("chapter");
|
||||
};
|
||||
|
||||
const handleDeleteChat = async (sessionId) => {
|
||||
try {
|
||||
const token = await getToken();
|
||||
const headers = {};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/sessions/${sessionId}`, {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
credentials: "include",
|
||||
});
|
||||
if (res.ok) {
|
||||
// If we were on the deleted chat, clear it
|
||||
if (activeChat === sessionId) {
|
||||
handleNewChat();
|
||||
} else {
|
||||
loadSessions();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to delete session:", err);
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
|
||||
// Add user message immediately
|
||||
const userMsg = { id: Date.now(), role: "user", text };
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
setIsTyping(true);
|
||||
|
||||
const fetchChatId = activeChat;
|
||||
|
||||
// Build request body
|
||||
const body = {
|
||||
text: text.trim(),
|
||||
chatId: fetchChatId,
|
||||
grade: activeGrade === DEFAULTS.grade ? undefined : activeGrade,
|
||||
subject: activeSubject === DEFAULTS.subject ? undefined : activeSubject,
|
||||
chapter: activeChapter === DEFAULTS.chapter ? undefined : activeChapter,
|
||||
};
|
||||
|
||||
// Open SSE connection
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
const token = await getToken();
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
|
||||
fetch(`${API_BASE}/api/chat`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
credentials: "include",
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
})
|
||||
.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}`);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let assistantText = "";
|
||||
|
||||
// Create placeholder assistant message
|
||||
const assistantId = Date.now() + 1;
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: assistantId, role: "assistant", text: "", streaming: true },
|
||||
]);
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
const jsonStr = line.slice(6);
|
||||
if (jsonStr === "[DONE]") continue;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(jsonStr);
|
||||
if (data.type === "session") {
|
||||
setActiveChat(data.id);
|
||||
|
||||
if (isAuthenticated) {
|
||||
// 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;
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
"",
|
||||
`/chat/${encodeURIComponent(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) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantId ? { ...m, text: assistantText } : m,
|
||||
),
|
||||
);
|
||||
} else if (data.type === "done") {
|
||||
// Mark as non-streaming
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantId ? { ...m, streaming: false } : m,
|
||||
),
|
||||
);
|
||||
} else if (data.type === "error") {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantId
|
||||
? {
|
||||
...m,
|
||||
text: `⚠️ Error: ${data.message}`,
|
||||
streaming: false,
|
||||
}
|
||||
: m,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed SSE frames
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setIsTyping(false);
|
||||
abortRef.current = null;
|
||||
|
||||
// Refresh the sidebar without changing routes. The active session id
|
||||
// already lives in state and is reused by the next message.
|
||||
if (isAuthenticated) loadSessions();
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name === "AbortError") return; // intentional abort
|
||||
setIsTyping(false);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: Date.now() + 1,
|
||||
role: "assistant",
|
||||
text: `⚠️ Connection error: ${err.message}. Make sure the backend is running on port 3001.`,
|
||||
},
|
||||
]);
|
||||
abortRef.current = null;
|
||||
});
|
||||
},
|
||||
[
|
||||
isTyping,
|
||||
activeChat,
|
||||
activeGrade,
|
||||
activeSubject,
|
||||
activeChapter,
|
||||
isAnonymous,
|
||||
anonRemaining,
|
||||
isAuthenticated,
|
||||
],
|
||||
);
|
||||
|
||||
const handlePromptClick = useCallback(
|
||||
(promptText) => {
|
||||
handleSend(promptText);
|
||||
},
|
||||
[handleSend],
|
||||
);
|
||||
|
||||
/* ─── Selector step handlers ─── */
|
||||
|
||||
const handleGradeSelect = (item) => {
|
||||
setActiveGrade(item.label);
|
||||
setSelectorStep("subject");
|
||||
};
|
||||
|
||||
const handleSubjectSelect = (item) => {
|
||||
setActiveSubject(item.id);
|
||||
setActiveChapter(DEFAULTS.chapter);
|
||||
setSelectorStep("chapter");
|
||||
};
|
||||
|
||||
const handleChapterSelect = (item) => {
|
||||
setActiveChapter(item.label);
|
||||
setSelectorStep("chat");
|
||||
};
|
||||
|
||||
/* ─── Selector step data ─── */
|
||||
|
||||
const selectorData = {
|
||||
grade: {
|
||||
title: "Select your standard",
|
||||
subtitle: "Choose the grade you are studying in",
|
||||
items: gradeItems,
|
||||
selected: activeGrade,
|
||||
onSelect: handleGradeSelect,
|
||||
selectedValue: activeGrade,
|
||||
compareField: "label",
|
||||
onSelect: (item) => {
|
||||
setActiveGrade(item.label);
|
||||
setSelectorStep("subject");
|
||||
},
|
||||
},
|
||||
subject: {
|
||||
title: "Select your subject",
|
||||
subtitle: `Chose by grade – ${activeGrade}`,
|
||||
subtitle: `Chosen grade – ${activeGrade}`,
|
||||
items: subjectItems,
|
||||
selectedValue: activeSubject,
|
||||
onSelect: handleSubjectSelect,
|
||||
compareField: "id",
|
||||
onSelect: (item) => {
|
||||
setActiveSubject(item.id);
|
||||
setActiveChapter(DEFAULTS.chapter);
|
||||
setSelectorStep("chapter");
|
||||
},
|
||||
},
|
||||
chapter: {
|
||||
title: "Select a chapter",
|
||||
subtitle: `Subject – ${getSubjectLabel(activeSubject)}`,
|
||||
items: getChaptersForSubject(activeSubject),
|
||||
items: chapterData[activeSubject] || [],
|
||||
selectedValue: activeChapter,
|
||||
onSelect: handleChapterSelect,
|
||||
compareField: "label",
|
||||
onSelect: (item) => {
|
||||
setActiveChapter(item.label);
|
||||
setSelectorStep("chat");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Render — the app is always accessible; sign-in is on-demand ───
|
||||
const authenticate = async (action, email, password) => {
|
||||
try {
|
||||
await action(email, password);
|
||||
setShowSignIn(false);
|
||||
} catch (error) {
|
||||
addError(error.message || "Authentication failed");
|
||||
}
|
||||
};
|
||||
|
||||
const isAnonymous = !isAuthenticated;
|
||||
|
||||
return (
|
||||
<>
|
||||
{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}
|
||||
onSignIn={(email, password) => authenticate(signIn, email, password)}
|
||||
onSignUp={(email, password) => authenticate(signUp, email, password)}
|
||||
onError={addError}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="page">
|
||||
<Sidebar
|
||||
activeSubject={activeSubject}
|
||||
onSubjectChange={(id) => {
|
||||
setActiveSubject(id);
|
||||
setActiveChapter(DEFAULTS.chapter);
|
||||
if (selectorStep !== "chat") {
|
||||
setSelectorStep("subject");
|
||||
}
|
||||
}}
|
||||
onSubjectChange={selectSubject}
|
||||
activeChat={activeChat}
|
||||
onChatSelect={handleChatSelect}
|
||||
onNewChat={handleNewChat}
|
||||
onChatSelect={selectChat}
|
||||
onNewChat={newChat}
|
||||
sessions={sessions}
|
||||
onDeleteChat={handleDeleteChat}
|
||||
onDeleteChat={deleteChat}
|
||||
isLoadingSessions={isLoadingSessions}
|
||||
showUpgrade={false}
|
||||
isAnonymous={isAnonymous}
|
||||
onSignInRequest={() => setShowSignIn(true)}
|
||||
onSignInRequest={requestSignIn}
|
||||
user={user}
|
||||
onSignOut={signOut}
|
||||
/>
|
||||
@@ -598,26 +142,15 @@ function App() {
|
||||
<div className="page__main">
|
||||
<TopNav
|
||||
activeGrade={activeGrade}
|
||||
onGradeChange={(g) => setActiveGrade(g)}
|
||||
onGradeChange={setActiveGrade}
|
||||
subjectItems={subjectItems}
|
||||
chapterData={chapterData}
|
||||
activeSubject={activeSubject}
|
||||
activeChapter={activeChapter}
|
||||
onChapterChange={(ch) => {
|
||||
setActiveChapter(ch);
|
||||
if (selectorStep !== "chat") {
|
||||
setSelectorStep("chapter");
|
||||
}
|
||||
}}
|
||||
onSubjectChange={(id) => {
|
||||
setActiveSubject(id);
|
||||
setActiveChapter(DEFAULTS.chapter);
|
||||
if (selectorStep !== "chat") {
|
||||
setSelectorStep("subject");
|
||||
}
|
||||
}}
|
||||
onChapterChange={selectChapter}
|
||||
onSubjectChange={selectSubject}
|
||||
isAnonymous={isAnonymous}
|
||||
onSignInRequest={() => setShowSignIn(true)}
|
||||
onSignInRequest={requestSignIn}
|
||||
user={user}
|
||||
onSignOut={signOut}
|
||||
/>
|
||||
@@ -625,10 +158,7 @@ function App() {
|
||||
<div className="main-content">
|
||||
<ChatHistory
|
||||
messages={messages}
|
||||
activeSubject={activeSubject}
|
||||
activeGrade={activeGrade}
|
||||
activeChapter={activeChapter}
|
||||
onPromptClick={handlePromptClick}
|
||||
onPromptClick={send}
|
||||
isTyping={isTyping}
|
||||
selectorStep={selectorStep}
|
||||
selectorData={
|
||||
@@ -638,11 +168,11 @@ function App() {
|
||||
</div>
|
||||
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
onSend={send}
|
||||
placeholder={`Ask anything about ${activeGrade} ${getSubjectLabel(activeSubject)} – ${getChapterLabel(activeChapter)}...`}
|
||||
disabled={isTyping}
|
||||
exhausted={isAnonymous && anonRemaining <= 0}
|
||||
onSignInRequest={() => setShowSignIn(true)}
|
||||
onSignInRequest={requestSignIn}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
export const DEFAULT_APP_STATE = Object.freeze({
|
||||
messages: [],
|
||||
activeChat: null,
|
||||
activeGrade: "Choose standard",
|
||||
activeSubject: "choose-subject",
|
||||
activeChapter: "choose-chapter",
|
||||
isTyping: false,
|
||||
sessions: [],
|
||||
selectorStep: "grade",
|
||||
});
|
||||
|
||||
export function resetAppState() {
|
||||
return {
|
||||
...DEFAULT_APP_STATE,
|
||||
messages: [],
|
||||
sessions: [],
|
||||
};
|
||||
}
|
||||
@@ -2,211 +2,220 @@
|
||||
ChatHistory Component — padhle (Refined Workspace)
|
||||
======================================== */
|
||||
|
||||
/* Main Chat History */
|
||||
/* Main Chat History — the only scroll container in the app shell.
|
||||
min-height: 0 lets it shrink below content height so it scrolls
|
||||
internally instead of stretching the page. */
|
||||
.chat-history {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background-color: var(--color-background);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
|
||||
/* Focus Column — centered content area */
|
||||
.chat-history__focus {
|
||||
max-width: var(--container-max);
|
||||
margin: 0 auto;
|
||||
padding: 48px 24px 128px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-height: 100%;
|
||||
max-width: var(--container-max);
|
||||
margin: 0 auto;
|
||||
padding: 48px 24px 128px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* Welcome Header */
|
||||
.chat-history__welcome {
|
||||
text-align: center;
|
||||
margin-bottom: 48px;
|
||||
text-align: center;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.chat-history__welcome-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
margin-bottom: 16px;
|
||||
color: var(--color-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
margin-bottom: 16px;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.chat-history__welcome-icon .material-symbols-outlined {
|
||||
font-size: 48px;
|
||||
line-height: 1;
|
||||
font-size: 48px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chat-history__welcome-sparkle {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -16px;
|
||||
font-size: 20px;
|
||||
color: var(--color-primary);
|
||||
font-weight: 700;
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -16px;
|
||||
font-size: 20px;
|
||||
color: var(--color-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.chat-history__welcome-title {
|
||||
font-family: var(--font-heading);
|
||||
font-size: var(--font-size-display-lg);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-primary);
|
||||
letter-spacing: -0.02em;
|
||||
line-height: var(--line-height-tight);
|
||||
margin-bottom: 12px;
|
||||
font-family: var(--font-heading);
|
||||
font-size: var(--font-size-display-lg);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-primary);
|
||||
letter-spacing: -0.02em;
|
||||
line-height: var(--line-height-tight);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chat-history__welcome-subtitle {
|
||||
font-size: var(--font-size-body-md);
|
||||
color: var(--color-on-surface-variant);
|
||||
max-width: 440px;
|
||||
margin: 0 auto;
|
||||
line-height: var(--line-height-relaxed);
|
||||
font-size: var(--font-size-body-md);
|
||||
color: var(--color-on-surface-variant);
|
||||
max-width: 440px;
|
||||
margin: 0 auto;
|
||||
line-height: var(--line-height-relaxed);
|
||||
}
|
||||
|
||||
/* Bento Grid */
|
||||
.chat-history__bento {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
margin-bottom: 24px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.chat-history__bento {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
.chat-history__bento {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Bento Card */
|
||||
.chat-history__bento-card {
|
||||
position: relative;
|
||||
text-align: left;
|
||||
padding: 20px;
|
||||
border: var(--border-thin);
|
||||
border-radius: var(--radius-xl);
|
||||
background-color: var(--color-surface-lowest);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), transform var(--transition-fast);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 120px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
text-align: left;
|
||||
padding: 20px;
|
||||
border: var(--border-thin);
|
||||
border-radius: var(--radius-xl);
|
||||
background-color: var(--color-surface-lowest);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast),
|
||||
transform var(--transition-fast);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 120px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-history__bento-card:hover {
|
||||
border-color: var(--color-outline);
|
||||
box-shadow: var(--shadow-card-hover);
|
||||
border-color: var(--color-outline);
|
||||
box-shadow: var(--shadow-card-hover);
|
||||
}
|
||||
|
||||
.chat-history__bento-icon-wrap {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.chat-history__bento-icon-wrap .material-symbols-outlined {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chat-history__bento-text {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
line-height: 1.625;
|
||||
color: var(--color-on-surface);
|
||||
margin-bottom: 16px;
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
line-height: 1.625;
|
||||
color: var(--color-on-surface);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.chat-history__bento-text-main {
|
||||
color: var(--color-on-surface);
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
|
||||
.chat-history__bento-text-highlight {
|
||||
font-weight: 600;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chat-history__bento-arrow {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
color: var(--color-on-surface-variant);
|
||||
opacity: 0.5;
|
||||
transition: color var(--transition-fast), opacity var(--transition-fast);
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
color: var(--color-on-surface-variant);
|
||||
opacity: 0.5;
|
||||
transition:
|
||||
color var(--transition-fast),
|
||||
opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.chat-history__bento-card:hover .chat-history__bento-arrow {
|
||||
color: var(--color-primary);
|
||||
opacity: 1;
|
||||
color: var(--color-primary);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Info Panel */
|
||||
.chat-history__info {
|
||||
width: 100%;
|
||||
border: var(--border-thin);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
background-color: var(--color-surface-lowest);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
border: var(--border-thin);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
background-color: var(--color-surface-lowest);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-history__info-icon-wrap {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-surface-low);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: var(--border-thin);
|
||||
border-color: rgba(0, 0, 0, 0.05);
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-surface-low);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: var(--border-thin);
|
||||
border-color: rgba(0, 0, 0, 0.05);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-history__info-icon-wrap .material-symbols-outlined {
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
color: var(--color-on-surface);
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
|
||||
.chat-history__info-content {
|
||||
flex: 1;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chat-history__info-title {
|
||||
font-size: 15px;
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-primary);
|
||||
margin-bottom: 4px;
|
||||
font-size: 15px;
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.chat-history__info-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
color: var(--color-on-surface-variant);
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
color: var(--color-on-surface-variant);
|
||||
}
|
||||
|
||||
/* Message List */
|
||||
.chat-history__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 24px 0;
|
||||
width: 100%;
|
||||
max-width: var(--container-max);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
width: 100%;
|
||||
max-width: var(--container-max);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@@ -1,257 +1,194 @@
|
||||
/* ========================================
|
||||
ChatHistory Component — padhle (Refined Workspace)
|
||||
======================================== */
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import Message from "../message/Message";
|
||||
import SelectorFlow from "../selector-flow/SelectorFlow";
|
||||
import "./ChatHistory.css";
|
||||
|
||||
const suggestions = [
|
||||
{
|
||||
icon: "eco",
|
||||
filled: true,
|
||||
color: "--color-accent-green",
|
||||
background: "--color-accent-green-bg",
|
||||
before: "Explain how ",
|
||||
highlight: "photosynthesis",
|
||||
after: " works in simple terms",
|
||||
},
|
||||
{
|
||||
icon: "calculate",
|
||||
color: "--color-accent-blue",
|
||||
background: "--color-accent-blue-bg",
|
||||
before: "Help me understand ",
|
||||
highlight: "derivatives",
|
||||
after: " in calculus",
|
||||
},
|
||||
{
|
||||
icon: "account_balance",
|
||||
color: "--color-accent-orange",
|
||||
background: "--color-accent-orange-bg",
|
||||
before: "What caused the ",
|
||||
highlight: "Industrial",
|
||||
after: " Revolution?",
|
||||
},
|
||||
{
|
||||
icon: "edit",
|
||||
filled: true,
|
||||
color: "--color-accent-purple",
|
||||
background: "--color-accent-purple-bg",
|
||||
before: "Help me analyze themes in ",
|
||||
highlight: "Romeo and Juliet",
|
||||
after: "",
|
||||
},
|
||||
];
|
||||
|
||||
const ChatHistory = ({
|
||||
messages,
|
||||
activeSubject,
|
||||
activeGrade,
|
||||
activeChapter,
|
||||
onPromptClick,
|
||||
isTyping,
|
||||
selectorStep,
|
||||
selectorData,
|
||||
messages,
|
||||
onPromptClick,
|
||||
isTyping,
|
||||
selectorStep,
|
||||
selectorData,
|
||||
}) => {
|
||||
const hasMessages = messages.length > 0;
|
||||
const isSelector = selectorStep !== "chat";
|
||||
const historyRef = useRef(null);
|
||||
const autoFollowRef = useRef(true);
|
||||
const hasMessages = messages.length > 0;
|
||||
const isStreaming = messages.some((message) => message.streaming);
|
||||
const latestText = messages[messages.length - 1]?.text;
|
||||
|
||||
const suggestions = [
|
||||
{
|
||||
id: 1,
|
||||
icon: "eco",
|
||||
iconFilled: true,
|
||||
color: "--color-accent-green",
|
||||
colorBg: "--color-accent-green-bg",
|
||||
keywords: ["photosynthesis"],
|
||||
text: "Explain how ",
|
||||
textEnd: " works in simple terms",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
icon: "calculate",
|
||||
iconFilled: false,
|
||||
color: "--color-accent-blue",
|
||||
colorBg: "--color-accent-blue-bg",
|
||||
keywords: ["derivatives"],
|
||||
text: "Help me understand ",
|
||||
textEnd: " in calculus",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
icon: "account_balance",
|
||||
iconFilled: false,
|
||||
color: "--color-accent-orange",
|
||||
colorBg: "--color-accent-orange-bg",
|
||||
keywords: ["Industrial"],
|
||||
text: "What caused the ",
|
||||
textEnd: " Revolution?",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
icon: "edit",
|
||||
iconFilled: true,
|
||||
color: "--color-accent-purple",
|
||||
colorBg: "--color-accent-purple-bg",
|
||||
keywords: ["Romeo and Juliet"],
|
||||
text: "Help me analyze themes in ",
|
||||
textEnd: "",
|
||||
},
|
||||
];
|
||||
const scrollToBottom = () => {
|
||||
const history = historyRef.current;
|
||||
if (history) history.scrollTop = history.scrollHeight;
|
||||
};
|
||||
|
||||
const fullPromptTexts = suggestions.map(
|
||||
(s) => s.text + s.keywords.join(" ") + s.textEnd,
|
||||
);
|
||||
useEffect(() => {
|
||||
autoFollowRef.current = true;
|
||||
scrollToBottom();
|
||||
}, [messages.length]);
|
||||
|
||||
const streamingMsg = messages.find((m) => m.streaming);
|
||||
const historyRef = useRef(null);
|
||||
useEffect(() => {
|
||||
if (autoFollowRef.current) scrollToBottom();
|
||||
}, [latestText]);
|
||||
|
||||
// Scroll only the chat pane, and only when a message is added. Streaming
|
||||
// token updates must not move the page or restart a smooth-scroll animation.
|
||||
useEffect(() => {
|
||||
const history = historyRef.current;
|
||||
if (history) history.scrollTop = history.scrollHeight;
|
||||
}, [messages.length]);
|
||||
const handleScroll = () => {
|
||||
const history = historyRef.current;
|
||||
if (!history) return;
|
||||
autoFollowRef.current =
|
||||
history.scrollHeight - history.scrollTop - history.clientHeight < 80;
|
||||
};
|
||||
|
||||
/* ─── Messages mode ─── */
|
||||
if (hasMessages) {
|
||||
return (
|
||||
<div className="chat-history" ref={historyRef}>
|
||||
<div className="chat-history__list">
|
||||
{messages.map((message) => (
|
||||
<Message
|
||||
key={message.id}
|
||||
message={message}
|
||||
/>
|
||||
))}
|
||||
{isTyping && !streamingMsg && (
|
||||
<Message
|
||||
message={{
|
||||
id: "typing",
|
||||
role: "assistant",
|
||||
text: "",
|
||||
streaming: true,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (hasMessages) {
|
||||
return (
|
||||
<div className="chat-history" ref={historyRef} onScroll={handleScroll}>
|
||||
<div className="chat-history__list">
|
||||
{messages.map((message) => (
|
||||
<Message key={message.id} message={message} />
|
||||
))}
|
||||
{isTyping && !isStreaming && (
|
||||
<Message
|
||||
message={{
|
||||
id: "typing",
|
||||
role: "assistant",
|
||||
text: "",
|
||||
streaming: true,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Selector mode (grade → subject → chapter) ─── */
|
||||
if (isSelector && selectorData) {
|
||||
return (
|
||||
<div className="chat-history">
|
||||
<SelectorFlow
|
||||
title={selectorData.title}
|
||||
subtitle={selectorData.subtitle}
|
||||
selected={selectorData.selected}
|
||||
items={selectorData.items}
|
||||
onSelect={selectorData.onSelect}
|
||||
step={selectorStep}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (selectorStep !== "chat" && selectorData) {
|
||||
return (
|
||||
<div className="chat-history">
|
||||
<SelectorFlow {...selectorData} step={selectorStep} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Welcome / suggestions mode ─── */
|
||||
return (
|
||||
<div className="chat-history">
|
||||
<div className="chat-history__focus">
|
||||
{/* 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 (
|
||||
<div className="chat-history">
|
||||
<div className="chat-history__focus">
|
||||
<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>
|
||||
|
||||
{/* Bento Grid Suggestions */}
|
||||
<div className="chat-history__bento">
|
||||
{suggestions.map((suggestion) => {
|
||||
const colorVar =
|
||||
suggestion.color.replace(
|
||||
"--",
|
||||
"var(",
|
||||
);
|
||||
const bgColorVar =
|
||||
suggestion.colorBg.replace(
|
||||
"--",
|
||||
"var(",
|
||||
);
|
||||
<div className="chat-history__bento">
|
||||
{suggestions.map((suggestion) => {
|
||||
const color = `var(${suggestion.color})`;
|
||||
const prompt =
|
||||
suggestion.before + suggestion.highlight + suggestion.after;
|
||||
|
||||
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>
|
||||
return (
|
||||
<button
|
||||
key={suggestion.highlight}
|
||||
className="chat-history__bento-card"
|
||||
onClick={() => onPromptClick(prompt)}
|
||||
>
|
||||
<div
|
||||
className="chat-history__bento-icon-wrap"
|
||||
style={{ backgroundColor: `var(${suggestion.background})` }}
|
||||
>
|
||||
<span
|
||||
className="material-symbols-outlined"
|
||||
style={{
|
||||
color,
|
||||
fontWeight: suggestion.filled ? 700 : 200,
|
||||
}}
|
||||
>
|
||||
{suggestion.icon}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
<div className="chat-history__bento-text">
|
||||
<span className="chat-history__bento-text-main">
|
||||
{suggestion.before}
|
||||
</span>
|
||||
<span
|
||||
className="chat-history__bento-text-highlight"
|
||||
style={{ color, fontWeight: 600 }}
|
||||
>
|
||||
{suggestion.highlight}
|
||||
</span>
|
||||
{suggestion.after}
|
||||
</div>
|
||||
<span className="material-symbols-outlined chat-history__bento-arrow">
|
||||
arrow_forward
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<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;
|
||||
|
||||
@@ -4,223 +4,236 @@
|
||||
|
||||
/* Main Input Bar */
|
||||
.chat-input {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
/* Gradient fade from background */
|
||||
background: linear-gradient(to top, var(--color-background) 50%, transparent);
|
||||
padding-top: 24px;
|
||||
padding-bottom: 32px;
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
/* Gradient fade from background */
|
||||
background: linear-gradient(to top, var(--color-background) 50%, transparent);
|
||||
padding-top: 24px;
|
||||
padding-bottom: 32px;
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Container — centered content */
|
||||
.chat-input__container {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
/* Form */
|
||||
.chat-input__form {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border: var(--border-thin);
|
||||
border-radius: 32px;
|
||||
background-color: var(--color-surface-lowest);
|
||||
box-shadow: var(--shadow-input);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border: var(--border-thin);
|
||||
border-radius: 32px;
|
||||
background-color: var(--color-surface-lowest);
|
||||
box-shadow: var(--shadow-input);
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.chat-input__form:focus-within {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--shadow-input-focus);
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--shadow-input-focus);
|
||||
}
|
||||
|
||||
/* Attachment Button */
|
||||
.chat-input__attach-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: var(--border-thin);
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-surface-lowest);
|
||||
cursor: pointer;
|
||||
color: var(--color-on-surface-variant);
|
||||
transition: background-color var(--transition-fast), color var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: var(--border-thin);
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-surface-lowest);
|
||||
cursor: pointer;
|
||||
color: var(--color-on-surface-variant);
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-input__attach-btn:hover {
|
||||
background-color: var(--color-surface-low);
|
||||
color: var(--color-primary);
|
||||
background-color: var(--color-surface-low);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.chat-input__attach-btn .material-symbols-outlined {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Textarea */
|
||||
.chat-input__field {
|
||||
flex: 1;
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: 1.5;
|
||||
color: var(--color-primary);
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
padding: 8px 4px;
|
||||
resize: none;
|
||||
outline: none;
|
||||
min-height: 44px;
|
||||
max-height: 128px;
|
||||
flex: 1;
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: 1.5;
|
||||
color: var(--color-primary);
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
padding: 8px 4px;
|
||||
resize: none;
|
||||
outline: none;
|
||||
min-height: 44px;
|
||||
max-height: 128px;
|
||||
}
|
||||
|
||||
.chat-input__field:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.chat-input__field::placeholder {
|
||||
color: var(--color-outline);
|
||||
font-weight: var(--font-weight-regular);
|
||||
color: var(--color-outline);
|
||||
font-weight: var(--font-weight-regular);
|
||||
}
|
||||
|
||||
.chat-input__field:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Actions Container */
|
||||
.chat-input__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Mic Button */
|
||||
.chat-input__mic-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--color-on-surface-variant);
|
||||
transition: background-color var(--transition-fast), color var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--color-on-surface-variant);
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-input__mic-btn:hover {
|
||||
background-color: var(--color-surface-low);
|
||||
color: var(--color-primary);
|
||||
background-color: var(--color-surface-low);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.chat-input__mic-btn .material-symbols-outlined {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Send Button */
|
||||
.chat-input__send-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
cursor: not-allowed;
|
||||
color: var(--color-on-surface-variant);
|
||||
opacity: 0.3;
|
||||
transition: opacity var(--transition-fast), background-color var(--transition-fast), color var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
cursor: not-allowed;
|
||||
color: var(--color-on-surface-variant);
|
||||
opacity: 0.3;
|
||||
transition:
|
||||
opacity var(--transition-fast),
|
||||
background-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-input__send-btn--active {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-on-primary);
|
||||
cursor: pointer;
|
||||
opacity: 1;
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-on-primary);
|
||||
cursor: pointer;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chat-input__send-btn--active:hover {
|
||||
opacity: 0.9;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.chat-input__send-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.chat-input__send-btn .material-symbols-outlined {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Disclaimer */
|
||||
.chat-input__disclaimer {
|
||||
font-size: var(--font-size-label-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-on-surface-variant);
|
||||
text-align: center;
|
||||
margin-top: 12px;
|
||||
font-size: var(--font-size-label-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-on-surface-variant);
|
||||
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);
|
||||
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;
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
font-size: 15px;
|
||||
line-height: var(--line-height-relaxed);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font-weight: var(--font-weight-regular);
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
@@ -93,9 +94,15 @@
|
||||
.message__text.markdown-body {
|
||||
white-space: normal;
|
||||
}
|
||||
.markdown-body > *:first-child { margin-top: 0; }
|
||||
.markdown-body > *:last-child { margin-bottom: 0; }
|
||||
.markdown-body p { margin: 0 0 12px; }
|
||||
.markdown-body > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.markdown-body > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.markdown-body p {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.markdown-body h1,
|
||||
.markdown-body h2,
|
||||
.markdown-body h3,
|
||||
@@ -105,16 +112,26 @@
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.markdown-body h1 { font-size: 22px; }
|
||||
.markdown-body h2 { font-size: 19px; }
|
||||
.markdown-body h3 { font-size: 16px; }
|
||||
.markdown-body h4 { font-size: 15px; }
|
||||
.markdown-body h1 {
|
||||
font-size: 22px;
|
||||
}
|
||||
.markdown-body h2 {
|
||||
font-size: 19px;
|
||||
}
|
||||
.markdown-body h3 {
|
||||
font-size: 16px;
|
||||
}
|
||||
.markdown-body h4 {
|
||||
font-size: 15px;
|
||||
}
|
||||
.markdown-body ul,
|
||||
.markdown-body ol {
|
||||
margin: 0 0 12px;
|
||||
padding-left: 22px;
|
||||
}
|
||||
.markdown-body li { margin-bottom: 4px; }
|
||||
.markdown-body li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.markdown-body blockquote {
|
||||
margin: 0 0 12px;
|
||||
padding: 6px 14px;
|
||||
@@ -169,7 +186,9 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
/* KaTeX math inherits font size */
|
||||
.markdown-body .katex { font-size: 1.05em; }
|
||||
.markdown-body .katex {
|
||||
font-size: 1.05em;
|
||||
}
|
||||
|
||||
/* --- ACTION BUTTONS --- */
|
||||
.message__actions {
|
||||
@@ -191,7 +210,9 @@
|
||||
background-color: var(--color-surface-low);
|
||||
cursor: pointer;
|
||||
color: var(--color-on-surface-variant);
|
||||
transition: background-color var(--transition-fast), color var(--transition-fast);
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.message__action:hover {
|
||||
@@ -244,7 +265,9 @@
|
||||
}
|
||||
|
||||
@keyframes typingBounce {
|
||||
0%, 80%, 100% {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: scale(0.6);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
export const DEFAULTS = {
|
||||
grade: "Choose standard",
|
||||
subject: "choose-subject",
|
||||
chapter: "choose-chapter",
|
||||
};
|
||||
|
||||
export const gradeItems = [
|
||||
{ id: "grade-6", label: "Grade 6", icon: "backpack" },
|
||||
{ id: "grade-7", label: "Grade 7", icon: "auto_stories" },
|
||||
{ id: "grade-8", label: "Grade 8", icon: "menu_book" },
|
||||
{ id: "grade-9", label: "Grade 9", icon: "science" },
|
||||
{ id: "grade-10", label: "Grade 10", icon: "calculate" },
|
||||
{ id: "grade-11", label: "Grade 11", icon: "psychology" },
|
||||
{ id: "grade-12", label: "Grade 12", icon: "school" },
|
||||
];
|
||||
|
||||
export const subjectItems = [
|
||||
{ id: "math", label: "Mathematics" },
|
||||
{ id: "science", label: "Science" },
|
||||
{ id: "history", label: "History" },
|
||||
{ id: "language", label: "Language Arts" },
|
||||
{ id: "english", label: "English" },
|
||||
{ id: "geography", label: "Geography" },
|
||||
];
|
||||
|
||||
export const chapterData = {
|
||||
math: [
|
||||
{ id: "ch-1", label: "Chapter 1: Rational Numbers" },
|
||||
{ id: "ch-2", label: "Chapter 2: Linear Equations" },
|
||||
{ id: "ch-3", label: "Chapter 3: Coordinate Geometry" },
|
||||
{ id: "ch-4", label: "Chapter 4: Algebra" },
|
||||
{ id: "ch-5", label: "Chapter 5: Geometry" },
|
||||
{ id: "ch-6", label: "Chapter 6: Trigonometry" },
|
||||
],
|
||||
science: [
|
||||
{ id: "ch-1", label: "Chapter 1: Nutrition in Plants" },
|
||||
{ id: "ch-2", label: "Chapter 2: Photosynthesis" },
|
||||
{ id: "ch-3", label: "Chapter 3: Human Physiology" },
|
||||
{ id: "ch-4", label: "Chapter 4: Chemistry Basics" },
|
||||
{ id: "ch-5", label: "Chapter 5: Motion & Force" },
|
||||
{ id: "ch-6", label: "Chapter 6: Electricity" },
|
||||
],
|
||||
history: [
|
||||
{ id: "ch-1", label: "Chapter 1: Early Civilizations" },
|
||||
{ id: "ch-2", label: "Chapter 2: Ancient India" },
|
||||
{ id: "ch-3", label: "Chapter 3: Medieval Period" },
|
||||
{ id: "ch-4", label: "Chapter 4: Modern India" },
|
||||
{ id: "ch-5", label: "Chapter 5: World Wars" },
|
||||
{ id: "ch-6", label: "Chapter 6: Independence Movement" },
|
||||
],
|
||||
language: [
|
||||
{ id: "ch-1", label: "Chapter 1: Grammar Basics" },
|
||||
{ id: "ch-2", label: "Chapter 2: Comprehension" },
|
||||
{ id: "ch-3", label: "Chapter 3: Creative Writing" },
|
||||
{ id: "ch-4", label: "Chapter 4: Literature" },
|
||||
{ id: "ch-5", label: "Chapter 5: Vocabulary" },
|
||||
{ id: "ch-6", label: "Chapter 6: Composition" },
|
||||
],
|
||||
english: [
|
||||
{ id: "ch-1", label: "Chapter 1: Reading Skills" },
|
||||
{ id: "ch-2", label: "Chapter 2: Writing Skills" },
|
||||
{ id: "ch-3", label: "Chapter 3: Grammar" },
|
||||
{ id: "ch-4", label: "Chapter 4: Poetry" },
|
||||
{ id: "ch-5", label: "Chapter 5: Prose" },
|
||||
{ id: "ch-6", label: "Chapter 6: Drama" },
|
||||
],
|
||||
geography: [
|
||||
{ id: "ch-1", label: "Chapter 1: Earth & Universe" },
|
||||
{ id: "ch-2", label: "Chapter 2: Landforms" },
|
||||
{ id: "ch-3", label: "Chapter 3: Climate & Weather" },
|
||||
{ id: "ch-4", label: "Chapter 4: Natural Resources" },
|
||||
{ id: "ch-5", label: "Chapter 5: Maps & Atlas" },
|
||||
{ id: "ch-6", label: "Chapter 6: Human Geography" },
|
||||
],
|
||||
};
|
||||
|
||||
export const getSubjectLabel = (id) =>
|
||||
!id || id === DEFAULTS.subject
|
||||
? "Choose Subject"
|
||||
: subjectItems.find((subject) => subject.id === id)?.label || id;
|
||||
|
||||
export const getChapterLabel = (chapter) =>
|
||||
!chapter || chapter === DEFAULTS.chapter ? "Choose Chapter" : chapter;
|
||||
@@ -0,0 +1,15 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
chapterData,
|
||||
DEFAULTS,
|
||||
getChapterLabel,
|
||||
getSubjectLabel,
|
||||
} from "./curriculum.js";
|
||||
|
||||
test("resolves curriculum labels and defaults", () => {
|
||||
assert.equal(getSubjectLabel("science"), "Science");
|
||||
assert.equal(getSubjectLabel(DEFAULTS.subject), "Choose Subject");
|
||||
assert.equal(getChapterLabel(DEFAULTS.chapter), "Choose Chapter");
|
||||
assert.ok(chapterData.science.length > 0);
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { chatApi } from "../lib/chatApi";
|
||||
import { readSseData } from "../lib/readSseData";
|
||||
import { DEFAULTS } from "../data/curriculum";
|
||||
|
||||
const errorMessage = (text) => ({
|
||||
id: crypto.randomUUID(),
|
||||
role: "assistant",
|
||||
text: `⚠️ ${text}`,
|
||||
});
|
||||
|
||||
export function useChat({
|
||||
isAuthenticated,
|
||||
routeChatId,
|
||||
navigate,
|
||||
onSignInRequired,
|
||||
}) {
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [activeChat, setActiveChat] = useState(null);
|
||||
const [activeGrade, setActiveGrade] = useState(DEFAULTS.grade);
|
||||
const [activeSubject, setActiveSubject] = useState(DEFAULTS.subject);
|
||||
const [activeChapter, setActiveChapter] = useState(DEFAULTS.chapter);
|
||||
const [selectorStep, setSelectorStep] = useState("grade");
|
||||
const [sessions, setSessions] = useState([]);
|
||||
const [isLoadingSessions, setIsLoadingSessions] = useState(false);
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const [anonRemaining, setAnonRemaining] = useState(5);
|
||||
|
||||
const requestRef = useRef(null);
|
||||
const handledRouteRef = useRef(null);
|
||||
const mountedRef = useRef(false);
|
||||
|
||||
const abortRequest = useCallback(() => {
|
||||
requestRef.current?.abort();
|
||||
requestRef.current = null;
|
||||
}, []);
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
if (!isAuthenticated) return;
|
||||
setIsLoadingSessions(true);
|
||||
try {
|
||||
const response = await chatApi.listSessions();
|
||||
if (response.ok) setSessions(await response.json());
|
||||
} catch (error) {
|
||||
console.error("Failed to load sessions:", error);
|
||||
} finally {
|
||||
setIsLoadingSessions(false);
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const resetChat = useCallback(() => {
|
||||
abortRequest();
|
||||
setMessages([]);
|
||||
setActiveChat(null);
|
||||
setActiveGrade(DEFAULTS.grade);
|
||||
setActiveSubject(DEFAULTS.subject);
|
||||
setActiveChapter(DEFAULTS.chapter);
|
||||
setSelectorStep("grade");
|
||||
setIsTyping(false);
|
||||
}, [abortRequest]);
|
||||
|
||||
const newChat = useCallback(() => {
|
||||
resetChat();
|
||||
handledRouteRef.current = null;
|
||||
navigate("/");
|
||||
loadSessions();
|
||||
}, [loadSessions, navigate, resetChat]);
|
||||
|
||||
const selectChat = useCallback(
|
||||
async (sessionId) => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
abortRequest();
|
||||
const controller = new AbortController();
|
||||
requestRef.current = controller;
|
||||
setIsTyping(true);
|
||||
setMessages([]);
|
||||
|
||||
try {
|
||||
const response = await chatApi.getSession(sessionId, controller.signal);
|
||||
if (!response.ok) throw new Error("Session not found");
|
||||
|
||||
const data = await response.json();
|
||||
const session = data.session || {};
|
||||
setActiveGrade(session.grade || DEFAULTS.grade);
|
||||
setActiveSubject(session.subject || DEFAULTS.subject);
|
||||
setActiveChapter(session.chapter || DEFAULTS.chapter);
|
||||
setMessages(
|
||||
(data.messages || []).map((message) => ({
|
||||
...message,
|
||||
streaming: false,
|
||||
})),
|
||||
);
|
||||
setSelectorStep("chat");
|
||||
setActiveChat(sessionId);
|
||||
handledRouteRef.current = sessionId;
|
||||
navigate(`/chat/${encodeURIComponent(sessionId)}`, { replace: true });
|
||||
} catch (error) {
|
||||
if (error.name !== "AbortError") {
|
||||
console.error("Failed to load session:", error);
|
||||
setMessages([
|
||||
errorMessage("Could not load that chat. It may have been deleted."),
|
||||
]);
|
||||
}
|
||||
} finally {
|
||||
if (requestRef.current === controller) {
|
||||
requestRef.current = null;
|
||||
setIsTyping(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[abortRequest, isAuthenticated, navigate],
|
||||
);
|
||||
|
||||
const deleteChat = useCallback(
|
||||
async (sessionId) => {
|
||||
try {
|
||||
const response = await chatApi.deleteSession(sessionId);
|
||||
if (!response.ok) return;
|
||||
if (activeChat === sessionId) newChat();
|
||||
else loadSessions();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete session:", error);
|
||||
}
|
||||
},
|
||||
[activeChat, loadSessions, newChat],
|
||||
);
|
||||
|
||||
const send = useCallback(
|
||||
async (value) => {
|
||||
const text = value.trim();
|
||||
if (!text || isTyping) return;
|
||||
if (!isAuthenticated && anonRemaining <= 0) {
|
||||
onSignInRequired();
|
||||
return;
|
||||
}
|
||||
|
||||
abortRequest();
|
||||
const controller = new AbortController();
|
||||
requestRef.current = controller;
|
||||
const assistantId = crypto.randomUUID();
|
||||
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
{ id: crypto.randomUUID(), role: "user", text },
|
||||
{ id: assistantId, role: "assistant", text: "", streaming: true },
|
||||
]);
|
||||
setIsTyping(true);
|
||||
|
||||
try {
|
||||
const response = await chatApi.send(
|
||||
{
|
||||
text,
|
||||
chatId: activeChat,
|
||||
grade: activeGrade === DEFAULTS.grade ? undefined : activeGrade,
|
||||
subject:
|
||||
activeSubject === DEFAULTS.subject ? undefined : activeSubject,
|
||||
chapter:
|
||||
activeChapter === DEFAULTS.chapter ? undefined : activeChapter,
|
||||
},
|
||||
controller.signal,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => null);
|
||||
if (response.status === 403 && data?.code === "SIGNIN_REQUIRED") {
|
||||
setAnonRemaining(0);
|
||||
onSignInRequired();
|
||||
return;
|
||||
}
|
||||
throw new Error(`Server error: ${response.status}`);
|
||||
}
|
||||
|
||||
let assistantText = "";
|
||||
for await (const event of readSseData(response.body)) {
|
||||
if (event.type === "session") {
|
||||
setActiveChat(event.id);
|
||||
if (isAuthenticated) {
|
||||
handledRouteRef.current = event.id;
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
"",
|
||||
`/chat/${encodeURIComponent(event.id)}`,
|
||||
);
|
||||
}
|
||||
} else if (event.type === "limit") {
|
||||
setAnonRemaining(event.remaining);
|
||||
} else if (event.type === "chunk") {
|
||||
assistantText += event.content;
|
||||
setMessages((current) =>
|
||||
current.map((message) =>
|
||||
message.id === assistantId
|
||||
? { ...message, text: assistantText }
|
||||
: message,
|
||||
),
|
||||
);
|
||||
} else if (event.type === "error") {
|
||||
assistantText = `⚠️ Error: ${event.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
setMessages((current) =>
|
||||
current.map((message) =>
|
||||
message.id === assistantId
|
||||
? { ...message, text: assistantText, streaming: false }
|
||||
: message,
|
||||
),
|
||||
);
|
||||
if (isAuthenticated) loadSessions();
|
||||
} catch (error) {
|
||||
if (error.name !== "AbortError") {
|
||||
setMessages((current) =>
|
||||
current.map((message) =>
|
||||
message.id === assistantId
|
||||
? {
|
||||
...message,
|
||||
text: `⚠️ Connection error: ${error.message}. Make sure the backend is running on port 3001.`,
|
||||
streaming: false,
|
||||
}
|
||||
: message,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (requestRef.current === controller) {
|
||||
requestRef.current = null;
|
||||
setIsTyping(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
abortRequest,
|
||||
activeChat,
|
||||
activeChapter,
|
||||
activeGrade,
|
||||
activeSubject,
|
||||
anonRemaining,
|
||||
isAuthenticated,
|
||||
isTyping,
|
||||
loadSessions,
|
||||
onSignInRequired,
|
||||
],
|
||||
);
|
||||
|
||||
const addError = useCallback((message) => {
|
||||
setMessages((current) => [...current, errorMessage(message)]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mountedRef.current) {
|
||||
mountedRef.current = true;
|
||||
loadSessions();
|
||||
return;
|
||||
}
|
||||
|
||||
resetChat();
|
||||
setSessions([]);
|
||||
setAnonRemaining(5);
|
||||
loadSessions();
|
||||
|
||||
if (handledRouteRef.current) {
|
||||
handledRouteRef.current = null;
|
||||
navigate("/");
|
||||
}
|
||||
}, [isAuthenticated, loadSessions, navigate, resetChat]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
routeChatId &&
|
||||
isAuthenticated &&
|
||||
handledRouteRef.current !== routeChatId
|
||||
) {
|
||||
handledRouteRef.current = routeChatId;
|
||||
selectChat(routeChatId);
|
||||
}
|
||||
}, [isAuthenticated, routeChatId, selectChat]);
|
||||
|
||||
useEffect(() => abortRequest, [abortRequest]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
activeChat,
|
||||
activeGrade,
|
||||
activeSubject,
|
||||
activeChapter,
|
||||
selectorStep,
|
||||
sessions,
|
||||
isLoadingSessions,
|
||||
isTyping,
|
||||
anonRemaining,
|
||||
setActiveGrade,
|
||||
setActiveSubject,
|
||||
setActiveChapter,
|
||||
setSelectorStep,
|
||||
addError,
|
||||
newChat,
|
||||
selectChat,
|
||||
deleteChat,
|
||||
send,
|
||||
};
|
||||
}
|
||||
@@ -36,21 +36,9 @@ export function AuthProvider({ children }) {
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
const getToken = async () => {
|
||||
// Token lives in httpOnly cookie — not accessible to JavaScript
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
isAuthenticated: !!user,
|
||||
signIn,
|
||||
signUp,
|
||||
signOut,
|
||||
getToken,
|
||||
}}
|
||||
value={{ user, isAuthenticated: !!user, signIn, signUp, signOut }}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
|
||||
@@ -76,13 +76,4 @@ export const auth = {
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if user is authenticated.
|
||||
* Backend verifies the httpOnly cookie.
|
||||
*/
|
||||
async check() {
|
||||
const { user } = await auth.me();
|
||||
return !!user;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
const request = (path, options) =>
|
||||
fetch(path, { credentials: "include", ...options });
|
||||
|
||||
export const chatApi = {
|
||||
listSessions: () => request("/api/sessions"),
|
||||
getSession: (id, signal) =>
|
||||
request(`/api/chat/${encodeURIComponent(id)}`, { signal }),
|
||||
deleteSession: (id) =>
|
||||
request(`/api/sessions/${encodeURIComponent(id)}`, { method: "DELETE" }),
|
||||
send: (body, signal) =>
|
||||
request("/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
export async function* readSseData(body) {
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
buffer += decoder.decode(value, { stream: !done });
|
||||
|
||||
const lines = buffer.split("\n");
|
||||
buffer = done ? "" : lines.pop() || "";
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trimEnd();
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
|
||||
const value = line.slice(6);
|
||||
if (value === "[DONE]") continue;
|
||||
|
||||
try {
|
||||
yield JSON.parse(value);
|
||||
} catch {
|
||||
// Ignore malformed frames; one bad event must not end the stream.
|
||||
}
|
||||
}
|
||||
|
||||
if (done) return;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readSseData } from "./readSseData.js";
|
||||
|
||||
const streamFrom = (chunks) =>
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
for (const chunk of chunks)
|
||||
controller.enqueue(new TextEncoder().encode(chunk));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
test("reads fragmented SSE data and ignores invalid frames", async () => {
|
||||
const body = streamFrom([
|
||||
'data: {"type":"sess',
|
||||
'ion","id":"abc"}\n',
|
||||
"event: ignored\n",
|
||||
"data: not-json\n",
|
||||
'data: {"type":"chunk","content":"Hi"}\n',
|
||||
"data: [DONE]\n",
|
||||
]);
|
||||
|
||||
const events = [];
|
||||
for await (const event of readSseData(body)) events.push(event);
|
||||
|
||||
assert.deepEqual(events, [
|
||||
{ type: "session", id: "abc" },
|
||||
{ type: "chunk", content: "Hi" },
|
||||
]);
|
||||
});
|
||||
+127
-115
@@ -6,177 +6,189 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Root Variables — Material Design 3 tokens from EduAI */
|
||||
:root {
|
||||
/* Surface Colors */
|
||||
--color-background: #fcf8fa;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-lowest: #ffffff;
|
||||
--color-surface-low: #f9fafb;
|
||||
--color-surface-container: #f3f4f6;
|
||||
--color-surface-high: #e5e7eb;
|
||||
/* Surface Colors */
|
||||
--color-background: #fcf8fa;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-lowest: #ffffff;
|
||||
--color-surface-low: #f9fafb;
|
||||
--color-surface-container: #f3f4f6;
|
||||
--color-surface-high: #e5e7eb;
|
||||
|
||||
/* Outline Colors */
|
||||
--color-outline: #d1d5db;
|
||||
--color-outline-variant: #e5e5e5;
|
||||
/* Outline Colors */
|
||||
--color-outline: #d1d5db;
|
||||
--color-outline-variant: #e5e5e5;
|
||||
|
||||
/* Text Colors */
|
||||
--color-on-surface: #111827;
|
||||
--color-on-surface-variant: #6b7280;
|
||||
--color-primary: #000000;
|
||||
--color-on-primary: #ffffff;
|
||||
--color-primary-fixed: #dce2f7;
|
||||
--color-primary-container: #141b2b;
|
||||
/* Text Colors */
|
||||
--color-on-surface: #111827;
|
||||
--color-on-surface-variant: #6b7280;
|
||||
--color-primary: #000000;
|
||||
--color-on-primary: #ffffff;
|
||||
--color-primary-fixed: #dce2f7;
|
||||
--color-primary-container: #141b2b;
|
||||
|
||||
/* Accent Colors for Suggestion Cards */
|
||||
--color-accent-green: #2e7d32;
|
||||
--color-accent-green-bg: #e8f5e9;
|
||||
--color-accent-blue: #1565c0;
|
||||
--color-accent-blue-bg: #e3f2fd;
|
||||
--color-accent-orange: #e65100;
|
||||
--color-accent-orange-bg: #fff3e0;
|
||||
--color-accent-purple: #6a1b9a;
|
||||
--color-accent-purple-bg: #f3e5f5;
|
||||
/* Accent Colors for Suggestion Cards */
|
||||
--color-accent-green: #2e7d32;
|
||||
--color-accent-green-bg: #e8f5e9;
|
||||
--color-accent-blue: #1565c0;
|
||||
--color-accent-blue-bg: #e3f2fd;
|
||||
--color-accent-orange: #e65100;
|
||||
--color-accent-orange-bg: #fff3e0;
|
||||
--color-accent-purple: #6a1b9a;
|
||||
--color-accent-purple-bg: #f3e5f5;
|
||||
|
||||
/* Fonts */
|
||||
--font-heading: "Montserrat", sans-serif;
|
||||
--font-body: "Inter", system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", "Fira Code", monospace;
|
||||
/* Fonts */
|
||||
--font-heading: "Montserrat", sans-serif;
|
||||
--font-body: "Inter", system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", "Fira Code", monospace;
|
||||
|
||||
/* Font Sizes */
|
||||
--font-size-body-md: 16px;
|
||||
--font-size-body-lg: 18px;
|
||||
--font-size-headline-md: 24px;
|
||||
--font-size-headline-sm: 20px;
|
||||
--font-size-label-md: 14px;
|
||||
--font-size-label-sm: 12px;
|
||||
--font-size-label-xs: 11px;
|
||||
--font-size-display-lg: 40px;
|
||||
/* Font Sizes */
|
||||
--font-size-body-md: 16px;
|
||||
--font-size-body-lg: 18px;
|
||||
--font-size-headline-md: 24px;
|
||||
--font-size-headline-sm: 20px;
|
||||
--font-size-label-md: 14px;
|
||||
--font-size-label-sm: 12px;
|
||||
--font-size-label-xs: 11px;
|
||||
--font-size-display-lg: 40px;
|
||||
|
||||
/* Font Weights */
|
||||
--font-weight-regular: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
/* Font Weights */
|
||||
--font-weight-regular: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
|
||||
/* Line Heights */
|
||||
--line-height-tight: 1.25;
|
||||
--line-height-normal: 1.5;
|
||||
--line-height-relaxed: 1.625;
|
||||
/* Line Heights */
|
||||
--line-height-tight: 1.25;
|
||||
--line-height-normal: 1.5;
|
||||
--line-height-relaxed: 1.625;
|
||||
|
||||
/* Border */
|
||||
--border-thin: 1px solid var(--color-outline);
|
||||
--border-regular: 1.5px solid var(--color-outline);
|
||||
--border-thick: 2px solid var(--color-outline);
|
||||
/* Border */
|
||||
--border-thin: 1px solid var(--color-outline);
|
||||
--border-regular: 1.5px solid var(--color-outline);
|
||||
--border-thick: 2px solid var(--color-outline);
|
||||
|
||||
/* Border Radius */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
--radius-xl: 20px;
|
||||
--radius-pill: 9999px;
|
||||
/* Border Radius */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
--radius-xl: 20px;
|
||||
--radius-pill: 9999px;
|
||||
|
||||
/* Spacing */
|
||||
--spacing-xs: 4px;
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 16px;
|
||||
--spacing-lg: 24px;
|
||||
--spacing-xl: 32px;
|
||||
--spacing-2xl: 48px;
|
||||
/* Spacing */
|
||||
--spacing-xs: 4px;
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 16px;
|
||||
--spacing-lg: 24px;
|
||||
--spacing-xl: 32px;
|
||||
--spacing-2xl: 48px;
|
||||
|
||||
/* Transition */
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-normal: 200ms ease;
|
||||
--transition-slow: 300ms ease;
|
||||
/* Transition */
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-normal: 200ms ease;
|
||||
--transition-slow: 300ms ease;
|
||||
|
||||
/* Layout */
|
||||
--sidebar-width: 280px;
|
||||
--container-max: 800px;
|
||||
--content-padding: 24px;
|
||||
/* Layout */
|
||||
--sidebar-width: 280px;
|
||||
--container-max: 800px;
|
||||
--content-padding: 24px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-card: 0 1px 3px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-card-hover: 0 2px 8px 0 rgba(0, 0, 0, 0.08);
|
||||
--shadow-input: 0 2px 8px 0 rgba(0, 0, 0, 0.04);
|
||||
--shadow-input-focus: 0 2px 12px 0 rgba(0, 0, 0, 0.08);
|
||||
/* Shadows */
|
||||
--shadow-card: 0 1px 3px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-card-hover: 0 2px 8px 0 rgba(0, 0, 0, 0.08);
|
||||
--shadow-input: 0 2px 8px 0 rgba(0, 0, 0, 0.04);
|
||||
--shadow-input-focus: 0 2px 12px 0 rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* App viewport — the shell owns scrolling, never the document.
|
||||
A definite height here is what lets .chat-history become a real
|
||||
scroll container instead of growing the page. */
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Body */
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
font-weight: var(--font-weight-regular);
|
||||
font-size: var(--font-size-body-md);
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-on-surface);
|
||||
line-height: var(--line-height-normal);
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-family: var(--font-body);
|
||||
font-weight: var(--font-weight-regular);
|
||||
font-size: var(--font-size-body-md);
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-on-surface);
|
||||
line-height: var(--line-height-normal);
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Page Frame — sidebar + main layout */
|
||||
.page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Main Content Area */
|
||||
.page__main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-left: var(--sidebar-width);
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-left: var(--sidebar-width);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Main Content Wrapper (scrollable chat area) */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
background-color: var(--color-background);
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
|
||||
/* Container — centered content */
|
||||
.container {
|
||||
max-width: var(--container-max);
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
max-width: var(--container-max);
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: var(--color-outline-variant);
|
||||
border-radius: 3px;
|
||||
background-color: var(--color-outline-variant);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--color-outline);
|
||||
background-color: var(--color-outline);
|
||||
}
|
||||
|
||||
/* Focus outline */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
background-color: var(--color-primary-fixed);
|
||||
color: var(--color-primary-container);
|
||||
background-color: var(--color-primary-fixed);
|
||||
color: var(--color-primary-container);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"dev:frontend": "cd frontend && vite",
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
"build": "cd frontend && vite build",
|
||||
"test": "npm --prefix frontend test",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
Reference in New Issue
Block a user