feat: Implement responsive analysis page layout with horizontal navigation for mobile and vertical navigation for desktop
This commit is contained in:
parent
ac31c5e014
commit
1fa0edb3b5
|
|
@ -3,7 +3,7 @@
|
||||||
> **Gitea:** [#30 – Responsive UI](http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/30)
|
> **Gitea:** [#30 – Responsive UI](http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/30)
|
||||||
> **Spec:** `.claude/docs/functional/RESPONSIVE_UI.md`
|
> **Spec:** `.claude/docs/functional/RESPONSIVE_UI.md`
|
||||||
> **Breakpoint:** `<1024px` = Mobile (Bottom-Nav, bestehendes Verhalten), `≥1024px` = Desktop (Sidebar 220px)
|
> **Breakpoint:** `<1024px` = Mobile (Bottom-Nav, bestehendes Verhalten), `≥1024px` = Desktop (Sidebar 220px)
|
||||||
> **Letzte Plan-Aktualisierung:** 2026-04-04
|
> **Letzte Plan-Aktualisierung:** 2026-04-04 (P5)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
| P2 | Globales Layout & Content-Bereich (CSS) | ☑ erledigt | Desktop: Header aus, Content max 1200px; Mobile unverändert Bottom-Nav |
|
| P2 | Globales Layout & Content-Bereich (CSS) | ☑ erledigt | Desktop: Header aus, Content max 1200px; Mobile unverändert Bottom-Nav |
|
||||||
| P3 | Dashboard (Desktop-Grid) | ☑ erledigt | 4-spaltige Kennzahlen; Begrüßung; Ernährung/Aktivität 2-spaltig |
|
| P3 | Dashboard (Desktop-Grid) | ☑ erledigt | 4-spaltige Kennzahlen; Begrüßung; Ernährung/Aktivität 2-spaltig |
|
||||||
| P4 | Verlauf (Tabs links / Content rechts) | ☑ erledigt | `History.jsx` + `.history-*` in `app.css`; Tab-State bei `location.state.tab` |
|
| P4 | Verlauf (Tabs links / Content rechts) | ☑ erledigt | `History.jsx` + `.history-*` in `app.css`; Tab-State bei `location.state.tab` |
|
||||||
| P5 | Analyse (Prompts links / Ergebnis rechts) | ☐ pending | |
|
| P5 | Analyse (Prompts links / Ergebnis rechts) | ☑ erledigt | `Analysis.jsx` + `.analysis-split*` in `app.css` |
|
||||||
| P6 | Erfassung / Capture & Formularseiten | ☐ pending | |
|
| P6 | Erfassung / Capture & Formularseiten | ☐ pending | |
|
||||||
| P7 | Admin & restliche Vollbreiten-Seiten | ☐ pending | |
|
| P7 | Admin & restliche Vollbreiten-Seiten | ☐ pending | |
|
||||||
| P8 | Abschluss, Regression, Spec-Pflege | ☐ pending | |
|
| P8 | Abschluss, Regression, Spec-Pflege | ☐ pending | |
|
||||||
|
|
|
||||||
|
|
@ -225,6 +225,113 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* KI-Analyse (P5): Mobile Prompt-Leiste oben / horizontal, Desktop links ~300px (RESPONSIVE_UI §5.3) */
|
||||||
|
.analysis-page__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-split {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-split__nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-split__nav::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-split__nav-item {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1.5px solid var(--border2);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text2);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-split__nav-item:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--text1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-split__nav-item--active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-split__nav-item--active:hover {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-split__nav-item--active .muted {
|
||||||
|
color: rgba(255, 255, 255, 0.88) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-split__main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.analysis-split {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-split__nav-wrap {
|
||||||
|
flex: 0 0 300px;
|
||||||
|
max-width: 320px;
|
||||||
|
position: sticky;
|
||||||
|
top: 16px;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-split__nav {
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-x: visible;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: calc(100vh - 140px);
|
||||||
|
padding-bottom: 0;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-split__nav-item {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: 10px;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-split__main {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.muted { color: var(--text3); font-size: 13px; }
|
.muted { color: var(--text3); font-size: 13px; }
|
||||||
.empty-state { text-align: center; padding: 48px 16px; color: var(--text3); }
|
.empty-state { text-align: center; padding: 48px 16px; color: var(--text3); }
|
||||||
.empty-state h3 { font-size: 16px; color: var(--text2); margin-bottom: 6px; }
|
.empty-state h3 { font-size: 16px; color: var(--text2); margin-bottom: 6px; }
|
||||||
|
|
|
||||||
|
|
@ -286,6 +286,8 @@ export default function Analysis() {
|
||||||
const [tab, setTab] = useState('run')
|
const [tab, setTab] = useState('run')
|
||||||
const [newResult, setNewResult] = useState(null)
|
const [newResult, setNewResult] = useState(null)
|
||||||
const [aiUsage, setAiUsage] = useState(null)
|
const [aiUsage, setAiUsage] = useState(null)
|
||||||
|
const [activePipelineSlug, setActivePipelineSlug] = useState(null)
|
||||||
|
const [historyScopePick, setHistoryScopePick] = useState(null)
|
||||||
|
|
||||||
const loadAll = async () => {
|
const loadAll = async () => {
|
||||||
const [p, i] = await Promise.all([
|
const [p, i] = await Promise.all([
|
||||||
|
|
@ -305,6 +307,19 @@ export default function Analysis() {
|
||||||
}).catch(err => console.error('Failed to load usage:', err))
|
}).catch(err => console.error('Failed to load usage:', err))
|
||||||
},[])
|
},[])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const list = prompts.filter(p => p.active && p.type === 'pipeline')
|
||||||
|
setActivePipelineSlug(prev => {
|
||||||
|
if (!list.length) return null
|
||||||
|
if (prev && list.some(p => p.slug === prev)) return prev
|
||||||
|
return list[0].slug
|
||||||
|
})
|
||||||
|
}, [prompts])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (newResult?.scope) setActivePipelineSlug(newResult.scope)
|
||||||
|
}, [newResult?.scope])
|
||||||
|
|
||||||
const runPrompt = async (slug) => {
|
const runPrompt = async (slug) => {
|
||||||
setLoading(slug); setError(null); setNewResult(null)
|
setLoading(slug); setError(null); setNewResult(null)
|
||||||
try {
|
try {
|
||||||
|
|
@ -386,11 +401,20 @@ export default function Analysis() {
|
||||||
// Show only active pipeline-type prompts
|
// Show only active pipeline-type prompts
|
||||||
const pipelinePrompts = prompts.filter(p => p.active && p.type === 'pipeline')
|
const pipelinePrompts = prompts.filter(p => p.active && p.type === 'pipeline')
|
||||||
|
|
||||||
|
const historyScopeKeys = Object.keys(grouped).sort((a, b) => a.localeCompare(b))
|
||||||
|
const activeHistoryScope =
|
||||||
|
historyScopeKeys.length === 0
|
||||||
|
? null
|
||||||
|
: historyScopeKeys.includes(historyScopePick)
|
||||||
|
? historyScopePick
|
||||||
|
: historyScopeKeys[0]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="analysis-page">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
<div className="analysis-page__header">
|
||||||
<h1 className="page-title" style={{ margin: 0 }}>KI-Analyse</h1>
|
<h1 className="page-title" style={{ margin: 0 }}>KI-Analyse</h1>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
onClick={() => navigate('/goals')}
|
onClick={() => navigate('/goals')}
|
||||||
style={{ fontSize: 13, padding: '6px 12px' }}
|
style={{ fontSize: 13, padding: '6px 12px' }}
|
||||||
|
|
@ -400,8 +424,8 @@ export default function Analysis() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="tabs">
|
<div className="tabs">
|
||||||
<button className={'tab'+(tab==='run'?' active':'')} onClick={()=>setTab('run')}>Analysen starten</button>
|
<button type="button" className={'tab'+(tab==='run'?' active':'')} onClick={()=>setTab('run')}>Analysen starten</button>
|
||||||
<button className={'tab'+(tab==='history'?' active':'')} onClick={()=>setTab('history')}>
|
<button type="button" className={'tab'+(tab==='history'?' active':'')} onClick={()=>setTab('history')}>
|
||||||
Verlauf
|
Verlauf
|
||||||
{allInsights.length>0 && <span style={{marginLeft:4,fontSize:10,background:'var(--accent)',
|
{allInsights.length>0 && <span style={{marginLeft:4,fontSize:10,background:'var(--accent)',
|
||||||
color:'white',padding:'1px 5px',borderRadius:8}}>{allInsights.length}</span>}
|
color:'white',padding:'1px 5px',borderRadius:8}}>{allInsights.length}</span>}
|
||||||
|
|
@ -452,17 +476,43 @@ export default function Analysis() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canUseAI && pipelinePrompts.length > 0 && (
|
{canUseAI && pipelinePrompts.length > 0 && (
|
||||||
|
<>
|
||||||
<p style={{fontSize:13,color:'var(--text2)',marginBottom:14,lineHeight:1.6}}>
|
<p style={{fontSize:13,color:'var(--text2)',marginBottom:14,lineHeight:1.6}}>
|
||||||
Wähle eine mehrstufige KI-Analyse:
|
Wähle in der Liste eine Analyse; auf dem Desktop erscheint der Detailbereich rechts, auf schmalen Screens darunter.
|
||||||
</p>
|
</p>
|
||||||
)}
|
<div className="analysis-split">
|
||||||
|
<div className="analysis-split__nav-wrap">
|
||||||
|
<nav className="analysis-split__nav" aria-label="Verfügbare KI-Analysen">
|
||||||
{pipelinePrompts.map(p => {
|
{pipelinePrompts.map(p => {
|
||||||
const existing = allInsights.find(i=>i.scope===p.slug)
|
const existing = allInsights.find(i => i.scope === p.slug)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
className={'analysis-split__nav-item' + (activePipelineSlug === p.slug ? ' analysis-split__nav-item--active' : '')}
|
||||||
|
onClick={() => setActivePipelineSlug(p.slug)}
|
||||||
|
aria-current={activePipelineSlug === p.slug ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
{p.display_name || SLUG_LABELS[p.slug] || p.name}
|
||||||
|
{existing && (
|
||||||
|
<span className="muted" style={{ fontSize: 11, fontWeight: 400 }}>
|
||||||
|
{' '}· {dayjs(existing.created).format('DD.MM.')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div className="analysis-split__main">
|
||||||
|
{activePipelineSlug && (() => {
|
||||||
|
const p = pipelinePrompts.find(x => x.slug === activePipelineSlug)
|
||||||
|
if (!p) return null
|
||||||
|
const existing = allInsights.find(i => i.scope === p.slug)
|
||||||
return (
|
return (
|
||||||
<div key={p.id} className="card" style={{marginBottom:16,borderColor:'var(--accent)',borderWidth:2}}>
|
<div key={p.id} className="card" style={{marginBottom:16,borderColor:'var(--accent)',borderWidth:2}}>
|
||||||
<div style={{display:'flex',alignItems:'flex-start',gap:12}}>
|
<div style={{display:'flex',alignItems:'flex-start',gap:12,flexWrap:'wrap'}}>
|
||||||
<div style={{flex:1}}>
|
<div style={{flex:1,minWidth:0}}>
|
||||||
<div className="badge-container-right" style={{fontWeight:700,fontSize:15,color:'var(--accent)'}}>
|
<div className="badge-container-right" style={{fontWeight:700,fontSize:15,color:'var(--accent)'}}>
|
||||||
<span>{p.display_name || SLUG_LABELS[p.slug] || p.name}</span>
|
<span>{p.display_name || SLUG_LABELS[p.slug] || p.name}</span>
|
||||||
{aiUsage && <UsageBadge {...aiUsage} />}
|
{aiUsage && <UsageBadge {...aiUsage} />}
|
||||||
|
|
@ -483,9 +533,10 @@ export default function Analysis() {
|
||||||
style={{display:'inline-block'}}
|
style={{display:'inline-block'}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
style={{flexShrink:0,minWidth:100, cursor: (aiUsage && !aiUsage.allowed) ? 'not-allowed' : 'pointer'}}
|
style={{flexShrink:0,minWidth:100, cursor: (aiUsage && !aiUsage.allowed) ? 'not-allowed' : 'pointer'}}
|
||||||
onClick={()=>runPrompt(p.slug)}
|
onClick={() => runPrompt(p.slug)}
|
||||||
disabled={!!loading||!canUseAI||(aiUsage && !aiUsage.allowed)}
|
disabled={!!loading||!canUseAI||(aiUsage && !aiUsage.allowed)}
|
||||||
>
|
>
|
||||||
{loading===p.slug
|
{loading===p.slug
|
||||||
|
|
@ -495,7 +546,6 @@ export default function Analysis() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Show existing result collapsed */}
|
|
||||||
{existing && newResult?.id !== existing.id && (
|
{existing && newResult?.id !== existing.id && (
|
||||||
<div style={{marginTop:8,borderTop:'1px solid var(--border)',paddingTop:8}}>
|
<div style={{marginTop:8,borderTop:'1px solid var(--border)',paddingTop:8}}>
|
||||||
<InsightCard ins={existing} onDelete={deleteInsight} defaultOpen={false} prompts={prompts}/>
|
<InsightCard ins={existing} onDelete={deleteInsight} defaultOpen={false} prompts={prompts}/>
|
||||||
|
|
@ -503,7 +553,11 @@ export default function Analysis() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{canUseAI && pipelinePrompts.length === 0 && (
|
{canUseAI && pipelinePrompts.length === 0 && (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
|
|
@ -519,18 +573,33 @@ export default function Analysis() {
|
||||||
{/* ── Verlauf gruppiert ── */}
|
{/* ── Verlauf gruppiert ── */}
|
||||||
{tab==='history' && (
|
{tab==='history' && (
|
||||||
<div>
|
<div>
|
||||||
{allInsights.length===0
|
{allInsights.length===0 ? (
|
||||||
? <div className="empty-state"><h3>Noch keine Analysen</h3></div>
|
<div className="empty-state"><h3>Noch keine Analysen</h3></div>
|
||||||
: Object.entries(grouped).map(([scope, ins]) => (
|
) : (
|
||||||
<div key={scope} style={{marginBottom:20}}>
|
<div className="analysis-split">
|
||||||
<div style={{fontSize:13,fontWeight:700,color:'var(--text3)',
|
<div className="analysis-split__nav-wrap">
|
||||||
textTransform:'uppercase',letterSpacing:'0.05em',marginBottom:8}}>
|
<nav className="analysis-split__nav" aria-label="Gespeicherte Analysen">
|
||||||
{prompts.find(p => p.slug === scope)?.display_name || SLUG_LABELS[scope] || scope} ({ins.length})
|
{historyScopeKeys.map(scope => (
|
||||||
|
<button
|
||||||
|
key={scope}
|
||||||
|
type="button"
|
||||||
|
className={'analysis-split__nav-item' + (activeHistoryScope === scope ? ' analysis-split__nav-item--active' : '')}
|
||||||
|
onClick={() => setHistoryScopePick(scope)}
|
||||||
|
aria-current={activeHistoryScope === scope ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
{prompts.find(pr => pr.slug === scope)?.display_name || SLUG_LABELS[scope] || scope}
|
||||||
|
<span className="muted" style={{ fontSize: 12 }}> ({grouped[scope].length})</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
{ins.map(i => <InsightCard key={i.id} ins={i} onDelete={deleteInsight} prompts={prompts}/>)}
|
<div className="analysis-split__main">
|
||||||
|
{activeHistoryScope && grouped[activeHistoryScope]?.map(i => (
|
||||||
|
<InsightCard key={i.id} ins={i} onDelete={deleteInsight} prompts={prompts}/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))
|
</div>
|
||||||
}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user