feat: Implement responsive desktop sidebar navigation and update app structure
This commit is contained in:
parent
922c846b03
commit
7e8422cbd7
|
|
@ -3,7 +3,7 @@
|
|||
> **Gitea:** [#30 – Responsive UI](http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/30)
|
||||
> **Spec:** `.claude/docs/functional/RESPONSIVE_UI.md`
|
||||
> **Breakpoint:** `<1024px` = Mobile (Bottom-Nav, bestehendes Verhalten), `≥1024px` = Desktop (Sidebar 220px)
|
||||
> **Letzte Plan-Aktualisierung:** 2026-04-04
|
||||
> **Letzte Plan-Aktualisierung:** 2026-04-05
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -11,9 +11,9 @@
|
|||
|
||||
| Phase | Titel | Status | Datum / Notiz |
|
||||
|-------|--------|--------|----------------|
|
||||
| P0 | Vorbereitung & Baseline | ☐ pending | |
|
||||
| P1 | App-Shell: Sidebar + Breakpoint + gemeinsame Navigation | ☐ pending | |
|
||||
| P2 | Globales Layout & Content-Bereich (CSS) | ☐ pending | |
|
||||
| P0 | Vorbereitung & Baseline | ☑ erledigt | Spec `RESPONSIVE_UI.md` bereinigt |
|
||||
| P1 | App-Shell: Sidebar + Breakpoint + gemeinsame Navigation | ☑ erledigt | `DesktopSidebar`, `config/appNav.js`, Admin `/admin/*`-Highlight |
|
||||
| P2 | Globales Layout & Content-Bereich (CSS) | ☑ erledigt | Desktop: Header aus, Content max 1200px; Mobile unverändert Bottom-Nav |
|
||||
| P3 | Dashboard (Desktop-Grid) | ☐ pending | |
|
||||
| P4 | Verlauf (Tabs links / Content rechts) | ☐ pending | |
|
||||
| P5 | Analyse (Prompts links / Ergebnis rechts) | ☐ pending | |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect } from 'react'
|
||||
import { BrowserRouter, Routes, Route, NavLink, useNavigate } from 'react-router-dom'
|
||||
import { LayoutDashboard, PlusSquare, TrendingUp, BarChart2, Settings, LogOut } from 'lucide-react'
|
||||
import { BrowserRouter, Routes, Route, NavLink, useLocation } from 'react-router-dom'
|
||||
import { LogOut } from 'lucide-react'
|
||||
import { ProfileProvider, useProfile } from './context/ProfileContext'
|
||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||
import { setProfileId } from './utils/api'
|
||||
|
|
@ -40,21 +40,32 @@ import VitalsPage from './pages/VitalsPage'
|
|||
import GoalsPage from './pages/GoalsPage'
|
||||
import CustomGoalsPage from './pages/CustomGoalsPage'
|
||||
import WorkflowEditorPage from './pages/WorkflowEditorPage'
|
||||
import DesktopSidebar from './components/DesktopSidebar'
|
||||
import { getMainNavItems } from './config/appNav'
|
||||
import './app.css'
|
||||
|
||||
function Nav() {
|
||||
const links = [
|
||||
{ to:'/', icon:<LayoutDashboard size={20}/>, label:'Übersicht' },
|
||||
{ to:'/capture', icon:<PlusSquare size={20}/>, label:'Erfassen' },
|
||||
{ to:'/history', icon:<TrendingUp size={20}/>, label:'Verlauf' },
|
||||
{ to:'/analysis', icon:<BarChart2 size={20}/>, label:'Analyse' },
|
||||
{ to:'/settings', icon:<Settings size={20}/>, label:'Einst.' },
|
||||
]
|
||||
function navItemActive(pathname, item, routerIsActive) {
|
||||
if (item.to.startsWith('/admin')) return pathname.startsWith('/admin')
|
||||
return routerIsActive
|
||||
}
|
||||
|
||||
function Nav({ isAdmin }) {
|
||||
const items = getMainNavItems(isAdmin)
|
||||
const loc = useLocation()
|
||||
return (
|
||||
<nav className="bottom-nav">
|
||||
{links.map(l=>(
|
||||
<NavLink key={l.to} to={l.to} end={l.to==='/'} className={({isActive})=>'nav-item'+(isActive?' active':'')}>
|
||||
{l.icon}<span>{l.label}</span>
|
||||
{items.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={!!item.end}
|
||||
className={({ isActive }) =>
|
||||
'nav-item' +
|
||||
(navItemActive(loc.pathname, item, isActive) ? ' active' : '')
|
||||
}
|
||||
>
|
||||
<item.Icon size={20} strokeWidth={2} />
|
||||
<span>{item.shortLabel || item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
|
@ -62,9 +73,8 @@ function Nav() {
|
|||
}
|
||||
|
||||
function AppShell() {
|
||||
const { session, loading: authLoading, needsSetup, logout } = useAuth()
|
||||
const { activeProfile, loading: profileLoading } = useProfile()
|
||||
const nav = useNavigate()
|
||||
const { session, loading: authLoading, needsSetup, logout, isAdmin } = useAuth()
|
||||
const { activeProfile, loading: profileLoading } = useProfile()
|
||||
|
||||
const handleLogout = () => {
|
||||
if (confirm('Wirklich abmelden?')) {
|
||||
|
|
@ -136,36 +146,56 @@ function AppShell() {
|
|||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="app-header">
|
||||
<span className="app-logo">Mitai Jinkendo</span>
|
||||
<div style={{display:'flex', gap:12, alignItems:'center'}}>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
title="Abmelden"
|
||||
style={{
|
||||
background:'none',
|
||||
border:'none',
|
||||
cursor:'pointer',
|
||||
padding:6,
|
||||
display:'flex',
|
||||
alignItems:'center',
|
||||
color:'var(--text2)',
|
||||
transition:'color 0.15s'
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#D85A30'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text2)'}
|
||||
>
|
||||
<LogOut size={18}/>
|
||||
</button>
|
||||
<NavLink to="/settings" style={{textDecoration:'none'}}>
|
||||
{activeProfile
|
||||
? <Avatar profile={activeProfile} size={30}/>
|
||||
: <div style={{width:30,height:30,borderRadius:'50%',background:'var(--accent)'}}/>
|
||||
}
|
||||
</NavLink>
|
||||
</div>
|
||||
</header>
|
||||
<main className="app-main">
|
||||
<DesktopSidebar
|
||||
isAdmin={isAdmin}
|
||||
activeProfile={activeProfile}
|
||||
sessionProfile={session?.profile}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
<div className="app-shell__column">
|
||||
<header className="app-header app-header--mobile">
|
||||
<span className="app-logo">Mitai Jinkendo</span>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
title="Abmelden"
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 6,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: 'var(--text2)',
|
||||
transition: 'color 0.15s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = '#D85A30'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = 'var(--text2)'
|
||||
}}
|
||||
>
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
<NavLink to="/settings" style={{ textDecoration: 'none' }}>
|
||||
{activeProfile ? (
|
||||
<Avatar profile={activeProfile} size={30} />
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--accent)'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</NavLink>
|
||||
</div>
|
||||
</header>
|
||||
<main className="app-main">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard/>}/>
|
||||
<Route path="/capture" element={<CaptureHub/>}/>
|
||||
|
|
@ -198,8 +228,9 @@ function AppShell() {
|
|||
<Route path="/workflow-editor/:id" element={<WorkflowEditorPage/>}/>
|
||||
<Route path="/subscription" element={<SubscriptionPage/>}/>
|
||||
</Routes>
|
||||
</main>
|
||||
<Nav/>
|
||||
</main>
|
||||
</div>
|
||||
<Nav isAdmin={isAdmin} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -158,3 +158,190 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
|
|||
/* Header with profile avatar */
|
||||
.app-header { display:flex; align-items:center; justify-content:space-between; }
|
||||
.app-header a { display:flex; }
|
||||
|
||||
/* ── Responsive shell: Desktop sidebar (≥1024px) — spec RESPONSIVE_UI.md ───── */
|
||||
.app-shell__column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.desktop-sidebar {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
width: 220px;
|
||||
height: 100vh;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 16px 0 16px;
|
||||
}
|
||||
|
||||
.desktop-sidebar__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.desktop-sidebar__logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid var(--accent);
|
||||
flex-shrink: 0;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.desktop-sidebar__title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.desktop-sidebar__nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 0 12px;
|
||||
}
|
||||
|
||||
.desktop-sidebar__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px 10px 13px;
|
||||
text-decoration: none;
|
||||
color: var(--text2);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-left: 3px solid transparent;
|
||||
border-radius: 0 8px 8px 0;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.desktop-sidebar__link:hover {
|
||||
background: var(--surface2);
|
||||
color: var(--text1);
|
||||
}
|
||||
|
||||
.desktop-sidebar__link.desktop-sidebar__link--active {
|
||||
background: var(--accent-light);
|
||||
color: var(--accent);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
|
||||
.desktop-sidebar__footer {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 16px 12px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.desktop-sidebar__user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.desktop-sidebar__user-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.desktop-sidebar__user-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text1);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.desktop-sidebar__user-tier {
|
||||
font-size: 11px;
|
||||
color: var(--text3);
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.desktop-sidebar__logout {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
color: var(--text3);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.desktop-sidebar__logout:hover {
|
||||
color: var(--danger);
|
||||
background: rgba(216, 90, 48, 0.08);
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.app-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.app-shell {
|
||||
display: block;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.desktop-sidebar {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.app-shell__column {
|
||||
margin-left: 220px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-header--mobile {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.bottom-nav {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
padding: 24px 32px 32px;
|
||||
padding-bottom: max(32px, env(safe-area-inset-bottom, 0px));
|
||||
max-width: 1200px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
84
frontend/src/components/DesktopSidebar.jsx
Normal file
84
frontend/src/components/DesktopSidebar.jsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { NavLink, useLocation } from 'react-router-dom'
|
||||
import { LogOut } from 'lucide-react'
|
||||
import { Avatar } from '../pages/ProfileSelect'
|
||||
import { getMainNavItems } from '../config/appNav'
|
||||
|
||||
function sidebarLinkActive(pathname, item, routerIsActive) {
|
||||
if (item.to.startsWith('/admin')) return pathname.startsWith('/admin')
|
||||
return routerIsActive
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop-Sidebar (≥1024px) — Sichtbarkeit via CSS (.desktop-sidebar).
|
||||
*/
|
||||
export default function DesktopSidebar({
|
||||
isAdmin,
|
||||
activeProfile,
|
||||
sessionProfile,
|
||||
onLogout
|
||||
}) {
|
||||
const loc = useLocation()
|
||||
const items = getMainNavItems(isAdmin)
|
||||
const tier = (activeProfile && activeProfile.tier) || sessionProfile?.tier || ''
|
||||
|
||||
return (
|
||||
<aside className="desktop-sidebar" aria-label="Hauptnavigation">
|
||||
<div className="desktop-sidebar__brand">
|
||||
<div className="desktop-sidebar__logo" aria-hidden />
|
||||
<div className="desktop-sidebar__title">Mitai Jinkendo</div>
|
||||
</div>
|
||||
|
||||
<nav className="desktop-sidebar__nav">
|
||||
{items.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={!!item.end}
|
||||
className={({ isActive }) =>
|
||||
'desktop-sidebar__link' +
|
||||
(sidebarLinkActive(loc.pathname, item, isActive)
|
||||
? ' desktop-sidebar__link--active'
|
||||
: '')
|
||||
}
|
||||
>
|
||||
<item.Icon size={20} strokeWidth={2} />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="desktop-sidebar__footer">
|
||||
<NavLink to="/settings" className="desktop-sidebar__user">
|
||||
{activeProfile ? (
|
||||
<Avatar profile={activeProfile} size={32} />
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--accent)'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="desktop-sidebar__user-text">
|
||||
<span className="desktop-sidebar__user-name">
|
||||
{activeProfile?.name || 'Profil'}
|
||||
</span>
|
||||
{tier ? (
|
||||
<span className="desktop-sidebar__user-tier">{tier}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</NavLink>
|
||||
<button
|
||||
type="button"
|
||||
className="desktop-sidebar__logout"
|
||||
onClick={onLogout}
|
||||
title="Abmelden"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
43
frontend/src/config/appNav.js
Normal file
43
frontend/src/config/appNav.js
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import {
|
||||
LayoutDashboard,
|
||||
PlusSquare,
|
||||
TrendingUp,
|
||||
BarChart2,
|
||||
Settings,
|
||||
Shield
|
||||
} from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Eine Quelle für Hauptnavigation (Bottom-Nav + Desktop-Sidebar).
|
||||
* @typedef {{ to: string, label: string, shortLabel?: string, end?: boolean, Icon: import('react').ForwardRefExoticComponent }} AppNavItem
|
||||
*/
|
||||
|
||||
/** @returns {Omit<AppNavItem, 'Icon'>[]} */
|
||||
function baseItems() {
|
||||
return [
|
||||
{ to: '/', label: 'Übersicht', end: true },
|
||||
{ to: '/capture', label: 'Erfassen' },
|
||||
{ to: '/history', label: 'Verlauf' },
|
||||
{ to: '/analysis', label: 'Analyse' },
|
||||
{ to: '/settings', label: 'Einstellungen', shortLabel: 'Einst.' }
|
||||
]
|
||||
}
|
||||
|
||||
/** @param {boolean} isAdmin */
|
||||
export function getMainNavItems(isAdmin) {
|
||||
const icons = [
|
||||
LayoutDashboard,
|
||||
PlusSquare,
|
||||
TrendingUp,
|
||||
BarChart2,
|
||||
Settings
|
||||
]
|
||||
const raw = baseItems().map((item, i) => ({
|
||||
...item,
|
||||
Icon: icons[i]
|
||||
}))
|
||||
if (isAdmin) {
|
||||
raw.push({ to: '/admin/features', label: 'Admin', Icon: Shield })
|
||||
}
|
||||
return raw
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user