feat: Introduce a shared Layout component, add new Insights and Sources pages, and refactor Dashboard and Profile pages to integrate with the new layout.

This commit is contained in:
2025-12-19 21:25:23 +05:30
parent 10e6d2c58a
commit e558e19512
7 changed files with 424 additions and 132 deletions

View File

@@ -1,19 +1,29 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { LoginPage } from './pages/Login' import { LoginPage } from './pages/Login'
import { SignupPage } from './pages/Signup' import { SignupPage } from './pages/Signup'
import { Dashboard } from './pages/Dashboard' import { DashboardPage } from './pages/Dashboard'
import { ProfilePage } from './pages/Profile' import { ProfilePage } from './pages/Profile'
import { InsightsPage } from './pages/Insights'
import { SourcesPage } from './pages/Sources'
import { AdminPage } from './pages/Admin' import { AdminPage } from './pages/Admin'
import { Layout } from './components/Layout'
function App() { function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
{/* Public routes */}
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<SignupPage />} /> <Route path="/signup" element={<SignupPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/admin" element={<AdminPage />} /> {/* Protected routes with Layout */}
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Layout><DashboardPage /></Layout>} />
<Route path="/profile" element={<Layout><ProfilePage /></Layout>} />
<Route path="/insights" element={<Layout><InsightsPage /></Layout>} />
<Route path="/sources" element={<Layout><SourcesPage /></Layout>} />
<Route path="/admin" element={<Layout><AdminPage /></Layout>} />
{/* Fallback */}
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>

View File

@@ -0,0 +1,148 @@
import { useEffect, useState, type ReactNode } from 'react'
import { useNavigate, useLocation, Link } from 'react-router-dom'
interface User {
id: number
username: string
name: string | null
role: string
}
interface LayoutProps {
children: ReactNode
}
export function Layout({ children }: LayoutProps) {
const navigate = useNavigate()
const location = useLocation()
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const [theme, setTheme] = useState(() =>
document.documentElement.getAttribute('data-theme') || 'light'
)
useEffect(() => {
fetch('/api/auth/me', { credentials: 'include' })
.then(res => res.json())
.then(data => {
if (!data.user) {
navigate('/login')
return null
}
return fetch(`/api/users/${data.user.id}`, { credentials: 'include' })
.then(res => res.json())
})
.then((profile) => {
if (profile) {
setUser(profile)
}
})
.catch(() => navigate('/login'))
.finally(() => setLoading(false))
}, [navigate])
const handleLogout = async () => {
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include',
})
navigate('/login')
}
const toggleTheme = () => {
const newTheme = theme === 'dark' ? 'light' : 'dark'
document.documentElement.setAttribute('data-theme', newTheme)
localStorage.setItem('theme', newTheme)
setTheme(newTheme)
}
if (loading) {
return <div className="layout-loading">Loading...</div>
}
if (!user) {
return null
}
const displayName = user.name || user.username
const navItems = [
{ path: '/', label: 'Dashboard', icon: '📊' },
{ path: '/profile', label: 'Profile', icon: '👤' },
{ path: '/insights', label: 'Insights', icon: '💡', disabled: true },
{ path: '/sources', label: 'Sources', icon: '📄' },
]
return (
<div className="app-layout">
{/* Sidebar */}
<aside className="sidebar">
<div className="sidebar-header">
<img src="/logo.svg" alt="zhealth" className="sidebar-logo" />
<span className="sidebar-title">zhealth</span>
</div>
<nav className="sidebar-nav">
{navItems.map(item => (
<Link
key={item.path}
to={item.disabled ? '#' : item.path}
className={`sidebar-link ${location.pathname === item.path ? 'active' : ''} ${item.disabled ? 'disabled' : ''}`}
onClick={e => item.disabled && e.preventDefault()}
>
<span className="sidebar-icon">{item.icon}</span>
<span className="sidebar-label">{item.label}</span>
{item.disabled && <span className="sidebar-badge">Soon</span>}
</Link>
))}
</nav>
{/* Admin link for admins */}
{user.role === 'admin' && (
<div className="sidebar-section">
<div className="sidebar-section-title">Admin</div>
<Link
to="/admin"
className={`sidebar-link ${location.pathname === '/admin' ? 'active' : ''}`}
>
<span className="sidebar-icon"></span>
<span className="sidebar-label">Manage Users</span>
</Link>
</div>
)}
<div className="sidebar-footer">
<div className="sidebar-user">
<div className="sidebar-user-name">{displayName}</div>
<div className="sidebar-user-role">
<span className={`indicator indicator-${user.role === 'admin' ? 'warning' : 'info'}`}>
{user.role}
</span>
</div>
</div>
<div className="sidebar-actions">
<button
className="sidebar-btn"
onClick={toggleTheme}
title={theme === 'dark' ? 'Light mode' : 'Dark mode'}
>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
<button
className="sidebar-btn"
onClick={handleLogout}
title="Logout"
>
🚪
</button>
</div>
</div>
</aside>
{/* Main Content */}
<main className="main-content">
{children}
</main>
</div>
)
}

View File

@@ -420,4 +420,186 @@ select.input {
.biomarker-unit { .biomarker-unit {
color: var(--text-secondary); color: var(--text-secondary);
font-size: 11px; font-size: 11px;
}
/* App Layout with Sidebar */
.app-layout {
display: flex;
min-height: 100vh;
}
.layout-loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
color: var(--text-secondary);
}
/* Sidebar */
.sidebar {
width: 240px;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
position: fixed;
height: 100vh;
left: 0;
top: 0;
}
.sidebar-header {
padding: var(--space-md);
display: flex;
align-items: center;
gap: var(--space-sm);
border-bottom: 1px solid var(--border);
}
.sidebar-logo {
width: 28px;
height: 28px;
}
.sidebar-title {
font-size: 18px;
font-weight: 600;
}
.sidebar-nav {
flex: 1;
padding: var(--space-sm);
overflow-y: auto;
}
.sidebar-link {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-md);
color: var(--text-secondary);
text-decoration: none;
font-size: 14px;
margin-bottom: 2px;
transition: background-color 0.15s, color 0.15s;
}
.sidebar-link:hover {
background-color: var(--bg-primary);
color: var(--text-primary);
text-decoration: none;
}
.sidebar-link.active {
background-color: var(--accent);
color: white;
}
.sidebar-link.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.sidebar-link.disabled:hover {
background-color: transparent;
color: var(--text-secondary);
}
.sidebar-icon {
font-size: 16px;
}
.sidebar-label {
flex: 1;
}
.sidebar-badge {
font-size: 10px;
padding: 2px 6px;
background: var(--border);
color: var(--text-secondary);
border-radius: 9999px;
}
.sidebar-section {
padding: var(--space-sm);
border-top: 1px solid var(--border);
}
.sidebar-section-title {
font-size: 11px;
text-transform: uppercase;
color: var(--text-secondary);
padding: var(--space-xs) var(--space-md);
margin-bottom: var(--space-xs);
}
.sidebar-footer {
padding: var(--space-md);
border-top: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.sidebar-user {
min-width: 0;
display: flex;
align-items: center;
gap: var(--space-sm);
}
.sidebar-user-name {
font-size: 14px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-actions {
display: flex;
gap: var(--space-xs);
}
.sidebar-btn {
background: none;
border: 1px solid var(--border);
padding: var(--space-xs);
border-radius: var(--radius-md);
cursor: pointer;
font-size: 14px;
transition: background 0.15s;
}
.sidebar-btn:hover {
background: var(--bg-primary);
}
/* Main Content */
.main-content {
flex: 1;
margin-left: 240px;
min-height: 100vh;
background: var(--bg-primary);
}
/* Page wrapper */
.page {
padding: var(--space-lg);
}
.page-header {
margin-bottom: var(--space-xl);
}
.page-header h1 {
margin-bottom: var(--space-xs);
}
.page-loading {
padding: var(--space-lg);
color: var(--text-secondary);
} }

View File

@@ -1,12 +1,4 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
interface User {
id: number
username: string
name: string | null
role: string
}
interface Category { interface Category {
id: number id: number
@@ -23,62 +15,22 @@ interface Biomarker {
methodology: string | null methodology: string | null
} }
export function Dashboard() { export function DashboardPage() {
const navigate = useNavigate()
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const [theme, setTheme] = useState(() =>
document.documentElement.getAttribute('data-theme') || 'light'
)
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
const [biomarkers, setBiomarkers] = useState<Biomarker[]>([]) const [biomarkers, setBiomarkers] = useState<Biomarker[]>([])
const [expandedCategories, setExpandedCategories] = useState<Set<number>>(new Set()) const [expandedCategories, setExpandedCategories] = useState<Set<number>>(new Set())
const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
// First get auth info, then fetch full user profile for name
fetch('/api/auth/me', { credentials: 'include' })
.then(res => res.json())
.then(data => {
if (!data.user) {
navigate('/login')
return null
}
// Fetch full user profile to get name
return fetch(`/api/users/${data.user.id}`, { credentials: 'include' })
.then(res => res.json())
})
.then((profile) => {
if (profile) {
setUser(profile)
}
})
.catch(() => navigate('/login'))
.finally(() => setLoading(false))
// Fetch categories and biomarkers
Promise.all([ Promise.all([
fetch('/api/categories', { credentials: 'include' }).then(r => r.json()), fetch('/api/categories', { credentials: 'include' }).then(r => r.json()),
fetch('/api/biomarkers', { credentials: 'include' }).then(r => r.json()), fetch('/api/biomarkers', { credentials: 'include' }).then(r => r.json()),
]).then(([cats, bms]) => { ]).then(([cats, bms]) => {
setCategories(cats) setCategories(cats)
setBiomarkers(bms) setBiomarkers(bms)
setLoading(false)
}) })
}, [navigate]) }, [])
const handleLogout = async () => {
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include',
})
navigate('/login')
}
const toggleTheme = () => {
const newTheme = theme === 'dark' ? 'light' : 'dark'
document.documentElement.setAttribute('data-theme', newTheme)
localStorage.setItem('theme', newTheme)
setTheme(newTheme)
}
const toggleCategory = (categoryId: number) => { const toggleCategory = (categoryId: number) => {
setExpandedCategories(prev => { setExpandedCategories(prev => {
@@ -97,69 +49,18 @@ export function Dashboard() {
} }
if (loading) { if (loading) {
return <div style={{ padding: 'var(--space-lg)' }}>Loading...</div> return <div className="page-loading">Loading biomarkers...</div>
} }
if (!user) {
return null
}
// Display name or username fallback
const displayName = user.name || user.username
return ( return (
<div style={{ padding: 'var(--space-lg)' }}> <div className="page">
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-xl)' }}> <header className="page-header">
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)' }}> <h1>Dashboard</h1>
<img src="/logo.svg" alt="zhealth" style={{ width: '32px', height: '32px' }} /> <p className="text-secondary">View all biomarker categories and their reference markers</p>
<h1>zhealth</h1>
</div>
<div style={{ display: 'flex', gap: 'var(--space-sm)' }}>
<button
className="btn btn-secondary"
onClick={toggleTheme}
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
style={{ fontSize: '18px', padding: 'var(--space-sm)' }}
>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
<button className="btn btn-secondary" onClick={handleLogout}>
Logout
</button>
</div>
</header> </header>
<main> <section>
{/* User Welcome Card */} <h2 style={{ marginBottom: 'var(--space-md)' }}>Biomarker Categories</h2>
<div className="card" style={{ marginBottom: 'var(--space-lg)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<h3 style={{ marginBottom: 'var(--space-sm)' }}>Welcome, {displayName}</h3>
<p className="text-secondary text-sm">
Role: <span className="indicator indicator-info">{user.role}</span>
</p>
</div>
<Link to="/profile" className="btn btn-secondary">
Edit Profile
</Link>
</div>
</div>
{/* Admin Section */}
{user.role === 'admin' && (
<div className="card" style={{ marginBottom: 'var(--space-lg)' }}>
<h3 style={{ marginBottom: 'var(--space-sm)' }}>Admin</h3>
<p className="text-secondary text-sm" style={{ marginBottom: 'var(--space-md)' }}>
Manage users and system settings
</p>
<Link to="/admin" className="btn btn-secondary">
Manage Users
</Link>
</div>
)}
{/* Biomarker Categories */}
<h2 style={{ marginBottom: 'var(--space-md)' }}>Biomarkers</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-sm)' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-sm)' }}>
{categories.map(category => { {categories.map(category => {
const categoryBiomarkers = getBiomarkersForCategory(category.id) const categoryBiomarkers = getBiomarkersForCategory(category.id)
@@ -167,7 +68,6 @@ export function Dashboard() {
return ( return (
<div key={category.id} className="card" style={{ padding: 0 }}> <div key={category.id} className="card" style={{ padding: 0 }}>
{/* Category Header - Clickable */}
<button <button
className="collapsible-header" className="collapsible-header"
onClick={() => toggleCategory(category.id)} onClick={() => toggleCategory(category.id)}
@@ -195,7 +95,6 @@ export function Dashboard() {
</span> </span>
</button> </button>
{/* Biomarkers List - Collapsible */}
{isExpanded && ( {isExpanded && (
<div style={{ borderTop: '1px solid var(--border)', padding: 'var(--space-sm)' }}> <div style={{ borderTop: '1px solid var(--border)', padding: 'var(--space-sm)' }}>
{categoryBiomarkers.length === 0 ? ( {categoryBiomarkers.length === 0 ? (
@@ -222,7 +121,7 @@ export function Dashboard() {
) )
})} })}
</div> </div>
</main> </section>
</div> </div>
) )
} }

View File

@@ -0,0 +1,18 @@
export function InsightsPage() {
return (
<div className="page">
<header className="page-header">
<h1>Insights</h1>
<p className="text-secondary">AI-powered analysis of your health data</p>
</header>
<div className="card" style={{ textAlign: 'center', padding: 'var(--space-xl)' }}>
<div style={{ fontSize: '48px', marginBottom: 'var(--space-md)' }}>🚀</div>
<h3>Coming Soon</h3>
<p className="text-secondary">
AI-generated health insights and recommendations based on your biomarker data.
</p>
</div>
</div>
)
}

View File

@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
interface Diet { interface Diet {
id: number id: number
@@ -21,7 +20,6 @@ interface UserProfile {
} }
export function ProfilePage() { export function ProfilePage() {
const navigate = useNavigate()
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null) const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
@@ -39,19 +37,16 @@ export function ProfilePage() {
const [dietId, setDietId] = useState<number | null>(null) const [dietId, setDietId] = useState<number | null>(null)
useEffect(() => { useEffect(() => {
// Fetch current user and diets // Fetch current user and diets (Layout already ensures auth)
Promise.all([ Promise.all([
fetch('/api/auth/me', { credentials: 'include' }).then(r => r.json()), fetch('/api/auth/me', { credentials: 'include' }).then(r => r.json()),
fetch('/api/diets', { credentials: 'include' }).then(r => r.json()), fetch('/api/diets', { credentials: 'include' }).then(r => r.json()),
]) ])
.then(([authData, dietsData]) => { .then(([authData, dietsData]) => {
if (!authData.user) { if (!authData.user) return
navigate('/login')
return
}
setDiets(dietsData) setDiets(dietsData)
// Now fetch full user profile // Fetch full user profile
return fetch(`/api/users/${authData.user.id}`, { credentials: 'include' }) return fetch(`/api/users/${authData.user.id}`, { credentials: 'include' })
.then(r => r.json()) .then(r => r.json())
.then((profile: UserProfile) => { .then((profile: UserProfile) => {
@@ -68,9 +63,8 @@ export function ProfilePage() {
setDietId(diet?.id || null) setDietId(diet?.id || null)
}) })
}) })
.catch(() => navigate('/login'))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, [navigate]) }, [])
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
@@ -108,14 +102,14 @@ export function ProfilePage() {
} }
if (loading) { if (loading) {
return <div style={{ padding: 'var(--space-lg)' }}>Loading...</div> return <div className="page-loading">Loading...</div>
} }
return ( return (
<div style={{ padding: 'var(--space-lg)', maxWidth: '600px', margin: '0 auto' }}> <div className="page" style={{ maxWidth: '600px' }}>
<header style={{ marginBottom: 'var(--space-xl)' }}> <header className="page-header">
<Link to="/" className="text-secondary text-sm"> Back to Dashboard</Link> <h1>Profile</h1>
<h1 style={{ marginTop: 'var(--space-sm)' }}>Profile</h1> <p className="text-secondary">Manage your account and health information</p>
</header> </header>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>

View File

@@ -0,0 +1,41 @@
export function SourcesPage() {
return (
<div className="page">
<header className="page-header">
<h1>Sources</h1>
<p className="text-secondary">Upload and manage your health data sources</p>
</header>
<div className="card">
<h3 style={{ marginBottom: 'var(--space-md)' }}>Upload Data</h3>
<p className="text-secondary text-sm" style={{ marginBottom: 'var(--space-lg)' }}>
Upload lab reports in PDF, CSV, or Excel format to import your biomarker data.
</p>
<div
className="upload-zone"
style={{
border: '2px dashed var(--border)',
borderRadius: 'var(--radius-md)',
padding: 'var(--space-xl)',
textAlign: 'center',
cursor: 'pointer',
}}
>
<div style={{ fontSize: '36px', marginBottom: 'var(--space-sm)' }}>📤</div>
<p className="text-secondary">
Drag & drop files here, or click to browse
</p>
<p className="text-secondary text-xs" style={{ marginTop: 'var(--space-sm)' }}>
Supported: PDF, CSV, XLSX
</p>
</div>
</div>
<div className="card" style={{ marginTop: 'var(--space-lg)' }}>
<h3 style={{ marginBottom: 'var(--space-md)' }}>Recent Uploads</h3>
<p className="text-secondary text-sm">No files uploaded yet.</p>
</div>
</div>
)
}