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:
@@ -1,19 +1,29 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { LoginPage } from './pages/Login'
|
||||
import { SignupPage } from './pages/Signup'
|
||||
import { Dashboard } from './pages/Dashboard'
|
||||
import { DashboardPage } from './pages/Dashboard'
|
||||
import { ProfilePage } from './pages/Profile'
|
||||
import { InsightsPage } from './pages/Insights'
|
||||
import { SourcesPage } from './pages/Sources'
|
||||
import { AdminPage } from './pages/Admin'
|
||||
import { Layout } from './components/Layout'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/signup" element={<SignupPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
|
||||
{/* Protected routes with Layout */}
|
||||
<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 />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
148
frontend/src/components/Layout.tsx
Normal file
148
frontend/src/components/Layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -420,4 +420,186 @@ select.input {
|
||||
.biomarker-unit {
|
||||
color: var(--text-secondary);
|
||||
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);
|
||||
}
|
||||
@@ -1,12 +1,4 @@
|
||||
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 {
|
||||
id: number
|
||||
@@ -23,62 +15,22 @@ interface Biomarker {
|
||||
methodology: string | null
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
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'
|
||||
)
|
||||
export function DashboardPage() {
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [biomarkers, setBiomarkers] = useState<Biomarker[]>([])
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<number>>(new Set())
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
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([
|
||||
fetch('/api/categories', { credentials: 'include' }).then(r => r.json()),
|
||||
fetch('/api/biomarkers', { credentials: 'include' }).then(r => r.json()),
|
||||
]).then(([cats, bms]) => {
|
||||
setCategories(cats)
|
||||
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) => {
|
||||
setExpandedCategories(prev => {
|
||||
@@ -97,69 +49,18 @@ export function Dashboard() {
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ padding: 'var(--space-lg)' }}>
|
||||
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-xl)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)' }}>
|
||||
<img src="/logo.svg" alt="zhealth" style={{ width: '32px', height: '32px' }} />
|
||||
<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>
|
||||
<div className="page">
|
||||
<header className="page-header">
|
||||
<h1>Dashboard</h1>
|
||||
<p className="text-secondary">View all biomarker categories and their reference markers</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{/* User Welcome Card */}
|
||||
<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>
|
||||
<section>
|
||||
<h2 style={{ marginBottom: 'var(--space-md)' }}>Biomarker Categories</h2>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-sm)' }}>
|
||||
{categories.map(category => {
|
||||
const categoryBiomarkers = getBiomarkersForCategory(category.id)
|
||||
@@ -167,7 +68,6 @@ export function Dashboard() {
|
||||
|
||||
return (
|
||||
<div key={category.id} className="card" style={{ padding: 0 }}>
|
||||
{/* Category Header - Clickable */}
|
||||
<button
|
||||
className="collapsible-header"
|
||||
onClick={() => toggleCategory(category.id)}
|
||||
@@ -195,7 +95,6 @@ export function Dashboard() {
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Biomarkers List - Collapsible */}
|
||||
{isExpanded && (
|
||||
<div style={{ borderTop: '1px solid var(--border)', padding: 'var(--space-sm)' }}>
|
||||
{categoryBiomarkers.length === 0 ? (
|
||||
@@ -222,7 +121,7 @@ export function Dashboard() {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
18
frontend/src/pages/Insights.tsx
Normal file
18
frontend/src/pages/Insights.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
|
||||
interface Diet {
|
||||
id: number
|
||||
@@ -21,7 +20,6 @@ interface UserProfile {
|
||||
}
|
||||
|
||||
export function ProfilePage() {
|
||||
const navigate = useNavigate()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch current user and diets
|
||||
// Fetch current user and diets (Layout already ensures auth)
|
||||
Promise.all([
|
||||
fetch('/api/auth/me', { credentials: 'include' }).then(r => r.json()),
|
||||
fetch('/api/diets', { credentials: 'include' }).then(r => r.json()),
|
||||
])
|
||||
.then(([authData, dietsData]) => {
|
||||
if (!authData.user) {
|
||||
navigate('/login')
|
||||
return
|
||||
}
|
||||
if (!authData.user) return
|
||||
setDiets(dietsData)
|
||||
|
||||
// Now fetch full user profile
|
||||
// Fetch full user profile
|
||||
return fetch(`/api/users/${authData.user.id}`, { credentials: 'include' })
|
||||
.then(r => r.json())
|
||||
.then((profile: UserProfile) => {
|
||||
@@ -68,9 +63,8 @@ export function ProfilePage() {
|
||||
setDietId(diet?.id || null)
|
||||
})
|
||||
})
|
||||
.catch(() => navigate('/login'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [navigate])
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -108,14 +102,14 @@ export function ProfilePage() {
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ padding: 'var(--space-lg)' }}>Loading...</div>
|
||||
return <div className="page-loading">Loading...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 'var(--space-lg)', maxWidth: '600px', margin: '0 auto' }}>
|
||||
<header style={{ marginBottom: 'var(--space-xl)' }}>
|
||||
<Link to="/" className="text-secondary text-sm">← Back to Dashboard</Link>
|
||||
<h1 style={{ marginTop: 'var(--space-sm)' }}>Profile</h1>
|
||||
<div className="page" style={{ maxWidth: '600px' }}>
|
||||
<header className="page-header">
|
||||
<h1>Profile</h1>
|
||||
<p className="text-secondary">Manage your account and health information</p>
|
||||
</header>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
|
||||
41
frontend/src/pages/Sources.tsx
Normal file
41
frontend/src/pages/Sources.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user