feat: Implement responsive desktop sidebar navigation and update app structure
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s

This commit is contained in:
Lars 2026-04-05 07:58:38 +02:00
parent 922c846b03
commit 7e8422cbd7
5 changed files with 397 additions and 52 deletions

View File

@ -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 | |

View File

@ -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>
)
}

View File

@ -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;
}
}

View 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>
)
}

View 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
}