dosemate / frontend /src /components /Sidebar.jsx
DoseMate
Make the whole UI responsive (mobile drawer, fluid grids, responsive auth)
852e9a3
Raw History Blame Contribute Delete
10.7 kB
import { useEffect, useState, useRef, forwardRef, useImperativeHandle } from "react"
import { useNavigate, useLocation } from "react-router-dom"
import {
MessageSquare, Plus, LayoutDashboard, CalendarClock, CheckSquare,
Trophy, User, LogOut, Trash2, MoreVertical, HelpCircle, BookOpen, Menu, X,
} from "lucide-react"
import { getConversations, createConversation } from "../api"
import { useAuth } from "../auth/AuthContext"
import { useI18n } from "../i18n/I18nContext"
import { useIsMobile } from "../useMediaQuery"
import HelpGuide from "./HelpGuide"
import { T } from "../theme"
const Sidebar = forwardRef(function Sidebar(
{ activeConversationId, onSelectConversation, onNewConversation, onDeleteConversation },
ref
) {
const [conversations, setConversations] = useState([])
const [menu, setMenu] = useState(null)
const [showHelp, setShowHelp] = useState(false)
const [drawerOpen, setDrawerOpen] = useState(false)
const navigate = useNavigate()
const location = useLocation()
const isMobile = useIsMobile()
const { logout, user } = useAuth()
const { t } = useI18n()
const topNav = [
{ icon: LayoutDashboard, label: t("nav_dashboard"), path: "/dashboard" },
{ icon: MessageSquare, label: t("nav_chat"), path: "/chat" },
{ icon: CalendarClock, label: t("nav_schedule"), path: "/schedule" },
{ icon: CheckSquare, label: t("nav_today"), path: "/today" },
{ icon: BookOpen, label: t("nav_medicines"), path: "/medicines" },
{ icon: Trophy, label: t("nav_leaderboard"), path: "/leaderboard" },
{ icon: User, label: t("nav_profile"), path: "/profile" },
]
useImperativeHandle(ref, () => ({
refresh: loadConversations,
updateTitle: (id, title) =>
setConversations((prev) => prev.map((c) => (c.id === id ? { ...c, title } : c))),
}))
useEffect(() => { loadConversations() }, [])
useEffect(() => { loadConversations() }, [activeConversationId])
useEffect(() => { setDrawerOpen(false) }, [location.pathname]) // close on navigation
useEffect(() => {
const close = () => setMenu(null)
document.addEventListener("click", close)
return () => document.removeEventListener("click", close)
}, [])
const loadConversations = async () => {
try { setConversations(await getConversations()) } catch (e) { console.error(e) }
}
const go = (path) => { navigate(path); setDrawerOpen(false) }
const handleNew = async () => {
if (onNewConversation) {
const id = await createConversation()
await loadConversations()
onNewConversation(id)
} else {
navigate("/chat")
}
setDrawerOpen(false)
}
const handleSelect = (id) => {
if (onSelectConversation) onSelectConversation(id)
else navigate("/chat")
setDrawerOpen(false)
}
const openMenu = (e, convId) => {
e.stopPropagation()
const r = e.currentTarget.getBoundingClientRect()
setMenu({ x: Math.min(r.left, window.innerWidth - 170), y: r.bottom + 4, convId })
}
const handleDelete = async () => {
if (!menu) return
if (onDeleteConversation) await onDeleteConversation(menu.convId)
await loadConversations()
setMenu(null)
}
const panel = (
<>
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "2px 6px 0" }}>
<div style={logo}>💊</div>
<div>
<div style={{ fontSize: 16, fontWeight: 800, color: T.text, lineHeight: 1 }}>DoseMate</div>
<div style={{ fontSize: 9.5, color: T.faint, letterSpacing: ".1em", marginTop: 3 }}>{t("portal_subtitle")}</div>
</div>
</div>
<button onClick={handleNew} style={newBtn}><Plus size={16} /> New Chat</button>
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
{topNav.map(({ icon: Icon, label, path }) => {
const active = location.pathname === path
return (
<div key={path} onClick={() => go(path)} style={navItem(active)}>
<Icon size={17} /> {label}
</div>
)
})}
</div>
<div style={{ marginTop: 18, flex: 1, overflowY: "auto", minHeight: 0 }}>
<div style={sectionLabel}>RECENT CHATS</div>
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
{conversations.map((conv) => {
const active = activeConversationId === conv.id
return (
<div key={conv.id} onClick={() => handleSelect(conv.id)} style={chatRow(active)}>
<MessageSquare size={13} style={{ flexShrink: 0 }} />
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1 }}>
{conv.title || `Consultation #${conv.number ?? conv.id}`}
</span>
<button onClick={(e) => openMenu(e, conv.id)} style={dotsBtn}><MoreVertical size={15} /></button>
</div>
)
})}
</div>
</div>
<div style={{ marginTop: "auto", paddingTop: 10, borderTop: `1px solid ${T.border}` }}>
<div onClick={() => { setShowHelp(true); setDrawerOpen(false) }} style={navItem(false)}>
<HelpCircle size={17} /> {t("help")}
</div>
<div style={userRow}>
<div style={avatar}>{(user?.name || "U").charAt(0).toUpperCase()}</div>
<div style={{ flex: 1, overflow: "hidden" }}>
<div style={{ fontSize: 12.5, fontWeight: 600, color: T.text, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{user?.name || "User"}</div>
<div style={{ fontSize: 10.5, color: T.faint, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{user?.email}</div>
</div>
<button onClick={logout} style={logoutBtn} title={t("nav_logout")}><LogOut size={15} /></button>
</div>
</div>
</>
)
const menuEl = menu && (
<div style={{ position: "fixed", top: menu.y, left: menu.x, ...menuBox }}>
<div onClick={handleDelete} style={menuItem}
onMouseEnter={(e) => (e.currentTarget.style.background = "#fef2f2")}
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}>
<Trash2 size={14} /> Delete chat
</div>
</div>
)
// ---------- Mobile: top bar + slide-in drawer ----------
if (isMobile) {
return (
<>
<div style={topbar}>
<button onClick={() => setDrawerOpen(true)} style={hamburger} aria-label="menu"><Menu size={20} /></button>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 18 }}>💊</span>
<span style={{ fontWeight: 800, color: T.text, fontSize: 16 }}>DoseMate</span>
</div>
<div style={{ width: 38 }} />
</div>
{drawerOpen && <div style={backdrop} onClick={() => setDrawerOpen(false)} />}
<div style={{ ...drawer, transform: drawerOpen ? "translateX(0)" : "translateX(-105%)" }}>
<button onClick={() => setDrawerOpen(false)} style={drawerClose}><X size={18} /></button>
{panel}
</div>
{menuEl}
{showHelp && <HelpGuide onClose={() => setShowHelp(false)} />}
</>
)
}
// ---------- Desktop: fixed in-flow sidebar ----------
return (
<div style={wrap}>
{panel}
{menuEl}
{showHelp && <HelpGuide onClose={() => setShowHelp(false)} />}
</div>
)
})
const wrap = { width: 240, height: "100%", background: T.surface, borderInlineEnd: `1px solid ${T.border}`, display: "flex", flexDirection: "column", padding: "20px 14px", flexShrink: 0, gap: 16 }
const drawer = { position: "fixed", insetInlineStart: 0, top: 0, height: "100%", width: 270, maxWidth: "82vw", background: T.surface, display: "flex", flexDirection: "column", padding: "18px 14px", gap: 14, zIndex: 1600, boxShadow: T.shadowLg, transition: "transform .25s ease", overflowY: "auto" }
const drawerClose = { position: "absolute", insetInlineEnd: 10, top: 10, background: "transparent", border: "none", color: T.faint, cursor: "pointer", padding: 4 }
const backdrop = { position: "fixed", inset: 0, background: "rgba(15,23,42,.45)", zIndex: 1550 }
const topbar = { position: "fixed", top: 0, insetInlineStart: 0, right: 0, height: 52, background: T.surface, borderBottom: `1px solid ${T.border}`, display: "flex", alignItems: "center", justifyContent: "space-between", padding: "0 12px", zIndex: 1400 }
const hamburger = { width: 38, height: 38, borderRadius: 10, background: "#f0fdf4", border: "1px solid #bbf7d0", color: T.green, display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer" }
const logo = { width: 38, height: 38, borderRadius: 12, background: T.gradient, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 19, boxShadow: T.shadowGreen }
const newBtn = { display: "flex", alignItems: "center", justifyContent: "center", gap: 7, padding: "11px", borderRadius: 12, background: T.gradient, color: "#fff", border: "none", fontSize: 13.5, fontWeight: 600, cursor: "pointer", boxShadow: T.shadowGreen }
const navItem = (active) => ({ display: "flex", alignItems: "center", gap: 11, padding: "10px 12px", borderRadius: 11, cursor: "pointer", fontSize: 13.5, fontWeight: active ? 700 : 500, background: active ? "#f0fdf4" : "transparent", color: active ? T.green : T.muted })
const sectionLabel = { fontSize: 9.5, fontWeight: 700, color: T.faint, letterSpacing: ".09em", marginBottom: 8, paddingInlineStart: 4 }
const chatRow = (active) => ({ display: "flex", alignItems: "center", gap: 8, padding: "8px 8px 8px 12px", borderRadius: 9, cursor: "pointer", fontSize: 12.5, userSelect: "none", background: active ? "#f0fdf4" : "transparent", color: active ? T.green : T.muted, fontWeight: active ? 600 : 400 })
const dotsBtn = { background: "transparent", border: "none", color: T.faint, cursor: "pointer", padding: 2, borderRadius: 6, flexShrink: 0, display: "flex" }
const userRow = { display: "flex", alignItems: "center", gap: 9, padding: "9px 8px", marginTop: 2 }
const avatar = { width: 32, height: 32, borderRadius: "50%", background: T.gradient, color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 14, fontWeight: 700, flexShrink: 0 }
const logoutBtn = { background: "transparent", border: "none", color: T.faint, cursor: "pointer", padding: 6, borderRadius: 8, display: "flex" }
const menuBox = { background: "#fff", border: `1px solid ${T.border}`, borderRadius: 10, boxShadow: T.shadowMd, zIndex: 1700, overflow: "hidden", minWidth: 150 }
const menuItem = { display: "flex", alignItems: "center", gap: 8, padding: "10px 14px", cursor: "pointer", color: T.danger, fontSize: 13, fontWeight: 600 }
export default Sidebar