- Incremented application version to 0.7.9 and updated database schema version to 20260427030. - Revised project status documentation to reflect recent milestones and changes, including detailed logs of implemented features and next steps. - Enhanced API specifications for exercises, including support for exercise variants and improved query parameters. - Updated frontend routing to streamline exercise variant management within the ExerciseFormPage. - Implemented role-based media upload limits and refined search/filter specifications for better user experience.
16 KiB
Frontend Routing & Navigation Specification
Version: 1.1
Datum: 2026-04-27
Status: DRAFT - Awaiting Review
Autor: Claude Code
Änderungen v1.1: Übungsvarianten-Bearbeitung nur unter /exercises/:id/edit (keine VariantFormPage-Routen)
1. Route-Struktur
1.1 Route-Übersicht
/exercises → ExercisesListPage (Grid + Filter + Chips)
/exercises/new → ExerciseFormPage (Create)
/exercises/{id} → ExerciseDetailPage (Accordion-Layout)
/exercises/{id}/edit → ExerciseFormPage (Edit inkl. Varianten-Editor inline)
/exercise-blocks → ExerciseBlocksListPage (Meine Blocks)
/exercise-blocks/new → ExerciseBlockFormPage (Create)
/exercise-blocks/{id} → ExerciseBlockDetailPage (mit BlockEditor)
/exercise-blocks/{id}/edit → ExerciseBlockFormPage (Edit)
/admin/exercises/catalog → AdminCatalogPage (Fokusbereiche, Stile, Zielgruppen, Altersgruppen)
/admin/exercises/skills → AdminSkillsPage (Fähigkeiten-Matrix)
/admin/exercises/methods → AdminMethodsPage (Methoden-Katalog)
URL-Parameter-Konventionen:
- IDs immer numerisch:
/exercises/42(nicht/exercises/uuid) - Query-Parameter für Filter:
/exercises?focus_area=1&visibility=club - Pagination:
/exercises?limit=50&offset=100 - Sortierung:
/exercises?sort=created_at&order=desc
2. Navigation-Patterns
2.1 Haupt-Navigation (Desktop + Tablet)
Primäre Nav (Top-Level):
┌─────────────────────────────────────────┐
│ Shinkan │ Übungen │ Planung │ Admin │
└─────────────────────────────────────────┘
Übungen-Submenu (Dropdown):
Übungen
├─ Alle Übungen → /exercises
├─ Neue Übung → /exercises/new
├─ Meine Blocks → /exercise-blocks
├─ Neuer Block → /exercise-blocks/new
└─ Katalog verwalten → /admin/exercises/catalog (nur Admin)
2.2 Mobile Navigation (Hamburger-Menu)
☰ Menu
├─ Übungen
│ ├─ Alle anzeigen
│ ├─ Neue Übung
│ └─ Filter & Suche
├─ Planung
└─ Admin
└─ Katalog
2.3 Breadcrumbs (Detail/Edit-Seiten)
Pattern:
Home → Übungen → [Übungsname] → Bearbeiten (Varianten im gleichen Formular)
Implementation:
- Breadcrumbs dynamisch aus Route-Parametern generiert
- Letztes Element (aktuelle Seite) nicht klickbar
- Max. 4 Ebenen, dann
...für gekürzte Pfade
3. State Management
3.1 URL-State (Query-Parameter)
Filter-State in URL persistieren:
// ExercisesListPage.jsx
const [filters, setFilters] = useState({
focus_area: parseInt(searchParams.get('focus_area')) || null,
visibility: searchParams.get('visibility') || null,
status: searchParams.get('status') || null,
search: searchParams.get('search') || '',
limit: parseInt(searchParams.get('limit')) || 50,
offset: parseInt(searchParams.get('offset')) || 0,
})
// Bei Filter-Änderung → URL aktualisieren
const updateFilters = (newFilters) => {
const params = new URLSearchParams()
Object.entries(newFilters).forEach(([key, val]) => {
if (val) params.set(key, val)
})
navigate(`/exercises?${params.toString()}`)
}
Vorteile:
- Sharable URLs (Filter-Zustand übertragbar)
- Browser-Back funktioniert korrekt
- Keine verlorenen Filter beim Reload
3.2 Local State (React useState)
Form-State (nicht in URL):
// ExerciseFormPage.jsx
const [formData, setFormData] = useState({
title: '',
summary: '',
goal: '',
execution: '',
// ... weitere Felder
})
UI-State (nicht in URL):
// ExerciseDetailPage.jsx
const [expandedSections, setExpandedSections] = useState({
basics: true,
execution: false,
variants: false,
media: false,
})
3.3 Context (Global State)
Katalog-Daten (einmalig laden, global verfügbar):
// CatalogContext.jsx
const CatalogContext = createContext()
export function CatalogProvider({ children }) {
const [focusAreas, setFocusAreas] = useState([])
const [trainingStyles, setTrainingStyles] = useState([])
const [targetGroups, setTargetGroups] = useState([])
const [ageGroups, setAgeGroups] = useState([])
const [skills, setSkills] = useState([])
useEffect(() => {
// Kataloge einmalig beim App-Start laden
Promise.all([
api.getFocusAreas(),
api.getTrainingStyles(),
api.getTargetGroups(),
api.getAgeGroups(),
api.getSkills(),
]).then(([fa, ts, tg, ag, sk]) => {
setFocusAreas(fa)
setTrainingStyles(ts)
setTargetGroups(tg)
setAgeGroups(ag)
setSkills(sk)
})
}, [])
return (
<CatalogContext.Provider value={{
focusAreas, trainingStyles, targetGroups, ageGroups, skills
}}>
{children}
</CatalogContext.Provider>
)
}
export const useCatalog = () => useContext(CatalogContext)
Usage in Komponenten:
// ExerciseFormPage.jsx
const { focusAreas, trainingStyles } = useCatalog()
4. Deep Linking
4.1 Detail-Seite mit Accordion-State
Problem: Link teilen → Empfänger soll direkt zur "Varianten"-Sektion springen
Lösung: Hash-Fragment in URL:
/exercises/42#variants
/exercises/42#media
Implementation:
// ExerciseDetailPage.jsx
useEffect(() => {
const hash = window.location.hash.replace('#', '')
if (hash && expandedSections[hash] !== undefined) {
setExpandedSections(prev => ({ ...prev, [hash]: true }))
// Scroll zur Sektion
setTimeout(() => {
document.getElementById(hash)?.scrollIntoView({ behavior: 'smooth' })
}, 100)
}
}, [])
4.2 Externe Verknüpfung aus Trainingsplan
Szenario: Trainingsplan-Editor soll Übung verlinken
URL-Format:
/exercises/42?from=training_plan&plan_id=7
Navigation-Logik:
// ExerciseDetailPage.jsx
const searchParams = useSearchParams()
const returnUrl = searchParams.get('from') === 'training_plan'
? `/training-plans/${searchParams.get('plan_id')}`
: '/exercises'
// "Zurück"-Button zeigt entsprechendes Ziel
<button onClick={() => navigate(returnUrl)}>
← {returnUrl.includes('training') ? 'Zum Trainingsplan' : 'Zur Übersicht'}
</button>
5. Navigation Guards
5.1 Unsaved Changes Warning
Pattern:
// ExerciseFormPage.jsx
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
// Bei Form-Änderung
const handleChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }))
setHasUnsavedChanges(true)
}
// Browser-Warning bei Reload/Close
useEffect(() => {
const handleBeforeUnload = (e) => {
if (hasUnsavedChanges) {
e.preventDefault()
e.returnValue = ''
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [hasUnsavedChanges])
// React-Router Warning bei Navigation
const blocker = useBlocker(
({ currentLocation, nextLocation }) =>
hasUnsavedChanges &&
currentLocation.pathname !== nextLocation.pathname
)
useEffect(() => {
if (blocker.state === 'blocked') {
const confirmed = window.confirm(
'Du hast ungespeicherte Änderungen. Seite wirklich verlassen?'
)
if (confirmed) {
blocker.proceed()
} else {
blocker.reset()
}
}
}, [blocker])
5.2 Permission-Check (Admin-Routes)
Pattern:
// ProtectedRoute.jsx
function ProtectedRoute({ children, requiredRole = 'admin' }) {
const { profile } = useAuth()
if (!profile) {
return <Navigate to="/login" />
}
if (profile.role !== requiredRole) {
return <Navigate to="/exercises" replace />
}
return children
}
// App.jsx
<Route path="/admin/exercises/*" element={
<ProtectedRoute requiredRole="admin">
<AdminExerciseRoutes />
</ProtectedRoute>
} />
6. Performance-Optimierung
6.1 Lazy Loading von Routen
Pattern:
// App.jsx
const ExercisesListPage = lazy(() => import('./pages/ExercisesListPage'))
const ExerciseDetailPage = lazy(() => import('./pages/ExerciseDetailPage'))
const ExerciseFormPage = lazy(() => import('./pages/ExerciseFormPage'))
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/exercises" element={<ExercisesListPage />} />
<Route path="/exercises/:id" element={<ExerciseDetailPage />} />
<Route path="/exercises/:id/edit" element={<ExerciseFormPage />} />
</Routes>
</Suspense>
)
}
Vorteil: Initial Bundle kleiner, Routen nur bei Bedarf nachgeladen
6.2 Prefetching von Detail-Daten
Pattern:
// ExercisesListPage.jsx - Hover-Prefetch
const handleCardHover = (exerciseId) => {
// Prefetch Detail-Daten beim Hover
queryClient.prefetchQuery(['exercise', exerciseId], () =>
api.getExercise(exerciseId)
)
}
<ExerciseCard
exercise={exercise}
onMouseEnter={() => handleCardHover(exercise.id)}
onClick={() => navigate(`/exercises/${exercise.id}`)}
/>
Vorteil: Detail-Seite lädt sofort, da Daten bereits im Cache
7. Error-Handling
7.1 404 - Exercise Not Found
Pattern:
// ExerciseDetailPage.jsx
const { data: exercise, error, isLoading } = useQuery(
['exercise', exerciseId],
() => api.getExercise(exerciseId)
)
if (error?.response?.status === 404) {
return (
<NotFoundPage
title="Übung nicht gefunden"
message="Diese Übung existiert nicht oder wurde gelöscht."
backLink="/exercises"
backLabel="Zur Übersicht"
/>
)
}
7.2 403 - No Permission
Pattern:
if (error?.response?.status === 403) {
return (
<ForbiddenPage
title="Keine Berechtigung"
message="Du hast keinen Zugriff auf diese Übung."
backLink="/exercises"
/>
)
}
7.3 Network Error
Pattern:
if (error?.message === 'Network Error') {
return (
<ErrorPage
title="Verbindungsfehler"
message="Server nicht erreichbar. Bitte prüfe deine Internetverbindung."
retryAction={() => refetch()}
/>
)
}
8. Mobile-Spezifische Navigation
8.1 Bottom Navigation (Mobile)
Pattern:
┌─────────────────────────────────────┐
│ │
│ (Content Area) │
│ │
├─────────────────────────────────────┤
│ 🏠 Home │ 📋 Übungen │ 📅 Plan │
└─────────────────────────────────────┘
Implementation:
// MobileBottomNav.jsx
<nav className="mobile-bottom-nav">
<NavLink to="/" className={({ isActive }) => isActive ? 'active' : ''}>
<Icon name="home" />
<span>Home</span>
</NavLink>
<NavLink to="/exercises">
<Icon name="list" />
<span>Übungen</span>
</NavLink>
<NavLink to="/training-plans">
<Icon name="calendar" />
<span>Pläne</span>
</NavLink>
</nav>
CSS:
.mobile-bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 60px;
background: var(--surface);
border-top: 1px solid var(--border);
display: flex;
justify-content: space-around;
z-index: 100;
}
@media (min-width: 768px) {
.mobile-bottom-nav {
display: none; /* Desktop nutzt Top-Nav */
}
}
8.2 Swipe-Navigation (Detail-Seite)
Pattern: Swipe left/right zwischen Übungen (gleicher Filter)
Implementation:
// ExerciseDetailPage.jsx
const { exercises } = useExercisesList() // aus Context/Query
const currentIndex = exercises.findIndex(e => e.id === parseInt(exerciseId))
const swipeHandlers = useSwipeable({
onSwipedLeft: () => {
const nextExercise = exercises[currentIndex + 1]
if (nextExercise) navigate(`/exercises/${nextExercise.id}`)
},
onSwipedRight: () => {
const prevExercise = exercises[currentIndex - 1]
if (prevExercise) navigate(`/exercises/${prevExercise.id}`)
},
preventDefaultTouchmoveEvent: true,
trackMouse: false,
})
<div {...swipeHandlers}>
{/* Exercise Detail Content */}
</div>
9. Accessibility
9.1 Skip Links
Pattern:
// App.jsx
<a href="#main-content" className="skip-link">
Zum Hauptinhalt springen
</a>
<main id="main-content">
{/* Content */}
</main>
CSS:
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: var(--accent);
color: white;
padding: 8px;
z-index: 1000;
}
.skip-link:focus {
top: 0;
}
9.2 Focus Management
Pattern: Bei Navigation → Focus auf Hauptüberschrift setzen
// ExerciseDetailPage.jsx
const titleRef = useRef(null)
useEffect(() => {
titleRef.current?.focus()
}, [exerciseId])
<h1 ref={titleRef} tabIndex={-1}>
{exercise.title}
</h1>
9.3 ARIA Live-Region für Filter
Pattern:
// ExercisesListPage.jsx
<div aria-live="polite" aria-atomic="true" className="sr-only">
{exercises.length} Übungen gefunden
</div>
10. Testing-Strategie
10.1 Route-Tests (Playwright)
// exercises.routing.spec.js
test('Navigation zu Detail-Seite funktioniert', async ({ page }) => {
await page.goto('/exercises')
const firstCard = page.locator('.exercise-card').first()
await firstCard.click()
await expect(page).toHaveURL(/\/exercises\/\d+/)
await expect(page.locator('h1')).toBeVisible()
})
test('Filter-State bleibt in URL erhalten', async ({ page }) => {
await page.goto('/exercises?focus_area=1')
await page.reload()
const url = new URL(page.url())
expect(url.searchParams.get('focus_area')).toBe('1')
})
10.2 Navigation-Guard-Tests
test('Unsaved Changes Warning', async ({ page }) => {
await page.goto('/exercises/new')
await page.fill('[name="title"]', 'Test Übung')
page.on('dialog', dialog => {
expect(dialog.message()).toContain('ungespeicherte')
dialog.accept()
})
await page.goto('/exercises')
})
11. Route-Konfiguration (React Router)
11.1 Vollständige Route-Definitionen
// App.jsx
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
function App() {
return (
<BrowserRouter>
<CatalogProvider>
<Routes>
{/* Exercises Routes */}
<Route path="/exercises">
<Route index element={<ExercisesListPage />} />
<Route path="new" element={<ExerciseFormPage />} />
<Route path=":id" element={<ExerciseDetailPage />} />
<Route path=":id/edit" element={<ExerciseFormPage />} />
{/* Varianten: Bearbeitung inline in ExerciseFormPage, keine eigenen Routen */}
</Route>
{/* Exercise Blocks Routes */}
<Route path="/exercise-blocks">
<Route index element={<ExerciseBlocksListPage />} />
<Route path="new" element={<ExerciseBlockFormPage />} />
<Route path=":id" element={<ExerciseBlockDetailPage />} />
<Route path=":id/edit" element={<ExerciseBlockFormPage />} />
</Route>
{/* Admin Routes */}
<Route path="/admin/exercises" element={
<ProtectedRoute requiredRole="admin">
<Outlet />
</ProtectedRoute>
}>
<Route path="catalog" element={<AdminCatalogPage />} />
<Route path="skills" element={<AdminSkillsPage />} />
<Route path="methods" element={<AdminMethodsPage />} />
</Route>
{/* Redirects */}
<Route path="/" element={<Navigate to="/exercises" replace />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</CatalogProvider>
</BrowserRouter>
)
}
Version: 1.1 Letzte Änderung: 2026-04-24 Status: REVIEWED - Pending Implementation Review-Änderungen: Exercise Blocks Routes + Navigation hinzugefügt