added: grade, subject and chapter in new chat, fixed that shitty card bug
This commit is contained in:
@@ -27,3 +27,6 @@ build
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
|
||||
# LLM memory / documentation
|
||||
markdown/
|
||||
|
||||
+281
-24
@@ -1,48 +1,299 @@
|
||||
/* ========================================
|
||||
App Component — padhle (Refined Workspace)
|
||||
App Component — padhle (Backend-Connected)
|
||||
======================================== */
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
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 SelectorFlow from "./components/selector-flow/SelectorFlow";
|
||||
|
||||
const API_BASE = ""; // Uses Vite proxy → localhost:3001 in dev
|
||||
|
||||
// Default selectors
|
||||
const DEFAULTS = {
|
||||
grade: "Choose standard",
|
||||
subject: "Choose Subject",
|
||||
chapter: "Choose Chapter",
|
||||
};
|
||||
|
||||
function App() {
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [activeSubject, setActiveSubject] = useState("Choose Subject");
|
||||
const [activeSubject, setActiveSubject] = useState(DEFAULTS.subject);
|
||||
const [activeChat, setActiveChat] = useState(null);
|
||||
const [activeGrade, setActiveGrade] = useState("Choose standard");
|
||||
const [activeChapter, setActiveChapter] = useState("Choose Chapter");
|
||||
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 [showSelectorFlow, setShowSelectorFlow] = useState(false);
|
||||
const abortRef = useRef(null);
|
||||
|
||||
const handleSend = (text) => {
|
||||
if (!text.trim()) return;
|
||||
// Load sessions on mount
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
}, []);
|
||||
|
||||
setMessages((prev) => [...prev, { id: Date.now(), role: "user", text }]);
|
||||
|
||||
setTimeout(() => {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: Date.now() + 1,
|
||||
role: "assistant",
|
||||
text: "This is a simulated response. Connect the backend to get real AI answers!",
|
||||
},
|
||||
]);
|
||||
}, 1000);
|
||||
const loadSessions = async () => {
|
||||
setIsLoadingSessions(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/sessions`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSessions(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load sessions:", err);
|
||||
} finally {
|
||||
setIsLoadingSessions(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePromptClick = (promptText) => {
|
||||
handleSend(promptText);
|
||||
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);
|
||||
setActiveChapter(DEFAULTS.chapter);
|
||||
setIsTyping(false);
|
||||
|
||||
// Show the selector flow
|
||||
setShowSelectorFlow(true);
|
||||
|
||||
// Refresh sessions list from backend
|
||||
loadSessions();
|
||||
};
|
||||
|
||||
const handleChatSelect = async (sessionId) => {
|
||||
// Abort any in-flight request
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
abortRef.current = null;
|
||||
}
|
||||
|
||||
setIsTyping(true);
|
||||
setMessages([]);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/chat/${sessionId}`);
|
||||
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);
|
||||
}
|
||||
|
||||
// Hide selector flow when loading an existing chat
|
||||
setShowSelectorFlow(false);
|
||||
|
||||
// Load the messages
|
||||
if (data.messages) {
|
||||
setMessages(data.messages.map((m) => ({ ...m, streaming: false })));
|
||||
}
|
||||
|
||||
setActiveChat(sessionId);
|
||||
} 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 handleDeleteChat = async (sessionId) => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/sessions/${sessionId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
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(
|
||||
(text) => {
|
||||
if (!text.trim() || isTyping) 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 ? activeGrade : undefined,
|
||||
subject: activeSubject !== DEFAULTS.subject ? activeSubject : undefined,
|
||||
chapter: activeChapter !== DEFAULTS.chapter ? activeChapter : undefined,
|
||||
};
|
||||
|
||||
// Open SSE connection
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
fetch(`${API_BASE}/api/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
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 === "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 sessions to update timestamps
|
||||
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]
|
||||
);
|
||||
|
||||
const handlePromptClick = useCallback(
|
||||
(promptText) => {
|
||||
handleSend(promptText);
|
||||
},
|
||||
[handleSend]
|
||||
);
|
||||
|
||||
const handleGradeChange = (gradeLabel) => {
|
||||
setActiveGrade(gradeLabel);
|
||||
};
|
||||
|
||||
const handleSubjectChange = (subjectId) => {
|
||||
setActiveSubject(subjectId);
|
||||
};
|
||||
|
||||
const handleChapterChange = (chapterLabel) => {
|
||||
setActiveChapter(chapterLabel);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<Sidebar
|
||||
activeSubject={activeSubject}
|
||||
onSubjectChange={setActiveSubject}
|
||||
onSubjectChange={handleSubjectChange}
|
||||
activeChat={activeChat}
|
||||
onChatSelect={setActiveChat}
|
||||
onChatSelect={handleChatSelect}
|
||||
onNewChat={handleNewChat}
|
||||
sessions={sessions}
|
||||
onDeleteChat={handleDeleteChat}
|
||||
isLoadingSessions={isLoadingSessions}
|
||||
/>
|
||||
|
||||
<div className="page__main">
|
||||
@@ -50,7 +301,7 @@ function App() {
|
||||
activeGrade={activeGrade}
|
||||
onGradeChange={setActiveGrade}
|
||||
activeSubject={activeSubject}
|
||||
onSubjectChange={setActiveSubject}
|
||||
onSubjectChange={handleSubjectChange}
|
||||
activeChapter={activeChapter}
|
||||
onChapterChange={setActiveChapter}
|
||||
/>
|
||||
@@ -62,12 +313,18 @@ function App() {
|
||||
activeGrade={activeGrade}
|
||||
activeChapter={activeChapter}
|
||||
onPromptClick={handlePromptClick}
|
||||
isTyping={isTyping}
|
||||
showSelectorFlow={showSelectorFlow}
|
||||
onGradeChange={handleGradeChange}
|
||||
onSubjectChange={handleSubjectChange}
|
||||
onChapterChange={handleChapterChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
placeholder={`Ask anything about ${activeGrade} ${activeSubject} – ${activeChapter}...`}
|
||||
disabled={isTyping}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,9 +3,17 @@
|
||||
======================================== */
|
||||
|
||||
import Message from "../message/Message";
|
||||
import SelectorFlow from "../selector-flow/SelectorFlow";
|
||||
import "./ChatHistory.css";
|
||||
|
||||
const ChatHistory = ({ messages, activeSubject, activeGrade, activeChapter, onPromptClick }) => {
|
||||
const ChatHistory = ({
|
||||
messages,
|
||||
activeGrade,
|
||||
onPromptClick,
|
||||
isTyping,
|
||||
showSelectorFlow,
|
||||
onGradeChange,
|
||||
}) => {
|
||||
const hasMessages = messages.length > 0;
|
||||
|
||||
const suggestions = [
|
||||
@@ -53,6 +61,8 @@ const ChatHistory = ({ messages, activeSubject, activeGrade, activeChapter, onPr
|
||||
|
||||
const fullPromptTexts = suggestions.map((s) => s.text + s.keywords.join(" ") + s.textEnd);
|
||||
|
||||
const streamingMsg = messages.find((m) => m.streaming);
|
||||
|
||||
if (hasMessages) {
|
||||
return (
|
||||
<div className="chat-history">
|
||||
@@ -60,11 +70,23 @@ const ChatHistory = ({ messages, activeSubject, activeGrade, activeChapter, onPr
|
||||
{messages.map((message) => (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
if (showSelectorFlow) {
|
||||
return (
|
||||
<div className="chat-history">
|
||||
<SelectorFlow grade={activeGrade} onGradeChange={onGradeChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-history">
|
||||
<div className="chat-history__focus">
|
||||
|
||||
@@ -88,6 +88,11 @@
|
||||
font-weight: var(--font-weight-regular);
|
||||
}
|
||||
|
||||
.chat-input__field:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Actions Container */
|
||||
.chat-input__actions {
|
||||
display: flex;
|
||||
@@ -150,6 +155,11 @@
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.chat-input__send-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.chat-input__send-btn .material-symbols-outlined {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import "./ChatInput.css";
|
||||
|
||||
const ChatInput = ({ onSend, placeholder }) => {
|
||||
const ChatInput = ({ onSend, placeholder, disabled }) => {
|
||||
const [text, setText] = useState("");
|
||||
const textareaRef = useRef(null);
|
||||
|
||||
@@ -75,9 +75,9 @@ const ChatInput = ({ onSend, placeholder }) => {
|
||||
|
||||
{/* Send */}
|
||||
<button
|
||||
className={`chat-input__send-btn ${text.trim() ? "chat-input__send-btn--active" : ""}`}
|
||||
className={`chat-input__send-btn ${text.trim() && !disabled ? "chat-input__send-btn--active" : ""}`}
|
||||
type="submit"
|
||||
disabled={!text.trim()}
|
||||
disabled={!text.trim() || disabled}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontWeight: text.trim() ? 700 : 200 }}>
|
||||
|
||||
@@ -131,3 +131,43 @@
|
||||
.message__action--primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* --- TYPING INDICATOR --- */
|
||||
.message__typing-dots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 4px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.message__typing-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-primary);
|
||||
animation: typingBounce 1.4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.message__typing-dot:nth-child(1) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.message__typing-dot:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.message__typing-dot:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes typingBounce {
|
||||
0%, 80%, 100% {
|
||||
transform: scale(0.6);
|
||||
opacity: 0.4;
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,20 @@
|
||||
Message Component — padhle (Refined Workspace)
|
||||
======================================== */
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import "./Message.css";
|
||||
|
||||
const Message = ({ message }) => {
|
||||
const { role, text } = message;
|
||||
const { role, text, streaming } = message;
|
||||
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 (
|
||||
<div className={`message ${isUser ? "message--user" : "message--assistant"}`}>
|
||||
@@ -18,11 +27,18 @@ const Message = ({ message }) => {
|
||||
</div>
|
||||
)}
|
||||
<div className="message__bubble">
|
||||
<div className="message__text">{text}</div>
|
||||
<div className="message__text">
|
||||
{text || (streaming ? "" : null)}
|
||||
{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>
|
||||
|
||||
{/* Action buttons for assistant messages */}
|
||||
{!isUser && (
|
||||
{!isUser && text && (
|
||||
<div className="message__actions">
|
||||
<button className="message__action" aria-label="Helpful" title="Helpful">
|
||||
<span className="material-symbols-outlined">thumb_up</span>
|
||||
@@ -35,6 +51,7 @@ const Message = ({ message }) => {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
.selector-flow {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
width: 100%;
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.selector-flow__content {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.selector-flow__title {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 24px;
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-on-surface);
|
||||
text-align: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.selector-flow__subtitle {
|
||||
font-size: 14px;
|
||||
font-weight: var(--font-weight-regular);
|
||||
color: var(--color-on-surface-variant);
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.selector-flow__options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.selector-flow__option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 20px 12px;
|
||||
border: var(--border-thin);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--color-surface);
|
||||
cursor: pointer;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-on-surface);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.selector-flow__option:hover:not(.selector-flow__option--selected) {
|
||||
background-color: var(--color-surface-low);
|
||||
border-color: var(--color-outline);
|
||||
}
|
||||
|
||||
.selector-flow__option--selected {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-on-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.selector-flow__option--selected:hover {
|
||||
background-color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--shadow-card-hover);
|
||||
}
|
||||
|
||||
.selector-flow__icon {
|
||||
font-size: 28px;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.selector-flow__option--selected .selector-flow__icon {
|
||||
color: var(--color-on-primary);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useState } from "react";
|
||||
import "./SelectorFlow.css";
|
||||
|
||||
const grades = ["Grade 6", "Grade 7", "Grade 8", "Grade 9", "Grade 10", "Grade 11", "Grade 12"];
|
||||
|
||||
export default function SelectorFlow({ grade, onGradeChange }) {
|
||||
const handleClick = (g) => onGradeChange(g);
|
||||
|
||||
return (
|
||||
<div className="selector-flow">
|
||||
<div className="selector-flow__content">
|
||||
<h3 className="selector-flow__title">Select your standard</h3>
|
||||
<p className="selector-flow__subtitle">Choose the grade you are studying in</p>
|
||||
<div className="selector-flow__options">
|
||||
{grades.map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
className={`selector-flow__option${grade === g ? " selector-flow__option--selected" : ""}`}
|
||||
onClick={() => handleClick(g)}
|
||||
>
|
||||
<span className="selector-flow__icon">school</span>
|
||||
<span className="selector-flow__label">{g}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -62,12 +62,42 @@
|
||||
font-size: var(--font-size-label-md);
|
||||
font-weight: var(--font-weight-medium);
|
||||
cursor: pointer;
|
||||
transition: opacity var(--transition-fast);
|
||||
transition: all var(--transition-normal);
|
||||
margin-bottom: 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar__new-chat::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, transparent 50%);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.sidebar__new-chat:hover {
|
||||
opacity: 0.9;
|
||||
background-color: #1a1a1a;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.sidebar__new-chat:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar__new-chat:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.sidebar__new-chat-icon {
|
||||
transition: transform var(--transition-normal);
|
||||
}
|
||||
|
||||
.sidebar__new-chat:hover .sidebar__new-chat-icon {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* Section */
|
||||
@@ -78,9 +108,28 @@
|
||||
.sidebar__section--history {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sidebar__chat-count {
|
||||
font-size: var(--font-size-label-xs);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-on-surface-variant);
|
||||
background-color: var(--color-surface-container);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.sidebar__section-title {
|
||||
font-size: 11px;
|
||||
font-weight: var(--font-weight-bold);
|
||||
@@ -88,7 +137,6 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
padding: 0 24px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Subject List */
|
||||
@@ -100,6 +148,7 @@
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
/* Subject Link — Enhanced Hover Effects */
|
||||
.sidebar__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -115,23 +164,60 @@
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-on-surface-variant);
|
||||
text-align: left;
|
||||
transition: background-color var(--transition-fast), color var(--transition-fast);
|
||||
transition: all var(--transition-fast);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Slide indicator — hidden by default, slides in on hover */
|
||||
.sidebar__link-indicator {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%) scaleY(0);
|
||||
width: 3px;
|
||||
height: 60%;
|
||||
background-color: var(--color-primary);
|
||||
border-radius: 0 2px 2px 0;
|
||||
transition: transform var(--transition-fast);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sidebar__link:hover {
|
||||
background-color: var(--color-surface-low);
|
||||
color: var(--color-on-surface);
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.sidebar__link:hover .sidebar__link-indicator {
|
||||
transform: translateY(-50%) scaleY(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar__link:hover .sidebar__icon {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.sidebar__link--active {
|
||||
background-color: var(--color-surface-low);
|
||||
color: var(--color-primary);
|
||||
font-weight: var(--font-weight-bold);
|
||||
border-left: 2px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.sidebar__link--active .sidebar__link-indicator {
|
||||
transform: translateY(-50%) scaleY(1);
|
||||
opacity: 1;
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.sidebar__link--active .sidebar__icon {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.sidebar__icon {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
/* Chat List */
|
||||
@@ -143,10 +229,24 @@
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
/* Chat Item — wrapper for link + delete */
|
||||
.sidebar__chat-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: var(--radius-md);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.sidebar__chat-item:hover {
|
||||
background-color: var(--color-surface-low);
|
||||
}
|
||||
|
||||
/* Chat Link */
|
||||
.sidebar__chat-link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
width: calc(100% - 32px);
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
@@ -154,7 +254,8 @@
|
||||
cursor: pointer;
|
||||
font-family: var(--font-body);
|
||||
text-align: left;
|
||||
transition: background-color var(--transition-fast);
|
||||
transition: all var(--transition-fast);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.sidebar__chat-link:hover {
|
||||
@@ -163,6 +264,7 @@
|
||||
|
||||
.sidebar__chat-link--active {
|
||||
background-color: var(--color-surface-low);
|
||||
border-left: 2px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.sidebar__chat-preview {
|
||||
@@ -172,6 +274,7 @@
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sidebar__chat-time {
|
||||
@@ -181,6 +284,87 @@
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Delete Button — hidden until hover */
|
||||
.sidebar__chat-delete {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--color-on-surface-variant);
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
transition: all var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.sidebar__chat-item:hover .sidebar__chat-delete {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.sidebar__chat-delete:hover {
|
||||
background-color: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.sidebar__chat-delete .material-symbols-outlined {
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sidebar__chat-delete--visible {
|
||||
opacity: 0.6;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.sidebar__chat-delete--visible:hover {
|
||||
opacity: 1;
|
||||
background-color: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.sidebar__loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 24px 16px;
|
||||
color: var(--color-on-surface-variant);
|
||||
font-size: var(--font-size-label-md);
|
||||
}
|
||||
|
||||
.sidebar__loading-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--color-outline-variant);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.sidebar__empty {
|
||||
padding: 24px 16px;
|
||||
color: var(--color-on-surface-variant);
|
||||
font-size: var(--font-size-label-md);
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.sidebar__footer {
|
||||
display: flex;
|
||||
@@ -234,11 +418,12 @@
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition-fast);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.sidebar__upgrade:hover {
|
||||
border-color: var(--color-outline);
|
||||
box-shadow: var(--shadow-card-hover);
|
||||
}
|
||||
|
||||
.sidebar__upgrade-header {
|
||||
@@ -278,4 +463,5 @@
|
||||
|
||||
.sidebar__upgrade:hover .sidebar__upgrade-arrow {
|
||||
color: var(--color-primary);
|
||||
transform: translateY(-50%) translateX(2px);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,16 @@
|
||||
|
||||
import "./Sidebar.css";
|
||||
|
||||
const Sidebar = ({ activeSubject, onSubjectChange, activeChat, onChatSelect }) => {
|
||||
const Sidebar = ({
|
||||
activeSubject,
|
||||
onSubjectChange,
|
||||
activeChat,
|
||||
onChatSelect,
|
||||
onNewChat,
|
||||
sessions,
|
||||
onDeleteChat,
|
||||
isLoadingSessions,
|
||||
}) => {
|
||||
const subjects = [
|
||||
{ id: "math", name: "Mathematics", icon: "functions" },
|
||||
{ id: "science", name: "Science", icon: "science" },
|
||||
@@ -14,12 +23,20 @@ const Sidebar = ({ activeSubject, onSubjectChange, activeChat, onChatSelect }) =
|
||||
{ id: "geography", name: "Geography", icon: "public" },
|
||||
];
|
||||
|
||||
const chats = [
|
||||
{ id: 1, preview: "Explain photosynthesis", time: "Today, 10:42 AM" },
|
||||
{ id: 2, preview: "Help with integrals", time: "Yesterday, 3:15 PM" },
|
||||
{ id: 3, preview: "What is cell respiration?", time: "Yesterday, 11:08 AM" },
|
||||
{ id: 4, preview: "Newton's laws of motion", time: "May 18, 6:20 PM" },
|
||||
];
|
||||
const formatTime = (isoString) => {
|
||||
const date = new Date(isoString);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHrs = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return "Just now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHrs < 24) return `${diffHrs}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
@@ -32,8 +49,8 @@ const Sidebar = ({ activeSubject, onSubjectChange, activeChat, onChatSelect }) =
|
||||
</div>
|
||||
|
||||
{/* New Chat Button */}
|
||||
<button className="sidebar__new-chat">
|
||||
<span className="material-symbols-outlined">add</span>
|
||||
<button className="sidebar__new-chat" onClick={onNewChat} aria-label="Start new chat">
|
||||
<span className="material-symbols-outlined sidebar__new-chat-icon">add</span>
|
||||
<span>New Chat</span>
|
||||
</button>
|
||||
|
||||
@@ -47,6 +64,7 @@ const Sidebar = ({ activeSubject, onSubjectChange, activeChat, onChatSelect }) =
|
||||
className={`sidebar__link ${activeSubject === subject.id ? "sidebar__link--active" : ""}`}
|
||||
onClick={() => onSubjectChange(subject.id)}
|
||||
>
|
||||
<span className={`sidebar__link-indicator`} />
|
||||
<span className={`material-symbols-outlined sidebar__icon`}>{subject.icon}</span>
|
||||
<span className="sidebar__label">{subject.name}</span>
|
||||
</button>
|
||||
@@ -57,20 +75,48 @@ const Sidebar = ({ activeSubject, onSubjectChange, activeChat, onChatSelect }) =
|
||||
|
||||
{/* Chat History Section */}
|
||||
<div className="sidebar__section sidebar__section--history">
|
||||
<h2 className="sidebar__section-title">Chat History</h2>
|
||||
<ul className="sidebar__chat-list">
|
||||
{chats.map((chat) => (
|
||||
<li key={chat.id}>
|
||||
<button
|
||||
className={`sidebar__chat-link ${activeChat === chat.id ? "sidebar__chat-link--active" : ""}`}
|
||||
onClick={() => onChatSelect(chat.id)}
|
||||
>
|
||||
<span className="sidebar__chat-preview">{chat.preview}</span>
|
||||
<span className="sidebar__chat-time">{chat.time}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="sidebar__section-header">
|
||||
<h2 className="sidebar__section-title">Chat History</h2>
|
||||
<span className="sidebar__chat-count">{sessions.length}</span>
|
||||
</div>
|
||||
{isLoadingSessions ? (
|
||||
<div className="sidebar__loading">
|
||||
<span className="sidebar__loading-spinner" />
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<p className="sidebar__empty">No chats yet</p>
|
||||
) : (
|
||||
<ul className="sidebar__chat-list">
|
||||
{sessions.map((session) => {
|
||||
const preview = session.preview || session.subject || "New Chat";
|
||||
const isActive = activeChat === session.id;
|
||||
|
||||
return (
|
||||
<li key={session.id} className="sidebar__chat-item">
|
||||
<button
|
||||
className={`sidebar__chat-link ${isActive ? "sidebar__chat-link--active" : ""}`}
|
||||
onClick={() => onChatSelect(session.id)}
|
||||
>
|
||||
<span className="sidebar__chat-preview">{preview}</span>
|
||||
<span className="sidebar__chat-time">{formatTime(session.updatedAt)}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`sidebar__chat-delete ${isActive ? "sidebar__chat-delete--visible" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteChat(session.id);
|
||||
}}
|
||||
aria-label="Delete chat"
|
||||
title="Delete chat"
|
||||
>
|
||||
<span className="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
@@ -6,7 +6,13 @@ export default defineConfig({
|
||||
root: ".",
|
||||
publicDir: "public",
|
||||
server: {
|
||||
port: 3000,
|
||||
port: 5173,
|
||||
open: true,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:3001",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user