- Cookie-based auth via backend proxy (httpOnly JWTs) - Supabase Postgres persistence for sessions/messages/profiles + RLS - Fix cross-user session leak (token cache keyed by full token, not 50-char prefix) - Fix missing table grants (42501) via migration; auto-provision profiles on user creation - Chat validation, ownership checks, rate limiting, /api/chat/sessions route ordering - Frontend auth-state reset + credentials include - OpenRouter AI provider (OpenAI-compatible base URL, reasoning disabled) - Tests: chatValidation, appState
210 lines
7.7 KiB
React
210 lines
7.7 KiB
React
/* ========================================
|
|
TopNav Component — padhle (Refined Workspace)
|
|
======================================== */
|
|
|
|
import { useState, useRef, useEffect } from "react";
|
|
import "./TopNav.css";
|
|
|
|
const TopNav = ({ subjectItems, chapterData, activeGrade, onGradeChange, activeSubject, onSubjectChange, activeChapter, onChapterChange, user, onSignOut }) => {
|
|
const [openDropdown, setOpenDropdown] = useState(null);
|
|
const gradeRef = useRef(null);
|
|
const subjectRef = useRef(null);
|
|
const chapterRef = useRef(null);
|
|
|
|
// Close dropdown when clicking outside
|
|
useEffect(() => {
|
|
const handleClickOutside = (e) => {
|
|
const refs = [gradeRef, subjectRef, chapterRef];
|
|
const isInside = refs.some((ref) => ref.current && ref.current.contains(e.target));
|
|
if (!isInside) {
|
|
setOpenDropdown(null);
|
|
}
|
|
};
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
}, []);
|
|
|
|
const toggleDropdown = (name) => {
|
|
setOpenDropdown((prev) => (prev === name ? null : name));
|
|
};
|
|
|
|
const selectOption = (name, value) => {
|
|
setOpenDropdown(null);
|
|
switch (name) {
|
|
case "grade":
|
|
onGradeChange(value);
|
|
break;
|
|
case "subject":
|
|
onSubjectChange(value);
|
|
break;
|
|
case "chapter":
|
|
onChapterChange(value);
|
|
break;
|
|
}
|
|
};
|
|
|
|
const grades = [
|
|
{ id: "choose-standard", label: "Choose standard" },
|
|
{ id: "Grade 6", label: "Grade 6" },
|
|
{ id: "Grade 7", label: "Grade 7" },
|
|
{ id: "Grade 8", label: "Grade 8" },
|
|
{ id: "Grade 9", label: "Grade 9" },
|
|
{ id: "Grade 10", label: "Grade 10" },
|
|
{ id: "Grade 11", label: "Grade 11" },
|
|
{ id: "Grade 12", label: "Grade 12" },
|
|
];
|
|
|
|
// Chapters are now generated dynamically from chapterData (see chapterOptions above)
|
|
// Subjects now come from subjectItems prop
|
|
// Static list removed — was not subject-aware.
|
|
|
|
const subjectIcons = {
|
|
math: "calculate",
|
|
science: "science",
|
|
history: "account_balance",
|
|
language: "menu_book",
|
|
english: "edit",
|
|
geography: "public",
|
|
};
|
|
|
|
/* ─── Resolve subject display (ID → label) ─── */
|
|
const resolvedSubjectLabel = activeSubject === "choose-subject"
|
|
? "Choose Subject"
|
|
: subjectItems?.find((s) => s.id === activeSubject)?.label || activeSubject || "Choose Subject";
|
|
|
|
/* ─── Resolve chapter pill label ─── */
|
|
const resolvedChapterLabel = activeChapter === "choose-chapter" || !activeChapter
|
|
? "Choose Chapter"
|
|
: activeChapter;
|
|
|
|
/* ─── Subject-aware chapter options (from chapterData prop) ─── */
|
|
const chapterOptions = (() => {
|
|
const chapters = chapterData?.[activeSubject] || [];
|
|
return [
|
|
{ id: "choose-chapter", label: "Choose Chapter" },
|
|
...chapters,
|
|
];
|
|
})();
|
|
|
|
return (
|
|
<header className="topnav">
|
|
<div className="topnav__selectors">
|
|
{/* Grade Selector */}
|
|
<div className="topnav__selector" ref={gradeRef}>
|
|
<button
|
|
className={`topnav__pill topnav__pill--dropdown ${openDropdown === "grade" ? "topnav__pill--open" : ""}`}
|
|
onClick={() => toggleDropdown("grade")}
|
|
aria-label="Select Grade"
|
|
aria-expanded={openDropdown === "grade"}
|
|
>
|
|
<span className="material-symbols-outlined topnav__pill-icon">person_outline</span>
|
|
<span className="topnav__pill-label">{activeGrade}</span>
|
|
<span className="material-symbols-outlined topnav__pill-arrow">expand_more</span>
|
|
</button>
|
|
{openDropdown === "grade" && (
|
|
<ul className="topnav__dropdown">
|
|
{grades.map((grade) => (
|
|
<li key={grade.id}>
|
|
<button
|
|
className={`topnav__dropdown-item ${activeGrade === grade.label ? "topnav__dropdown-item--active" : ""}`}
|
|
onClick={() => selectOption("grade", grade.label)}
|
|
>
|
|
{grade.label}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
|
|
{/* Subject Selector */}
|
|
<div className="topnav__selector" ref={subjectRef}>
|
|
<button
|
|
className={`topnav__pill topnav__pill--dropdown ${openDropdown === "subject" ? "topnav__pill--open" : ""}`}
|
|
onClick={() => toggleDropdown("subject")}
|
|
aria-label="Select Subject"
|
|
aria-expanded={openDropdown === "subject"}
|
|
>
|
|
<span className={`material-symbols-outlined topnav__pill-icon`}>
|
|
{activeSubject === "choose-subject" ? "science" : subjectIcons[activeSubject] || "science"}
|
|
</span>
|
|
<span className="topnav__pill-label">{resolvedSubjectLabel}</span>
|
|
<span className="material-symbols-outlined topnav__pill-arrow">expand_more</span>
|
|
</button>
|
|
{openDropdown === "subject" && (
|
|
<ul className="topnav__dropdown">
|
|
{subjectItems?.map((subject) => (
|
|
<li key={subject.id}>
|
|
<button
|
|
className={`topnav__dropdown-item ${activeSubject === subject.id ? "topnav__dropdown-item--active" : ""}`}
|
|
onClick={() => selectOption("subject", subject.id)}
|
|
>
|
|
<span className={`material-symbols-outlined topnav__dropdown-icon`}>
|
|
{subject.id === "choose-subject" ? "science" : subjectIcons[subject.id]}
|
|
</span>
|
|
{subject.label}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
|
|
{/* Chapter Selector */}
|
|
<div className="topnav__selector" ref={chapterRef}>
|
|
<button
|
|
className={`topnav__pill topnav__pill--dropdown ${openDropdown === "chapter" ? "topnav__pill--open" : ""}`}
|
|
onClick={() => toggleDropdown("chapter")}
|
|
aria-label="Select Chapter"
|
|
aria-expanded={openDropdown === "chapter"}
|
|
>
|
|
<span className="material-symbols-outlined topnav__pill-icon">menu_book</span>
|
|
<span className="topnav__pill-label">{resolvedChapterLabel}</span>
|
|
<span className="material-symbols-outlined topnav__pill-arrow">expand_more</span>
|
|
</button>
|
|
{openDropdown === "chapter" && (
|
|
<ul className="topnav__dropdown">
|
|
{chapterOptions.map((chapter) => (
|
|
<li key={chapter.id}>
|
|
<button
|
|
className={`topnav__dropdown-item ${(activeChapter === chapter.label || activeChapter === "choose-chapter") ? "topnav__dropdown-item--active" : ""}`}
|
|
onClick={() => selectOption("chapter", chapter.label)}
|
|
>
|
|
{chapter.label}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="topnav__actions">
|
|
{/* Search */}
|
|
<button className="topnav__action" aria-label="Search">
|
|
<span className="material-symbols-outlined">search</span>
|
|
</button>
|
|
|
|
{/* Notifications */}
|
|
<button className="topnav__action" aria-label="Notifications">
|
|
<span className="material-symbols-outlined">notifications_none</span>
|
|
</button>
|
|
|
|
{/* User Avatar — Sign out on click */}
|
|
<button
|
|
className="topnav__avatar-btn"
|
|
onClick={onSignOut}
|
|
aria-label="Sign out"
|
|
title="Sign out"
|
|
>
|
|
<span className="topnav__avatar-initial">
|
|
{user?.email?.charAt(0).toUpperCase() || "U"}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</header>
|
|
);
|
|
};
|
|
|
|
export default TopNav;
|