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 {
--bg: #f4f3ef;
--bg: #f6f5f0;
--surface: #ffffff;
--surface2: #f9f8f5;
--surface2: #fafaf6;
--border: rgba(0,0,0,0.09);
--border2: rgba(0,0,0,0.16);
--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 */
.settings-shell {
width: 100%;
@ -1135,6 +1144,108 @@ a.analysis-split__nav-item {
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-shell {
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'
/**
@ -6,8 +6,6 @@ import { TreePine, FolderTree, Download, Grid3x3, Users } from 'lucide-react'
* Wechselt zwischen verschiedenen Admin-Seiten
*/
export default function AdminPageNav() {
const location = useLocation()
const pages = [
{ to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine },
{ to: '/admin/users', label: 'Nutzer', icon: Users },
@ -17,51 +15,18 @@ export default function AdminPageNav() {
]
return (
<nav style={{
display: 'flex',
gap: '8px',
borderBottom: '2px solid var(--border)',
marginBottom: '24px',
flexWrap: 'wrap'
}}>
{pages.map(page => {
<nav className="admin-top-nav" aria-label="Administration">
{pages.map((page) => {
const Icon = page.icon
const isActive = location.pathname === page.to
return (
<NavLink
key={page.to}
to={page.to}
style={{
padding: '12px 20px',
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'
}
}}
className={({ isActive }) =>
'admin-top-nav__link' + (isActive ? ' admin-top-nav__link--active' : '')
}
>
<Icon size={18} />
<Icon size={18} strokeWidth={2} aria-hidden />
<span>{page.label}</span>
</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 { TreePine, FolderTree, Link2 } from 'lucide-react'
import { api } from '../utils/api'
import AdminPageNav from '../components/AdminPageNav'
import AppSubnavShell from '../components/AppSubnavShell'
import HierarchyTab from '../components/admin/HierarchyTab'
import CatalogsTab from '../components/admin/CatalogsTab'
import AssignmentsTab from '../components/admin/AssignmentsTab'
@ -10,17 +12,14 @@ function AdminHierarchyPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
// Hierarchy Tab State
const [hierarchy, setHierarchy] = useState([])
const [expandedNodes, setExpandedNodes] = useState(new Set())
const [selectedItem, setSelectedItem] = useState(null)
// Catalogs Tab State
const [targetGroups, setTargetGroups] = useState([])
const [skillCategories, setSkillCategories] = useState([])
const [trainingCharacters, setTrainingCharacters] = useState([])
// Assignments Tab State
const [styleDirections, setStyleDirections] = useState([])
const [assignments, setAssignments] = useState([])
@ -62,7 +61,7 @@ function AdminHierarchyPage() {
}
function handleToggleNode(nodeId) {
setExpandedNodes(prev => {
setExpandedNodes((prev) => {
const newSet = new Set(prev)
if (newSet.has(nodeId)) {
newSet.delete(nodeId)
@ -86,33 +85,26 @@ function AdminHierarchyPage() {
loadData()
}
const tabs = [
{ id: 'hierarchy', label: '🌳 Hierarchie', icon: '🌳' },
{ id: 'catalogs', label: '📋 Kataloge', icon: '📋' },
{ id: 'assignments', label: '🔗 Zuordnungen', icon: '🔗' }
const subnavItems = [
{ id: 'hierarchy', label: 'Hierarchie', icon: TreePine },
{ id: 'catalogs', label: 'Kataloge', icon: FolderTree },
{ id: 'assignments', label: 'Zuordnungen', icon: Link2 }
]
return (
<div className="app-page">
<div className="app-page admin-hierarchy-page">
<AdminPageNav />
<h1 style={{ marginTop: 0 }}>Admin: Katalog-Hierarchie</h1>
<h1 className="page-title" style={{ marginBottom: '12px' }}>
Katalog &amp; Hierarchie
</h1>
{/* Tab Navigation */}
<div className="tab-navigation">
{tabs.map(tab => (
<button
key={tab.id}
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' }}>
<AppSubnavShell
ariaLabel="Bereich Katalogadministration"
items={subnavItems}
value={activeTab}
onChange={setActiveTab}
>
{activeTab === 'hierarchy' && (
<HierarchyTab
hierarchy={hierarchy}
@ -147,48 +139,7 @@ function AdminHierarchyPage() {
onUpdate={handleUpdate}
/>
)}
</div>
<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>
</AppSubnavShell>
</div>
)
}

View File

@ -106,31 +106,32 @@ function Dashboard() {
}
return (
<div className="app-page">
<h1>Dashboard</h1>
<p style={{ color: 'var(--text2)', marginTop: '0.5rem' }}>
Willkommen, {user?.name || user?.email}!
</p>
{profile && <EmailVerificationBanner profile={profile} />}
{/* Welcome Card */}
<div className="card" style={{ marginTop: '1.5rem', marginBottom: '1.5rem' }}>
<h2>Willkommen bei Shinkan Jinkendo</h2>
<p style={{ color: 'var(--text2)' }}>
Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung
<div className="app-page dashboard-page">
<div className="dashboard-greeting">
<div>
<h1 className="page-title" style={{ marginBottom: '6px' }}>
Dashboard
</h1>
<p className="muted" style={{ marginTop: 0 }}>
Willkommen, {user?.name || user?.email}! Shinkan unterstützt dich bei Übungen, Planung und Vereinsstruktur.
</p>
</div>
</div>
{profile && <EmailVerificationBanner profile={profile} />}
{user?.id && (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 280px), 1fr))',
gap: '1rem',
marginBottom: '1.5rem'
}}
>
<div className="card">
<h3 style={{ fontSize: '1.05rem', marginBottom: '0.65rem' }}>Deine nächsten Trainings</h3>
{user?.id && (
<div
className="dashboard-training-grid"
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 280px), 1fr))',
gap: '1rem',
alignItems: 'stretch',
marginBottom: '1.5rem',
}}
>
<div className="card">
<h3 style={{ fontSize: '1.05rem', marginBottom: '0.65rem' }}>Deine nächsten Trainings</h3>
{trainingHomeErr ? (
<p style={{ color: 'var(--danger)', fontSize: '0.9rem' }}>{trainingHomeErr}</p>
) : trainingHome?.upcoming?.length ? (
@ -216,43 +217,6 @@ function Dashboard() {
</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 && (
<div className="card">
<h3>System-Information</h3>

View File

@ -1067,53 +1067,28 @@ function TrainingPlanningPage() {
<span className="form-label" style={{ marginBottom: 0, alignSelf: 'center' }}>
Einblenden
</span>
<div
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)',
}}
>
<div className="planning-segment-group" role="group" aria-label="Gruppe oder ganzer Verein">
<button
type="button"
className={
'planning-segment-group__btn' +
(planScope === 'group' ? ' planning-segment-group__btn--active' : '')
}
aria-pressed={planScope === 'group'}
disabled={!selectedGroupId}
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
</button>
<button
type="button"
className={
'planning-segment-group__btn' +
(planScope === 'club' ? ' planning-segment-group__btn--active' : '')
}
aria-pressed={planScope === 'club'}
disabled={!selectedGroupId}
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
</button>
@ -1137,16 +1112,39 @@ function TrainingPlanningPage() {
/>
Nur meine Zuordnung (Leitung / Co)
</label>
<span style={{ fontSize: '0.78rem', color: 'var(--text3)', lineHeight: 1.4, flex: '1 1 240px' }}>
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.
{selectedGroupId ? (
<span style={{ display: 'block', marginTop: '6px', color: 'var(--text2)' }}>
Ü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).
Das Mitgliederverzeichnis listet nur <strong>eigene Vereinsmitglieder</strong>; die Leitung erscheint nicht unter CoTrainer.
Gasttrainer aus anderen Vereinen (Zugriff nur auf eine Session, nicht auf den Verein insgesamt) sind für später vorgesehen.
</span>
) : null}
</span>
<p
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 ? (
<p style={{ margin: 0 }}>
Über <strong>Trainer</strong> oder <strong>Trainer zuweisen</strong> bearbeitest du Leitung und
Co je Einheit (berechtigt: Vereinsorganisation, Haupt-/CoTrainer der Gruppe sowie Erstellung
der Einheit). Das Mitgliederverzeichnis listet nur eigene Vereinsmitglieder; die Leitung erscheint
nicht unter CoTrainer.
</p>
) : (
<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>
@ -1172,9 +1170,8 @@ function TrainingPlanningPage() {
<div className="training-planning-create__intro">
<h3 className="training-planning-create__title">Neue Trainingseinheit</h3>
<p className="training-planning-create__lede">
Termin mit Datum, Zeiten und Ablauf (Abschnitte &amp; Übungen) festlegen optional eine{' '}
<strong>Trainingsvorlage</strong> für die Gliederung wählen oder Inhalte aus einem{' '}
<strong>Rahmenprogramm</strong> übernehmen.
Datum, Zeiten und Ablauf (Abschnitte &amp; Übungen) optional{' '}
<strong>Trainingsvorlage</strong> oder Inhalte aus einem <strong>Rahmenprogramm</strong> im Dialog.
</p>
{!selectedGroupId && (
<p className="training-planning-create__hint training-planning-create__hint--warn">
@ -1208,10 +1205,6 @@ function TrainingPlanningPage() {
Aus Rahmen übernehmen
</button>
</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>