UX. refactor: simplify AdminPageNav component by removing unused hooks and improving styling
Some checks failed
Deploy Development / deploy (push) Successful in 35s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 28s

- Removed the useLocation hook as it was unnecessary for the component's functionality.
- Updated the navigation styling to use CSS classes instead of inline styles, enhancing maintainability and readability.
- Improved accessibility by adding aria-labels to navigation elements.
This commit is contained in:
Lars 2026-05-06 10:37:01 +02:00
parent 2007f3f659
commit 14884e6e55
6 changed files with 255 additions and 221 deletions

View File

@ -1,7 +1,7 @@
:root { :root {
--bg: #f4f3ef; --bg: #f6f5f0;
--surface: #ffffff; --surface: #ffffff;
--surface2: #f9f8f5; --surface2: #fafaf6;
--border: rgba(0,0,0,0.09); --border: rgba(0,0,0,0.09);
--border2: rgba(0,0,0,0.16); --border2: rgba(0,0,0,0.16);
--text1: #1c1b18; --text1: #1c1b18;
@ -1075,6 +1075,15 @@ a.analysis-split__nav-item {
} }
} }
button.capture-shell__nav-item {
font-family: inherit;
text-align: left;
-webkit-tap-highlight-color: transparent;
}
.capture-shell__nav-item svg.capture-shell__nav-icon {
flex-shrink: 0;
}
/* Einstellungen: gleiche Split-Struktur wie Analyse/Admin */ /* Einstellungen: gleiche Split-Struktur wie Analyse/Admin */
.settings-shell { .settings-shell {
width: 100%; width: 100%;
@ -1135,6 +1144,108 @@ a.analysis-split__nav-item {
background: var(--surface2); background: var(--surface2);
} }
/* Admin: horizontale Seiten-Weiche (Hierarchie · Nutzer · …) */
.admin-top-nav {
display: flex;
gap: 8px;
border-bottom: 2px solid var(--border);
margin-bottom: 24px;
flex-wrap: wrap;
}
.admin-top-nav__link {
padding: 12px 18px;
background: transparent;
border: none;
border-bottom: 3px solid transparent;
margin-bottom: -2px;
cursor: pointer;
font-size: 15px;
font-weight: 500;
color: var(--text2);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 8px;
transition: color 0.15s, background 0.15s, border-color 0.15s;
font-family: inherit;
border-radius: 8px 8px 0 0;
box-sizing: border-box;
}
.admin-top-nav__link:hover {
color: var(--text1);
background: var(--surface2);
}
.admin-top-nav__link--active {
color: var(--accent);
border-bottom-color: var(--accent);
background: transparent;
}
/* Trainingsplanung: kompakte Segmente (Gruppe / Verein) */
.planning-segment-group {
display: inline-flex;
border-radius: 10px;
border: 1.5px solid var(--border2);
overflow: hidden;
background: var(--surface2);
}
.planning-segment-group__btn {
border: none;
padding: 8px 14px;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
font-family: inherit;
background: transparent;
color: var(--text1);
white-space: nowrap;
transition: background 0.12s, color 0.12s;
}
.planning-segment-group__btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.planning-segment-group__btn--active {
background: var(--accent);
color: #fff;
}
.planning-segment-group__btn:not(:first-child) {
border-left: 1.5px solid var(--border2);
}
/* Ausklappbare Kontext-Hilfe (Filterzeile Planung) */
.planning-filter-help {
flex: 1 1 100%;
margin-top: 4px;
max-width: 100%;
}
.planning-filter-help__summary {
cursor: pointer;
font-size: 0.8rem;
font-weight: 600;
color: var(--accent-dark);
list-style: none;
user-select: none;
}
.planning-filter-help__summary::-webkit-details-marker {
display: none;
}
.planning-filter-help__body {
margin-top: 10px;
padding: 12px 14px;
font-size: 0.82rem;
line-height: 1.5;
color: var(--text2);
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 10px;
}
@media (prefers-color-scheme: dark) {
.planning-filter-help__summary {
color: var(--accent);
}
}
/* Admin: Split-Layout wie .analysis-split (nur Gruppen in der Nav) */ /* Admin: Split-Layout wie .analysis-split (nur Gruppen in der Nav) */
.admin-shell { .admin-shell {
width: 100%; width: 100%;

View File

@ -1,4 +1,4 @@
import { NavLink, useLocation } from 'react-router-dom' import { NavLink } from 'react-router-dom'
import { TreePine, FolderTree, Download, Grid3x3, Users } from 'lucide-react' import { TreePine, FolderTree, Download, Grid3x3, Users } from 'lucide-react'
/** /**
@ -6,8 +6,6 @@ import { TreePine, FolderTree, Download, Grid3x3, Users } from 'lucide-react'
* Wechselt zwischen verschiedenen Admin-Seiten * Wechselt zwischen verschiedenen Admin-Seiten
*/ */
export default function AdminPageNav() { export default function AdminPageNav() {
const location = useLocation()
const pages = [ const pages = [
{ to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine }, { to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine },
{ to: '/admin/users', label: 'Nutzer', icon: Users }, { to: '/admin/users', label: 'Nutzer', icon: Users },
@ -17,51 +15,18 @@ export default function AdminPageNav() {
] ]
return ( return (
<nav style={{ <nav className="admin-top-nav" aria-label="Administration">
display: 'flex', {pages.map((page) => {
gap: '8px',
borderBottom: '2px solid var(--border)',
marginBottom: '24px',
flexWrap: 'wrap'
}}>
{pages.map(page => {
const Icon = page.icon const Icon = page.icon
const isActive = location.pathname === page.to
return ( return (
<NavLink <NavLink
key={page.to} key={page.to}
to={page.to} to={page.to}
style={{ className={({ isActive }) =>
padding: '12px 20px', 'admin-top-nav__link' + (isActive ? ' admin-top-nav__link--active' : '')
background: 'transparent',
border: 'none',
borderBottom: '3px solid transparent',
cursor: 'pointer',
fontSize: '16px',
fontWeight: 500,
color: isActive ? 'var(--accent)' : 'var(--text2)',
borderBottomColor: isActive ? 'var(--accent)' : 'transparent',
textDecoration: 'none',
display: 'flex',
alignItems: 'center',
gap: '8px',
transition: 'all 0.2s'
}}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.color = 'var(--text1)'
e.currentTarget.style.background = 'var(--surface2)'
} }
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.color = 'var(--text2)'
e.currentTarget.style.background = 'transparent'
}
}}
> >
<Icon size={18} /> <Icon size={18} strokeWidth={2} aria-hidden />
<span>{page.label}</span> <span>{page.label}</span>
</NavLink> </NavLink>
) )

View File

@ -0,0 +1,50 @@
/**
* Einheitliche Sub-Navigation (Jinkendo-Muster):
* Mobil = horizontale Chips, Desktop 1024px = linke Spalte (sticky).
* Nutzt .capture-shell* aus app.css.
*/
export default function AppSubnavShell({
ariaLabel,
items,
value,
onChange,
children,
iconSize = 18,
}) {
return (
<div className="capture-shell app-subnav-shell">
<div className="capture-shell__layout">
<div className="capture-shell__nav-wrap">
<nav className="capture-shell__nav" aria-label={ariaLabel}>
{items.map((item) => {
const Icon = item.icon
const active = value === item.id
return (
<button
key={item.id}
type="button"
className={
'capture-shell__nav-item' +
(active ? ' capture-shell__nav-item--active' : '')
}
onClick={() => onChange(item.id)}
>
{Icon ? (
<Icon
className="capture-shell__nav-icon"
size={iconSize}
strokeWidth={2}
aria-hidden
/>
) : null}
<span className="capture-shell__nav-label">{item.label}</span>
</button>
)
})}
</nav>
</div>
<div className="capture-shell__main">{children}</div>
</div>
</div>
)
}

View File

@ -1,6 +1,8 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { TreePine, FolderTree, Link2 } from 'lucide-react'
import { api } from '../utils/api' import { api } from '../utils/api'
import AdminPageNav from '../components/AdminPageNav' import AdminPageNav from '../components/AdminPageNav'
import AppSubnavShell from '../components/AppSubnavShell'
import HierarchyTab from '../components/admin/HierarchyTab' import HierarchyTab from '../components/admin/HierarchyTab'
import CatalogsTab from '../components/admin/CatalogsTab' import CatalogsTab from '../components/admin/CatalogsTab'
import AssignmentsTab from '../components/admin/AssignmentsTab' import AssignmentsTab from '../components/admin/AssignmentsTab'
@ -10,17 +12,14 @@ function AdminHierarchyPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
// Hierarchy Tab State
const [hierarchy, setHierarchy] = useState([]) const [hierarchy, setHierarchy] = useState([])
const [expandedNodes, setExpandedNodes] = useState(new Set()) const [expandedNodes, setExpandedNodes] = useState(new Set())
const [selectedItem, setSelectedItem] = useState(null) const [selectedItem, setSelectedItem] = useState(null)
// Catalogs Tab State
const [targetGroups, setTargetGroups] = useState([]) const [targetGroups, setTargetGroups] = useState([])
const [skillCategories, setSkillCategories] = useState([]) const [skillCategories, setSkillCategories] = useState([])
const [trainingCharacters, setTrainingCharacters] = useState([]) const [trainingCharacters, setTrainingCharacters] = useState([])
// Assignments Tab State
const [styleDirections, setStyleDirections] = useState([]) const [styleDirections, setStyleDirections] = useState([])
const [assignments, setAssignments] = useState([]) const [assignments, setAssignments] = useState([])
@ -62,7 +61,7 @@ function AdminHierarchyPage() {
} }
function handleToggleNode(nodeId) { function handleToggleNode(nodeId) {
setExpandedNodes(prev => { setExpandedNodes((prev) => {
const newSet = new Set(prev) const newSet = new Set(prev)
if (newSet.has(nodeId)) { if (newSet.has(nodeId)) {
newSet.delete(nodeId) newSet.delete(nodeId)
@ -86,33 +85,26 @@ function AdminHierarchyPage() {
loadData() loadData()
} }
const tabs = [ const subnavItems = [
{ id: 'hierarchy', label: '🌳 Hierarchie', icon: '🌳' }, { id: 'hierarchy', label: 'Hierarchie', icon: TreePine },
{ id: 'catalogs', label: '📋 Kataloge', icon: '📋' }, { id: 'catalogs', label: 'Kataloge', icon: FolderTree },
{ id: 'assignments', label: '🔗 Zuordnungen', icon: '🔗' } { id: 'assignments', label: 'Zuordnungen', icon: Link2 }
] ]
return ( return (
<div className="app-page"> <div className="app-page admin-hierarchy-page">
<AdminPageNav /> <AdminPageNav />
<h1 style={{ marginTop: 0 }}>Admin: Katalog-Hierarchie</h1> <h1 className="page-title" style={{ marginBottom: '12px' }}>
Katalog &amp; Hierarchie
</h1>
{/* Tab Navigation */} <AppSubnavShell
<div className="tab-navigation"> ariaLabel="Bereich Katalogadministration"
{tabs.map(tab => ( items={subnavItems}
<button value={activeTab}
key={tab.id} onChange={setActiveTab}
className={activeTab === tab.id ? 'tab-button active' : 'tab-button'}
onClick={() => setActiveTab(tab.id)}
> >
{tab.icon} {tab.label}
</button>
))}
</div>
{/* Tab Content */}
<div style={{ marginTop: '20px' }}>
{activeTab === 'hierarchy' && ( {activeTab === 'hierarchy' && (
<HierarchyTab <HierarchyTab
hierarchy={hierarchy} hierarchy={hierarchy}
@ -147,48 +139,7 @@ function AdminHierarchyPage() {
onUpdate={handleUpdate} onUpdate={handleUpdate}
/> />
)} )}
</div> </AppSubnavShell>
<style>{`
.tab-navigation {
display: flex;
gap: 8px;
border-bottom: 2px solid var(--border);
margin-bottom: 20px;
flex-wrap: wrap;
}
.tab-button {
padding: 12px 20px;
background: transparent;
border: none;
border-bottom: 3px solid transparent;
cursor: pointer;
font-size: 16px;
font-weight: 500;
color: var(--text2);
transition: all 0.2s;
}
.tab-button:hover {
color: var(--text1);
background: var(--surface2);
}
.tab-button.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
@media (max-width: 768px) {
.tab-button {
flex: 1 1 auto;
min-width: 120px;
font-size: 14px;
padding: 10px 12px;
}
}
`}</style>
</div> </div>
) )
} }

View File

@ -106,27 +106,28 @@ function Dashboard() {
} }
return ( return (
<div className="app-page"> <div className="app-page dashboard-page">
<h1>Dashboard</h1> <div className="dashboard-greeting">
<p style={{ color: 'var(--text2)', marginTop: '0.5rem' }}> <div>
Willkommen, {user?.name || user?.email}! <h1 className="page-title" style={{ marginBottom: '6px' }}>
</p> Dashboard
{profile && <EmailVerificationBanner profile={profile} />} </h1>
{/* Welcome Card */} <p className="muted" style={{ marginTop: 0 }}>
<div className="card" style={{ marginTop: '1.5rem', marginBottom: '1.5rem' }}> Willkommen, {user?.name || user?.email}! Shinkan unterstützt dich bei Übungen, Planung und Vereinsstruktur.
<h2>Willkommen bei Shinkan Jinkendo</h2>
<p style={{ color: 'var(--text2)' }}>
Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung
</p> </p>
</div> </div>
</div>
{profile && <EmailVerificationBanner profile={profile} />}
{user?.id && ( {user?.id && (
<div <div
className="dashboard-training-grid"
style={{ style={{
display: 'grid', display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 280px), 1fr))', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 280px), 1fr))',
gap: '1rem', gap: '1rem',
marginBottom: '1.5rem' alignItems: 'stretch',
marginBottom: '1.5rem',
}} }}
> >
<div className="card"> <div className="card">
@ -216,43 +217,6 @@ function Dashboard() {
</div> </div>
)} )}
{/* Status Grid */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 260px), 1fr))',
gap: '1rem',
marginBottom: '1.5rem'
}}>
<div className="card">
<h3> Fertig</h3>
<ul style={{ marginTop: '1rem', paddingLeft: '1.5rem' }}>
<li>Backend-Basis</li>
<li>Datenbank-Schema</li>
<li>Auth-System</li>
<li>Login & Registrierung</li>
</ul>
</div>
<div className="card">
<h3>🚧 In Arbeit</h3>
<ul style={{ marginTop: '1rem', paddingLeft: '1.5rem' }}>
<li>Übungsverwaltung</li>
<li>Trainingsplanung</li>
<li>Kataloge (Skills, Methods)</li>
</ul>
</div>
<div className="card">
<h3>📋 Geplant</h3>
<ul style={{ marginTop: '1rem', paddingLeft: '1.5rem' }}>
<li>MediaWiki-Import</li>
<li>Trainingsprogramme</li>
<li>Admin-Panel</li>
</ul>
</div>
</div>
{/* System Info */}
{version && ( {version && (
<div className="card"> <div className="card">
<h3>System-Information</h3> <h3>System-Information</h3>

View File

@ -1067,53 +1067,28 @@ function TrainingPlanningPage() {
<span className="form-label" style={{ marginBottom: 0, alignSelf: 'center' }}> <span className="form-label" style={{ marginBottom: 0, alignSelf: 'center' }}>
Einblenden Einblenden
</span> </span>
<div <div className="planning-segment-group" role="group" aria-label="Gruppe oder ganzer Verein">
role="group"
aria-label="Gruppe oder ganzer Verein"
style={{
display: 'inline-flex',
borderRadius: '10px',
border: '1.5px solid var(--border2)',
overflow: 'hidden',
background: 'var(--surface2)',
}}
>
<button <button
type="button" type="button"
className={
'planning-segment-group__btn' +
(planScope === 'group' ? ' planning-segment-group__btn--active' : '')
}
aria-pressed={planScope === 'group'} aria-pressed={planScope === 'group'}
disabled={!selectedGroupId} disabled={!selectedGroupId}
onClick={() => setPlanScope('group')} onClick={() => setPlanScope('group')}
style={{
border: 'none',
padding: '8px 14px',
fontWeight: 600,
fontSize: '0.85rem',
cursor: selectedGroupId ? 'pointer' : 'not-allowed',
opacity: selectedGroupId ? 1 : 0.55,
background: planScope === 'group' ? 'var(--accent-dark)' : 'transparent',
color: planScope === 'group' ? '#fff' : 'var(--text1)',
whiteSpace: 'nowrap',
}}
> >
Nur diese Gruppe Nur diese Gruppe
</button> </button>
<button <button
type="button" type="button"
className={
'planning-segment-group__btn' +
(planScope === 'club' ? ' planning-segment-group__btn--active' : '')
}
aria-pressed={planScope === 'club'} aria-pressed={planScope === 'club'}
disabled={!selectedGroupId} disabled={!selectedGroupId}
onClick={() => setPlanScope('club')} onClick={() => setPlanScope('club')}
style={{
border: 'none',
borderLeft: '1.5px solid var(--border2)',
padding: '8px 14px',
fontWeight: 600,
fontSize: '0.85rem',
cursor: selectedGroupId ? 'pointer' : 'not-allowed',
opacity: selectedGroupId ? 1 : 0.55,
background: planScope === 'club' ? 'var(--accent-dark)' : 'transparent',
color: planScope === 'club' ? '#fff' : 'var(--text1)',
whiteSpace: 'nowrap',
}}
> >
Ganzer Verein Ganzer Verein
</button> </button>
@ -1137,16 +1112,39 @@ function TrainingPlanningPage() {
/> />
Nur meine Zuordnung (Leitung / Co) Nur meine Zuordnung (Leitung / Co)
</label> </label>
<span style={{ fontSize: '0.78rem', color: 'var(--text3)', lineHeight: 1.4, flex: '1 1 240px' }}> <p
Ganzer Verein bezieht sich auf denselben Verein wie die gewählte Gruppe: Dort siehst du Termine mehrerer Gruppen; neu angelegte Termine gelten weiter für die gesondert gewählte Gruppe. style={{
fontSize: '0.78rem',
color: 'var(--text3)',
lineHeight: 1.45,
flex: '1 1 220px',
margin: 0,
}}
>
Neue Termine gelten immer für die gewählte Gruppe. Ganzer Verein zeigt zusätzlich Termine
anderer Gruppen desselben Vereins.
</p>
<details className="planning-filter-help">
<summary className="planning-filter-help__summary">Mehr zu Ansicht &amp; Trainerzuordnung</summary>
<div className="planning-filter-help__body">
<p style={{ margin: '0 0 0.65rem' }}>
Ganzer Verein bezieht sich auf denselben Verein wie die gewählte Gruppe. Neu angelegte Termine
beziehen sich weiterhin auf die Gruppe, die du oben gewählt hast.
</p>
{selectedGroupId ? ( {selectedGroupId ? (
<span style={{ display: 'block', marginTop: '6px', color: 'var(--text2)' }}> <p style={{ margin: 0 }}>
Über <strong>Trainer</strong> oder <strong>Trainer zuweisen</strong>: Leitung und Co je Einheit bearbeitbar (berechtigt: Vereinsorganisation, Haupt-/CoTrainer der Gruppe sowie Erstellung der Einheit). Über <strong>Trainer</strong> oder <strong>Trainer zuweisen</strong> bearbeitest du Leitung und
Das Mitgliederverzeichnis listet nur <strong>eigene Vereinsmitglieder</strong>; die Leitung erscheint nicht unter CoTrainer. Co je Einheit (berechtigt: Vereinsorganisation, Haupt-/CoTrainer der Gruppe sowie Erstellung
Gasttrainer aus anderen Vereinen (Zugriff nur auf eine Session, nicht auf den Verein insgesamt) sind für später vorgesehen. der Einheit). Das Mitgliederverzeichnis listet nur eigene Vereinsmitglieder; die Leitung erscheint
</span> nicht unter CoTrainer.
) : null} </p>
</span> ) : (
<p style={{ margin: 0, color: 'var(--text3)' }}>
Wähle zuerst eine Gruppe dann erweitert sich die Hilfe zu Trainer und Berechtigungen.
</p>
)}
</div>
</details>
</div> </div>
</div> </div>
@ -1172,9 +1170,8 @@ function TrainingPlanningPage() {
<div className="training-planning-create__intro"> <div className="training-planning-create__intro">
<h3 className="training-planning-create__title">Neue Trainingseinheit</h3> <h3 className="training-planning-create__title">Neue Trainingseinheit</h3>
<p className="training-planning-create__lede"> <p className="training-planning-create__lede">
Termin mit Datum, Zeiten und Ablauf (Abschnitte &amp; Übungen) festlegen optional eine{' '} Datum, Zeiten und Ablauf (Abschnitte &amp; Übungen) optional{' '}
<strong>Trainingsvorlage</strong> für die Gliederung wählen oder Inhalte aus einem{' '} <strong>Trainingsvorlage</strong> oder Inhalte aus einem <strong>Rahmenprogramm</strong> im Dialog.
<strong>Rahmenprogramm</strong> übernehmen.
</p> </p>
{!selectedGroupId && ( {!selectedGroupId && (
<p className="training-planning-create__hint training-planning-create__hint--warn"> <p className="training-planning-create__hint training-planning-create__hint--warn">
@ -1208,10 +1205,6 @@ function TrainingPlanningPage() {
Aus Rahmen übernehmen Aus Rahmen übernehmen
</button> </button>
</div> </div>
<p className="training-planning-create__hint">
Vorlage (Ohne Vorlage oder gespeicherte Gliederung) stellst du im sich öffnenden Dialog ein; dort auch
Kalenderdatum und Zeiten.
</p>
</div> </div>
</div> </div>