23-01-2026

This commit is contained in:
Kevin Adametz 2026-01-23 17:34:40 +01:00
parent 8fd1f4d451
commit 389d5d1820
59 changed files with 9642 additions and 883 deletions

View file

@ -0,0 +1,312 @@
# Backend-UI für Navigation API
Die Backend-UI bietet eine benutzerfreundliche grafische Oberfläche zur Verwaltung und Visualisierung des Navigationsbaums **genau wie im Frontend (header.html.twig)**.
## Zugriff
**URL:** `/navigation-api`
**Permission:** `crm-nav-api`
**Menü:** Navigation API (Seitenmenü)
## Frontend-Struktur
Die Backend-UI zeigt den Navigationsbaum **exakt wie im Frontend** mit allen Bereichen:
### 🌍 Länder-Navigation
- ✅ **Länderseiten** (Pages mit `country_id`) als Root-Level
- ✅ **Sortierung** nach `order` und `title`
- ✅ **Gruppierung** der Children nach `beforeTitle` (Haupt / Infos)
- ✅ **titleShort** wird verwendet statt vollständigem Titel
- ✅ Toggle zum Auf-/Zuklappen der Children
### 🏠 USEDOM Ferienwohnungen
- ✅ **Ferienwohnungs-Übersicht** als Hauptseite
- ✅ **Einzelne FeWos** als Children
- ✅ Sortiert nach `order` und `title`
### 📑 Mehr-Menü Seiten
- ✅ **Über uns**
- ✅ **Reiseversicherung**
- ✅ **Reiseführer** (mit möglichen Children)
- ✅ **Reisemagazin** (mit möglichen Children)
- ✅ **Reisenews** (mit möglichen Children)
### Allgemein
- ✅ **Ausgeblendete Pages** werden angezeigt (Badge "Ausgeblendet")
- ✅ **Section-Separators** trennen die Bereiche visuell
## Features
### 📊 Live-Statistiken
Oben auf der Seite werden wichtige Kennzahlen angezeigt:
- **Gesamt Seiten:** Alle Pages in der Datenbank
- **Aktive Seiten:** Nur sichtbare und aktive Pages
- **Reiseprogramme:** Anzahl der TravelProgram-Pages
- **Länderseiten:** Anzahl der Country-Pages
Die Statistiken werden beim Laden der Seite automatisch aktualisiert.
### 🌳 Interaktiver Navigationsbaum
Der Hauptbereich zeigt den hierarchischen Navigationsbaum mit folgenden Features:
#### Visuelle Darstellung
- **Icons:**
- ⭐ Stern (gelb) = Länderseite (Root-Level)
- ✈️ Flugzeug = Reiseprogramm
- 🏠 Haus = Ferienwohnung
- Info-Circle = Infos-Gruppe
- 📄 Dokument = Normale Seite
- **Badges:**
- 🔴 **Inaktiv** = Status ist 0
- ⚠️ **Ausgeblendet** = show_in_navi ist 0 (wird trotzdem angezeigt!)
- 🔵 **Reiseprogramm** = Hat TravelProgram
- 💙 **FeWo** = Hat FewoLodging
- 💚 **Land** = Hat Country
- ⚪ **Gruppe: Infos** = beforeTitle ist gesetzt
- **Hierarchie:**
- **Root-Level:** Länderseiten (fett, grauer Hintergrund, blaue Linie links)
- **Children:** Eingerückt, normale Darstellung
- **Separator "Infos":** Grauer Block trennt Haupt- von Info-Seiten
- Toggle-Buttons nur bei Länderseiten
- **Besonderheiten:**
- **titleShort** wird angezeigt (wie im Frontend)
- **Gruppierung** nach beforeTitle (Haupt, dann Infos)
- **Ausgeblendete** Pages haben gelbes Badge aber sind sichtbar
#### Interaktion
- **Auf-/Zuklappen:**
- Klick auf Pfeil: Einzelnen Knoten auf-/zuklappen
- "Alle aufklappen": Zeigt kompletten Baum
- "Alle zuklappen": Zeigt nur Root-Ebene
- **Hover-Effekt:**
- Hintergrund wird grau beim Überfahren mit der Maus
### 🔍 Suche
Die Suchfunktion durchsucht alle Navigationspunkte nach:
- **Titel:** Page-Titel
- **Slug:** URL-freundlicher Name
- **URL:** Vollständiger Pfad
**Verwendung:**
1. Suchbegriff eingeben
2. Enter drücken oder auf Suchbutton klicken
3. Gefundene Knoten werden gelb markiert
4. Parent-Knoten werden automatisch aufgeklappt
### 🎯 Filter
**"Mit ausgeblendeten" / "Nur sichtbare"**
- **Mit ausgeblendeten (Standard):** Zeigt alle Pages inkl. ausgeblendete (show_in_navi=0)
- **Nur sichtbare:** Zeigt nur Pages mit show_in_navi=1 (wie im Frontend)
Der aktive Filter wird durch die Button-Farbe angezeigt:
- Blau = Mit ausgeblendeten
- Primär = Nur sichtbare
Ausgeblendete Pages werden mit einem gelben Badge "Ausgeblendet" markiert.
### 📥 Export
**JSON-Export-Funktion:**
- Klick auf "Export JSON" lädt eine JSON-Datei herunter
- Dateiname: `navigation-tree-YYYY-MM-DD-HHMMSS.json`
- Inhalt: Kompletter Navigationsbaum entsprechend aktuellem Filter
- Format: Pretty-printed JSON mit UTF-8 Encoding
**Verwendungszwecke:**
- Backup der Navigationsstruktur
- Import in andere Systeme
- Analyse und Dokumentation
- Debugging
### 🔄 Cache-Verwaltung
**Cache leeren:**
- Klick auf "Cache leeren"
- Bestätigung erforderlich
- Löscht alle gecachten Navigationsdaten
- Wird automatisch nach 60 Minuten erneuert
**Wann Cache leeren?**
- Nach Änderungen an Pages
- Nach Import/Export von Daten
- Bei veralteten Anzeigen
- Nach Struktur-Änderungen
## Technische Details
### API-Calls
Die UI nutzt folgende Endpunkte:
```javascript
// Statistiken laden
GET /navigation-api/stats
// Navigationsbaum laden (Frontend-Struktur)
GET /navigation-api/data?include_hidden=0|1
// Suche durchführen
GET /navigation-api/search?query=...
// Export starten (Frontend-Struktur)
GET /navigation-api/export?include_hidden=0|1
// Cache leeren
POST /navigation-api/clear-cache
```
**Unterschied zu API-Endpunkten:**
- `/api/navigation/*` = Kompletter Baum (API)
- `/navigation-api/*` = Frontend-Struktur (nur Länderseiten)
### Performance
- **Caching:** 60 Minuten Server-seitig
- **Lazy Loading:** Kinder werden nur bei Bedarf gerendert
- **Optimierte Queries:** Eager Loading von Relationships
- **Frontend-Rendering:** jQuery-basiert, schnell auch bei 1000+ Knoten
### Browser-Kompatibilität
- ✅ Chrome/Edge (aktuell)
- ✅ Firefox (aktuell)
- ✅ Safari (aktuell)
- ⚠️ IE11 (eingeschränkt)
## Styling
### Farben
- **Primary (Blau):** Reiseprogramme, aktive Aktionen
- **Success (Grün):** Aktive Pages, Länder
- **Info (Cyan):** Ferienwohnungen
- **Warning (Gelb):** "Nicht in Navi", Suchmarkierungen
- **Danger (Rot):** Inaktive Pages, Löschen-Aktionen
### Responsive Design
Die UI ist responsive und funktioniert auf verschiedenen Bildschirmgrößen:
- **Desktop (>1200px):** Volle Features, 4 Statistik-Karten
- **Tablet (768-1199px):** 2 Statistik-Karten pro Zeile
- **Mobile (<768px):** 1 Statistik-Karte pro Zeile, vereinfachte Toolbar
## Shortcuts
Keine Keyboard-Shortcuts implementiert (noch).
## Bekannte Einschränkungen
1. **Sehr große Bäume (>10.000 Knoten):**
- Kann zu langsamem Rendering führen
- Empfehlung: Filter verwenden
2. **Suche:**
- Nur client-seitig, keine Server-Suche
- Bei vielen Ergebnissen kann es unübersichtlich werden
3. **Keine Bearbeitung:**
- Nur Anzeige, keine Inline-Bearbeitung
- Änderungen müssen über CMS erfolgen
## Zukünftige Erweiterungen
Mögliche Features für die Zukunft:
- [ ] Drag & Drop zum Verschieben von Knoten
- [ ] Inline-Bearbeitung von Titeln
- [ ] Bulk-Operationen (Status ändern, löschen, etc.)
- [ ] Mehr Filter-Optionen (Template, Level, etc.)
- [ ] Keyboard-Shortcuts
- [ ] Pagination für sehr große Bäume
- [ ] Graphische Visualisierung (D3.js Tree)
- [ ] Breadcrumb-Anzeige für selektierten Knoten
- [ ] Export in andere Formate (CSV, XML)
## Troubleshooting
### Problem: Baum lädt nicht
**Lösung:**
1. Browser-Konsole öffnen (F12)
2. Fehler prüfen
3. Netzwerk-Tab prüfen: Status-Code der API-Calls
4. Backend-Logs prüfen
### Problem: Statistiken zeigen "-"
**Lösung:**
1. API-Endpunkt `/navigation-api/stats` prüfen
2. Browser-Konsole auf Fehler prüfen
3. Permission `crm-tp-na` prüfen
### Problem: Cache wird nicht geleert
**Lösung:**
1. CSRF-Token prüfen
2. POST-Request erfolgreich? (Netzwerk-Tab)
3. Server-Logs prüfen
4. Cache-System aktiv? (config/cache.php)
### Problem: Suche funktioniert nicht
**Lösung:**
1. JavaScript-Fehler in Konsole?
2. jQuery geladen?
3. Suchbegriff korrekt eingegeben?
## Support
Bei Problemen oder Fragen:
1. Dokumentation lesen: `dev/frontend-navigation/README.md`
2. API-Dokumentation: `dev/frontend-navigation/navigation-api.md`
3. Test-Tools verwenden: `dev/frontend-navigation/test-api.html`
## Changelog
### Version 1.0 (Initial Release)
- ✅ Navigationsbaum-Visualisierung
- ✅ Live-Statistiken
- ✅ Suche
- ✅ Filter (Alle/Aktive)
- ✅ Export (JSON)
- ✅ Cache-Verwaltung
- ✅ Responsive Design
- ✅ Icon-basierte Typerkennung
- ✅ Badge-System für Status

View file

@ -0,0 +1,256 @@
<?php
/**
* @author Ulrich Hecht <ulrich.hecht@hecht-software.de>
* @date 12/02/2016
*/
namespace AppBundle\Listener;
use AppBundle\AppBundle;
use AppBundle\Controller\DefaultController;
use AppBundle\Entity\Page;
use AppBundle\Util;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\PersistentCollection;
use Symfony\Bundle\FrameworkBundle\Routing\Router;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpKernel\Controller\ControllerResolverInterface;
use Symfony\Component\HttpKernel\Event\FilterControllerEvent;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class KernelControllerListener
{
private $em;
private $controllerResolver;
public function __construct(EntityManager $entityManager, ControllerResolverInterface $controllerResolver)
{
$this->em = $entityManager;
$this->controllerResolver = $controllerResolver;
}
private function setSessionAttributeByTime($request, $key)
{
$session = $request->getSession();
$session->set('_open_side_about', '');
$session->set('_open_side_search', '');
if ($key === 'default') { //is default visit
if (!$session->get('default_visit')) { //first visit
$session->set('default_visit', true);
$session->set('_open_side_about', 'open');
$session->set('_open_side_search', 'open');
}
}
if ($key === 'api') { //is api = Reiseführer
if (!$session->get('api_visit')) { //first visit
$session->set('api_visit', true);
$session->set('_open_side_about', 'open');
}
}
}
public function onKernelController(FilterControllerEvent $event)
{
$request = $event->getRequest();
$session = $request->getSession();
Util::setMySession('search_request_b', $session->get('search_request_b'));
Util::setMySession('search_request_e', $session->get('search_request_e'));
Util::setMySession('search_request_c', $session->get('search_request_c'));
if ($request->get('_controller') === 'AppBundle\Controller\DefaultController::homeAction') {
$this->setSessionAttributeByTime($request, "default");
}
if ($request->get('_controller') == 'AppBundle\Controller\DefaultController::defaultAction') {
$repo = $this->em->getRepository('AppBundle:Page');
$path = preg_replace('/^\/?(.*?)\/?$/', '$1', $request->getPathInfo());
/** @var Page $node */
$node = null;
// Try to find by url path. It's possible that the path consists of two parts:
// - the beginning part represents a node
// - the ending part represents a handler
// e.g. /path/to/travel/program/buchen ("buchen" is the handler part)
$pathArray = explode('/', $path);
$restOfPath = '';
$curPath = $path;
//search for entry in new tree objects
$api = Util::loadFromApi('cms/search', ['url' => $curPath]);
while (!empty($pathArray)) {
if (!$api) {
$node = $repo->findOneBy(['realUrlPath' => '/' . $curPath]);
if ($node) {
break;
}
}
$restOfPath = '/' . array_pop($pathArray) . $restOfPath;
$curPath = implode('/', $pathArray);
}
//find and try 301
//find => to
$redirects = [
'/reisefuehrer/tuerkei' => 'tuerkei-reisen/reisefuehrer',
'/reisefuehrer/jordanien' => 'jordanien-reisen/reisefuehrer',
'/reisefuehrer/oman' => 'oman-reisen/reisefuehrer',
'/reisefuehrer/israel' => 'israel-reisen/israel-reisefuehrer',
'/reisefuehrer/aegypten' => 'aegypten-reisen/aegypten-reisefuehrer',
'/reisemagazin/tuerkei-reisemagazin' => 'tuerkei-reisen/tuerkei-reisemagazin',
'/reisemagazin/aegypten' => 'aegypten-reisen/aegypten-reisemagazin',
'/reisemagazin/israel' => 'israel-reisen/israel-reisemagazin',
'/reisemagazin/jordanien' => 'jordanien-reisen/jordanien-reisemagazin',
'/reisemagazin/marokko' => 'marokko-urlaub/marokko-reisemagazin'
];
//301
foreach ($redirects as $find => $to) {
if (strpos($restOfPath, $find) !== false) {
$restOfPath = str_replace($find, $to, $restOfPath);
$protocol = 'https';
if (empty($_SERVER['HTTPS']) || $_SERVER['HTTPS'] === "off") {
$protocol = 'http';
}
header("Location: " . $protocol . "://" . $_SERVER["HTTP_HOST"] . "/" . $restOfPath, true, 301);
exit();
}
}
//load content from API, is found by cms/search
if ($api) {
$this->setSessionAttributeByTime($request, "api");
$request->attributes->set('_controller', 'AppBundle:Cms:iqTravelGuide');
$request->attributes->set('api', $api);
$request->attributes->set('template', 'TravelGuide');
try {
$controller = $this->controllerResolver->getController($request);
} catch (\LogicException $e) {
// If there is no controller action, call the default action and pass the template name
$request->attributes->set('_controller', 'AppBundle:Cms:Default');
$request->attributes->set('template', "Default");
}
$event->setController($controller ?? $this->controllerResolver->getController($request));
return;
}
$this->setSessionAttributeByTime($request, "default");
if (!$node) {
// Now try to find a page by tracing a page node path using the page nodes' slugs
$pathArray = explode('/', $path);
while (!empty($pathArray)) {
$restOfPath = '/' . implode('/', $pathArray);
$slug = array_shift($pathArray);
$qb = $repo->createQueryBuilder('p');
$qb->where($qb->expr()->eq('p.slug', ':slug'));
$qb->setParameter('slug', $slug);
if ($node != null) {
$qb->andWhere($qb->expr()->eq('p.parent', ':parentId'));
$qb->setParameter('parentId', $node->getId());
} else {
$qb->andWhere($qb->expr()->isNull('p.parent'));
}
$qb->setMaxResults(1);
$childNode = $qb->getQuery()->getOneOrNullResult();
if (!$childNode) {
$whitelist = [
'buchen',
'berechne-gesamtpreis',
'show_nationality_country_text',
'pdf',
];
if (!in_array($slug, $whitelist)) {
throw new HttpException(404, 'Seite nicht gefunden: ' . $slug);
}
break;
}
if ($node) {
// Avoid database calls to parent later
$childNode->setParent($node);
}
$node = $childNode;
}
if ($node && $node->getRealUrlPath() && $node->getRealUrlPath() != '/' . $path) {
// If there realUrlPath is set and the slug path differs from realUrlPath, then the slug path is
// not a valid URL. Otherwise, there would be two different URLs representing the same page.
$event->setController(function () {
throw new NotFoundHttpException('Invalid URL');
});
return;
}
}
if (!$node) {
// Search for a redirect entry
$redirect = $this->em->getRepository('AppBundle:Redirect')->findOneBy(['sourceUrlPath' => '/' . $path]);
if ($redirect) {
$redirectUrl = $redirect->getPage()->getUrlPath();
$event->setController(function () use ($redirectUrl) {
return new RedirectResponse($redirectUrl, 301);
});
return;
}
}
if ($node) {
if ($node->getStatus() == 0) {
throw new NotFoundHttpException('Inactive page');
}
$request->attributes->set('page', $node);
if ($node->getTravelProgram() != null && (
$restOfPath == '/buchen' || $restOfPath == '/berechne-gesamtpreis' || $restOfPath == '/show_nationality_country_text')) {
// Special case: Booking actions
$request->attributes->set('travelProgramPage', $node);
$request->attributes->set('action', $restOfPath);
$request->attributes->set('_controller', 'AppBundle:Booking:index');
} elseif ($restOfPath && $node->getTravelProgram() !== null && (
$restOfPath === '/pdf')) {
$request->attributes->set('_controller', 'AppBundle:Cms:pdf');
} elseif ($node->getTravelProgram() !== null) {
if ($node->getTravelProgram()->getStatus() == 0) {
throw new \NotFoundHttpException('Inactive travel program');
}
$request->attributes->set('_controller', 'AppBundle:Cms:travelProgram');
} elseif (
$node->getFewoLodging() != null &&
($restOfPath === '/buchen' || $restOfPath === '/berechne-gesamtpreis')
) {
$request->attributes->set('fewoTravelProgramPage', $node);
$request->attributes->set('action', $restOfPath);
$request->attributes->set('_controller', 'AppBundle:FewoBooking:index');
} elseif ($node->getFewoLodging() !== null) {
$request->attributes->set('fewoLodgingPage', $node);
$request->attributes->set('action', $restOfPath);
$request->attributes->set('_controller', 'AppBundle:Cms:fewoLodging');
} else {
$handler = $node->getTemplate() ? ucfirst($node->getTemplate()) : 'Default';
$request->attributes->set('_controller', 'AppBundle:Cms:' . $handler);
if ($node->getTemplate()) {
try {
$controller = $this->controllerResolver->getController($request);
} catch (\LogicException $e) {
// If there is no controller action, call the default action and pass the template name
$request->attributes->set('_controller', 'AppBundle:Cms:Default');
$request->attributes->set('template', $node->getTemplate());
}
}
}
} else {
return;
}
$event->setController($controller ?? $this->controllerResolver->getController($request));
}
}
}

View file

@ -0,0 +1,180 @@
<?php
namespace AppBundle\Entity;
use Gedmo\Tree\Entity\Repository\NestedTreeRepository;
use Doctrine\ORM\Query\Expr;
/**
* PageRepository
*
* This class was generated by the Doctrine ORM. Add your own custom
* repository methods below.
*/
class PageRepository extends NestedTreeRepository
{
/**
* @param Page $page
* @return Page[]|array
*
* @todo Optimize performance by adapting search algorithm's optimizations
*/
public function getChildrenWithTravelProgramsAndDates(Page $page)
{
$pages = $this->getChildrenQueryBuilder($page)
->leftJoin('node.travelProgram', 'tp')
->addSelect('tp')
->andWhere('tp.status > 0')
->andWhere('node.status > 0')
->orderBy('node.order')
->addOrderBy('tp.position')
->addOrderBy('node.title')
->getQuery()
->execute();
/** @var Page $childPage */
foreach ($pages as &$childPage) {
if ($childPage->getTravelProgram()) {
$this->getEntityManager()->getRepository('AppBundle:TravelPeriod')->getTrueTravelPeriods(
$childPage->getTravelProgram()
);
}
}
return $pages;
}
public function findWithTravelProgramsOfCountry(TravelCountry $country)
{
return $this->createQueryBuilder('node')
->innerJoin('node.travelProgram', 'tp')
->innerJoin('tp.countries', 'c')
->where('c.id = ' . $country->getId())
->andWhere('node.status = 1')
->andWhere('tp.status = 1')
->getQuery()
->execute()
;
}
/**
* @return Page[]
*/
public function findOffers()
{
$ret = [];
$countries = $this->getEntityManager()->getRepository('AppBundle:TravelCountry')->findAll();
foreach ($countries as $country) {
$ret = array_merge(
$ret,
$this->createQueryBuilder('node')
->innerJoin('node.travelProgram', 'tp')
->addSelect('tp')
->innerJoin('tp.countries', 'c')
->where('c.id = ' . $country->getId())
->andWhere('node.status = 1')
->andWhere('tp.status = 1')
->orderBy('node.order')
->addOrderBy('tp.position')
->addOrderBy('node.title')
->setMaxResults(3)
->getQuery()
->execute()
);
}
shuffle($ret);
return $ret;
}
public function findCountryPages()
{
return $this->createQueryBuilder('node')
->innerJoin('node.country', 'country')
->where('node.status > 0')
->andWhere('node.template = \'overview\'')
->andWhere('node.lvl = 0')
->andWhere('node.order > 0')
->orderBy('node.order,node.title')
->getQuery()
->execute()
;
}
public function findTopCountryNavPages()
{
return $this->createQueryBuilder('node')
->innerJoin('node.country', 'country')
->leftJoin('node.children', 'childPage', Expr\Join::WITH, 'childPage.status > 0')
->addSelect('childPage')
->where('node.status > 0')
->andWhere('node.template = \'overview\'')
->andWhere('node.lvl = 0')
->andWhere('node.order > 0')
->orderBy('node.order,node.title, childPage.order, childPage.title')
->getQuery()
->execute()
;
}
public function findFeedbacks($rootPageId)
{
$qb = $this->createQueryBuilder('node');
return $qb
->where($qb->expr()->eq('node.parent', $rootPageId))
->andWhere('node.showInNavi = 1')
->andWhere('node.status = 1')
->orderBy('node.order')
->getQuery()
->execute()
;
}
public function findParentsWithShowNav($rootPageId)
{
$qb = $this->createQueryBuilder('node');
$pages = $qb->innerJoin('node.travelProgram', 'tp')
->addSelect('tp')
->where($qb->expr()->eq('node.parent', $rootPageId))
->andWhere('node.showInNavi = 1')
->andWhere('node.status = 1')
->andWhere('tp.status > 0')
->orderBy('node.order')
->getQuery()
->execute();
foreach ($pages as &$childPage) {
if ($childPage->getTravelProgram()) {
// var_dump($childPage->getTravelProgram()->getId());
// $this->getEntityManager()->getRepository('AppBundle:TravelPeriod')->getTrueTravelPeriods($childPage->getTravelProgram());
}
}
return $pages;
}
/**
* @param Page $page
*
* @return Page[]|\Doctrine\Common\Collections\Collection
*/
public function getSiblings(Page $page)
{
$parent = $page->getParent();
if (!$parent) {
// On purpose, we don't treat root pages as if they were siblings
return [];
}
$siblings = $parent->getChildren();
foreach ($siblings as &$sibling) {
$sibling->setParent($parent);
}
// Da diese Methode nur für die Navigation verwendet wird, kann man hier vorfiltern
$filteredSiblings = [];
foreach ($siblings as &$sibling) {
if ($sibling->getStatus() == 1 && $sibling->getShowInNavi() == 1) {
$filteredSiblings[] = $sibling;
}
}
return $filteredSiblings;
}
}

View file

@ -0,0 +1,197 @@
# Frontend Navigation - Backend API
Dieses Verzeichnis enthält die Dokumentation und Referenzimplementierungen für die Frontend-Navigation.
## Übersicht
Die Navigation-API stellt die komplette Seitenstruktur des CMS als hierarchischen Baum oder flache Liste zur Verfügung.
## Schnellstart
### Backend-UI (Admin-Interface)
Zugriff über: **Navigation API** im Seitenmenü (Permission: `crm-nav-api`)
Die Backend-UI zeigt die **komplette Frontend-Navigation**:
- 📊 Live-Statistiken (Gesamt, Aktiv, Programme, Länder)
- 🌍 **Länder-Navigation** mit Children (gruppiert nach Haupt/Infos)
- 🏠 **USEDOM Ferienwohnungen** mit allen FeWos
- 📑 **Mehr-Menü Seiten** (Über uns, Reiseversicherung, Reiseführer, Reisemagazin, Reisenews)
- 🔍 Volltext-Suche über Titel, Slug und URL
- 🌳 Interaktiver Nested Tree mit Auf-/Zuklappen
- 🎯 Filter (Mit ausgeblendeten/Nur sichtbare)
- 📥 JSON-Export
- 🔄 Cache-Verwaltung
### API-Endpunkte
```
GET /api/navigation/tree - Kompletter Navigationsbaum
GET /api/navigation/tree/active - Nur aktive Navigationspunkte
GET /api/navigation/tree/{rootId} - Teilbaum ab einer bestimmten Page
GET /api/navigation/flat - Flache Liste aller Pages
GET /api/navigation/breadcrumb/{pageId} - Breadcrumb-Pfad
```
### Beispiel-Verwendung
```javascript
// Kompletten Navigationsbaum abrufen
fetch("/api/navigation/tree")
.then((response) => response.json())
.then((data) => {
console.log(data.data); // Navigationsbaum
});
```
## Dateien
- **README.md** - Diese Datei (Übersicht)
- **BACKEND-UI.md** - Dokumentation der Backend-UI (Admin-Interface)
- **navigation.md** - Dokumentation der Frontend-Routing-Logik (Symfony-basiert)
- **navigation-api.md** - Vollständige API-Dokumentation mit Beispielen
- **test-api.php** - CLI-Test-Script für alle API-Endpunkte
- **test-api.html** - Browser-basierte interaktive Test-Oberfläche
- **KernelControllerListener.php** - Referenz: Symfony KernelControllerListener
- **PageRepository.php** - Referenz: Doctrine Page Repository
## Implementierung
Die API wurde im Laravel-Backend implementiert:
### Backend-Komponenten
**API-Layer:**
1. **API Controller:** `app/Http/Controllers/API/NavigationController.php`
- REST API-Endpunkte
- JSON-Responses
2. **Web Controller:** `app/Http/Controllers/NavigationTreeController.php`
- Backend-UI (Admin-Interface)
- Daten-Export und Cache-Management
3. **Service:** `app/Services/NavigationTreeService.php`
- Business-Logik
- Rekursiver Baum-Aufbau
- Caching (60 Min.)
- Helper-Methoden (Breadcrumb, Node-Suche, etc.)
4. **Model:** `app/Models/Page.php`
- Eloquent Model für die Page-Entität
- Relationships (parent, children, travel_program, etc.)
5. **Views:** `resources/views/navigation/index.blade.php`
- Interaktive Baum-Visualisierung
- Suche und Filter
- Statistiken
6. **Routes:**
- API: `routes/api.php`
- Web: `routes/web.php` (Permission: `crm-tp-na`)
## Features
### Navigationsbaum
- ✅ Hierarchische Struktur mit unbegrenzter Tiefe
- ✅ Alle Page-Eigenschaften enthalten (SEO, Template, Status, etc.)
- ✅ Beziehungen zu TravelProgram, FewoLodging, Country
- ✅ Filterung nach Status und Sichtbarkeit
- ✅ Sortierung nach Order und Title
### Zusätzliche Funktionen
- ✅ Breadcrumb-Generierung
- ✅ Teilbaum-Abfrage
- ✅ Flache Listen-Ansicht
- ✅ Node-Zählung
- ✅ URL-Pfad-Generierung
### Performance
- Effiziente Queries mit Eager Loading
- Rekursive Baum-Konstruktion
- Unterstützung für Nested Set Tree (lft/rgt)
- Caching-ready
## Datenstruktur
Jeder Navigationspunkt enthält:
```json
{
"id": 1,
"title": "Seitentitel",
"slug": "seitentitel",
"url": "/seitentitel",
"status": 1,
"show_in_navi": 1,
"order": 1,
"template": "default",
"lvl": 0,
"parent_id": null,
"is_travel_program": false,
"is_fewo_lodging": false,
"is_country_page": false,
"children": [],
"has_children": false
}
```
Siehe `navigation-api.md` für eine vollständige Feldbeschreibung.
## Routing-Logik (Frontend)
Das Frontend verwendet einen `KernelControllerListener` (Symfony), der:
1. **API-Lookup** prüft (externe Reiseführer)
2. **Datenbank-Suche** durchführt (Page-Entität)
3. **Redirects** verarbeitet (301-Weiterleitungen)
4. **Controller zuweist** basierend auf Content-Type
Siehe `navigation.md` für Details zur Routing-Logik.
## Unterschiede Frontend/Backend
| Aspekt | Frontend (Symfony) | Backend (Laravel) |
| ---------- | ----------------------------- | -------------------------- |
| Framework | Symfony 2.x | Laravel 5.x |
| ORM | Doctrine | Eloquent |
| Routing | KernelControllerListener | API Routes |
| Tree-Logik | Nested Set + Parent Traversal | Parent-Child Relationships |
| API | External (IQ API) | Internal (Laravel) |
## Wartung
### Neue Felder hinzufügen
1. Füge das Feld in `NavigationTreeService::buildNodeData()` hinzu
2. Aktualisiere die Dokumentation in `navigation-api.md`
### Performance optimieren
1. Füge Caching in `NavigationTreeService` hinzu
2. Verwende Eager Loading für Relations
3. Implementiere Pagination für große Bäume
### Tests
Empfohlene Tests:
- Unit Tests für `NavigationTreeService`
- API Tests für alle Endpunkte
- Performance Tests für große Navigationsbäume
## Support
Bei Fragen zur API siehe:
- `navigation-api.md` - Vollständige API-Dokumentation
- `navigation.md` - Frontend Routing-Dokumentation

View file

@ -0,0 +1,325 @@
<div id="topBar" class="">
<div class="container-fluid">
{% if content.info.office_important_note_active == 1 %}
<ul class="top-links block">
<li class="icon">
<a class="dropdown-toggle no-text-underline" data-toggle="dropdown" data-hover="dropdown" href="#"><i class="fa fa-info"></i></a>
<div class="dropdown-menu dropdown-menu-left dropdown-menu-infos">
<div class="dropdown-menu-header">
<span><i class="fa fa-info"></i> aktuelle Infos</span>
</div>
<div class="dropdown-menu-body">
{{ content.info.office_important_note }}
</div>
</div>
</li>
<li style="overflow: hidden;width: 100%;">
<div id="marquee" class="marquee"><span> {{ content.info.office_important_note }}</span></div>
</li>
</ul>
{% endif %}
<ul class="top-links block wrap" id="topNavAccordion">
<li>
<a class="dropdown-toggle no-text-underline collapsed" data-toggle="collapse" href="#collapseTopTravelDates" role="button" aria-expanded="false" aria-controls="collapseTopTravelDates">
<i class="fa fa-plane"></i> Reisetermine
<i class="fa fa-caret-collapse"></i>
</a>
<!-- ab Montag um 09:00 Uhr -->
<!-- bis xxx Uhr -->
<div class="dropdown-menu-infos collapse" id="collapseTopTravelDates">
<div class="dropdown-menu-body">
<div class="badge badge-default btn-block">
<span class="text-default">{{ header_travel_program.title }} <br>
</div>
<table class="table table-condensed table-vertical-middle">
<tr>
<th class="text-left">Hinflug</th>
<th class="text-left">Rückflug</th>
<th class="text-left">Preis p. P.</th>
</tr>
{% set last_name = "" %}
{% for travel_date in header_travel_program.travelDates('header') if travel_date.status >= 0 %}
{% if loop.index <= 6 %}
{% if last_name != travel_date.name %}
{% set last_name = travel_date.name %}
<tr>
<td class="text-left">{{ travel_date.start|date }}</td>
<td class="text-left">{{ travel_date.end|date }}</td>
<td class="text-left">
<strong>
{% if travel_date.prices[3] is defined %}
{% if travel_date.prices[3].available == "1" %}
{% if travel_date.prices[3].effectiveDiscountPrice %}
<a href="{{ header_travel_program.page.urlPath }}" style="color: #558c55; text-decoration: underline;">
ab {{ travel_date.prices[3].effectiveDiscountPrice|number_format }}
</a>
{% endif %}
{% endif %}
{% endif %}
</strong>
</td>
</tr>
{% endif %}
{% endif %}
{% endfor %}
</table>
<a href="{{ header_travel_program.page.getUrlPathBefore }}">weitere Rundreisen ansehen</a>
</div>
</div>
</li>
<li>
<a class="dropdown-toggle no-text-underline collapsed" data-toggle="collapse" href="#collapseTopPhone" role="button" aria-expanded="false" aria-controls="collapseTopPhone">
<i class="fa fa-phone-square"></i> 030 - 700 94 100 &bull;
{% if(content.available.phone.active) %}
<span class="text-success">erreichbar</span>
{% else %}
erreichbar
{% endif %}
{{ content.available.phone.content }}
<i class="fa fa-caret-collapse"></i>
</a>
<!-- ab Montag um 09:00 Uhr -->
<!-- bis xxx Uhr -->
<div class="dropdown-menu-infos collapse" id="collapseTopPhone">
<div class="dropdown-menu-body">
<p><a href="tel:030 - 700 94 100" class="btn btn-secondary btn-sm btn-block text-center"><i class="fa fa-phone-square text-success" ></i> 030 - 700 94 100</a></p>
<hr>
<div class="badge badge-default btn-block">
{% if(content.available.phone.active) %}
<span class="text-success"><i class="fa fa-check-circle fa-lg"></i></span> Wir sind zur Zeit telefonisch zu erreichen.
{% else %}
<span class="text-danger"><i class="fa fa-times-circle fa-lg"></i></span> Wir sind zur Zeit telefonisch nicht zu erreichen.
{% endif %}
</div>
<table class="table table-condensed table-vertical-middle">
{% for key, val in phone %}
<tr>
<td class="text-left" style="width: 50%">{{ val.day }} <span class="text-muted pull-right"> {{ val.date }}</span></td>
{% if val.active == 0 %}
<td colspan="3">geschlossen</td>
{% else %}
<td>{{ val.from }}</td><td>-</td><td>{{ val.to }}</td>
{% endif %}
</tr>
{% endfor %}
</table>
</div>
</div>
</li>
<li>
<a class="dropdown-toggle no-text-underline collapsed" data-toggle="collapse" href="#collapseTopLocal" role="button" aria-expanded="false" aria-controls="collapseTopLocal">
<i class="fa fa-clock-o"></i> Reisebüro
{% if(content.available.local.active) %}
<span class="text-success">geöffnet</span> &bull;
{% else %}
<span class="text-danger">geschlossen</span> &bull;
{% endif %}
{{ content.available.local.content }}
<i class="fa fa-caret-collapse"></i>
</a>
<div class="dropdown-menu-infos collapse" id="collapseTopLocal">
<div class="dropdown-menu-body">
<div class="badge badge-default btn-block">
{% if(content.available.local.active) %}
<span class="text-success"><i class="fa fa-check-circle fa-lg"></i></span> Unsere Büro ist aktuell geöffnet.
{% else %}
<span class="text-danger"><i class="fa fa-times-circle fa-lg"></i></span> Unsere Büro ist aktuell geschlossen.
{% endif %}
</div>
<table class="table table-condensed table-vertical-middle">
{% for key, val in local %}
<tr>
<td class="text-left" style="width: 50%">{{ val.day }} <span class="text-muted pull-right"> {{ val.date }}</span></td>
{% if val.active == 0 %}
<td colspan="3">geschlossen</td>
{% else %}
<td>{{ val.from }}</td><td>-</td><td>{{ val.to }}</td>
{% endif %}
</tr>
{% endfor %}
</table>
</div>
</div>
</li>
<li>
<a class="dropdown-toggle no-text-underline collapsed" data-toggle="collapse" href="#collapseTopContact" role="button" aria-expanded="false" aria-controls="collapseTopContact"><i class="fa fa-envelope"></i> Kontakt &bull; Formular &bull; Terminvereinbarung <i class="fa fa-caret-collapse"></i></a>
<div class="dropdown-menu-infos collapse" id="collapseTopContact">
<div class="dropdown-menu-body">
{{ content.info.office_appointment | raw }}
</div>
</div>
</li>
</ul>
</div>
</div>
<div id="header" class="sticky clearfix">
<!-- TOP NAV -->
<header id="topNav">
<div class="container-fluid">
<!-- Mobile Menu Button -->
<button class="btn btn-mobile" data-toggle="collapse" data-target=".nav-main-collapse">
<i class="fa fa-bars"></i> Menü
</button>
<button class="btn btn-primary btn-mobile-info myanimated my_fadein">
<i class="fa fa-info"></i>
</button>
<!-- Logo -->
<a class="logo" href="/">
<img src="{{ asset('images/wlogo.png') }}" alt="Stern Tours">
</a>
<div class="navbar-collapse nav-main-collapse collapse">
<nav class="nav-main">
<ul class="topMain nav nav-pills nav-main md-pull-left">
<li class=" active"><!-- HOME -->
<a href="/" title="Kulturreisen" itemprop="">
<i class="fa fa-home fa-lg"></i> <span class="hidden-md hidden-lg">Kulturreisen</span>
</a>
</li>
{% for nav_page in nav_pages if nav_page.country is not empty %}
{% if nav_page.showInNavi == 1 %}
{# @var nav_page \AppBundle\Entity\Page #}
<li class="dropdown">
<a class="dropdown-toggle" href="{{ nav_page.urlPath }}">
<i class="fa fa-star"></i> {{ nav_page.title|replace({'Reisen': ''}) }} <span class="hidden-md hidden-lg">Reisen</span>
</a>
<ul class="dropdown-menu">
<li>
<h4><i class="fa fa-star"></i> <a href="{{ nav_page.urlPath }}" title="{{ nav_page.title }}">
{{ nav_page.title|replace({'Reisen': ''}) }} Reisen
</a></h4>
</li>
{% for childnav_page in nav_page.children %}
{# @var childnav_page \AppBundle\Entity\Page #}
{% if(childnav_page.beforeTitle == "Infos") %}
<li>
<h4><i class="fa fa-info-circle"></i> &nbsp;Infos</h4>
</li>
{% endif %}
<li role="menuitem" itemprop="name">
<a itemprop="url" href="{{ childnav_page.urlPath }}" title="{{ childnav_page.title }}">
{{ childnav_page.titleShort }}
</a>
</li>
{% endfor %}
</ul>
</li>
{% endif %}
{% endfor %}
</ul>
<ul class="topMain nav nav-pills nav-main md-pull-right">
<li class="dropdown">
<a class="dropdown-toggle extra-margin-left" href="/ferienwohnungen">
<span class="text-usedom">USEDOM</span> <span class="hidden-md">Ferienwohnungen</span> <span class="hidden-sm hidden-lg hidden-xs">FeWo</span>
</a>
<ul class="dropdown-menu pull-right">
<li role="menuitem" itemprop="name">
<a itemprop="url" href="/ferienwohnungen" title="Usedom Ferienwohnungen"><i class="isv-fewo"></i> Übersicht </a>
</li>
<li class="divider"></li>
<li role="menuitem" itemprop="name">
<a itemprop="url" href="/ferienwohnungen/fewo1-strandstr29" title="FeWo 1 Strandstr. 29">
<i class="isv-fewo"></i> FeWo 1 Strandstr. 29
</a>
</li>
<li role="menuitem" itemprop="name">
<a itemprop="url" href="/ferienwohnungen/fewo2-strandstr29" title="FeWo 2 Strandstr. 29">
<i class="isv-fewo"></i> FeWo 2 Strandstr. 29
</a>
</li>
<li role="menuitem" itemprop="name">
<a itemprop="url" href="/ferienwohnungen/fewo3-strandstr29" title="FeWo 3 Strandstr. 29">
<i class="isv-fewo"></i> FeWo 3 Strandstr. 29
</a>
</li>
<li role="menuitem" itemprop="name">
<a itemprop="url" href="/ferienwohnungen/fewo4-strandstr29" title="FeWo 4 Strandstr. 29">
<i class="isv-fewo"></i> FeWo 4 Strandstr. 29
</a>
</li>
<li role="menuitem" itemprop="name">
<a itemprop="url" href="/ferienwohnungen/fewo1-triftweg10" title="FeWo 1 Triftweg 10">
<i class="isv-fewo"></i> FeWo 1 Triftweg 10
</a>
</li>
<li role="menuitem" itemprop="name">
<a itemprop="url" href="/ferienwohnungen/fewo2-triftweg10" title="FeWo 2 Triftweg 10">
<i class="isv-fewo"></i> FeWo 2 Triftweg 10
</a>
</li>
<li role="menuitem" itemprop="name">
<a itemprop="url" href="/ferienwohnungen/fewo3-triftweg10" title="FeWo 3 Triftweg 10">
<i class="isv-fewo"></i> FeWo 3 Triftweg 10
</a>
</li>
<li role="menuitem" itemprop="name">
<a itemprop="url" href="/ferienwohnungen/fewo4-triftweg10" title="FeWo 4 Triftweg 10">
<i class="isv-fewo"></i> FeWo 4 Triftweg 10
</a>
</li>
</ul>
</li>
<li class="dropdown">
<a class="dropdown-toggle" href="#">
<i class="fa fa-ellipsis-v fa-lg"></i> <span class="hidden-md hidden-lg">mehr</span>
</a>
<ul class="dropdown-menu pull-right">
<li role="menuitem" itemprop="name">
<a itemprop="url" href="/tuerkei-reisen" title="Türkei Reisen"><i class="fa fa-star"></i> Türkei Reisen </a>
</li>
<li role="menuitem" itemprop="name">
<a itemprop="url" href="/israel-reisen" title="Israel Reisen"><i class="fa fa-star"></i> Israel Reisen </a>
</li>
<!--
<li role="menuitem" itemprop="name">
<a itemprop="url" href="/usbekistan-reisen" title="Usbekistan Reisen"><i class="fa fa-star"></i> Usbekistan Reisen </a>
</li>
<li role="menuitem" itemprop="name">
<a itemprop="url" href="/marokko-urlaub" title="Marokko Reisen"><i class="fa fa-star"></i> Marokko Reisen</a>
</li>
-->
<li class="divider"></li>
<li role="menuitem" itemprop="name">
<a itemprop="url" href="/ueber-uns" title="Über uns"><i class="fa fa-users"></i> Über uns</a>
</li>
<li class="divider"></li>
<li role="menuitem" itemprop="name">
<a itemprop="url" href="/reiseversicherung" title="Reiseversicherung">
<i class="fa fa-shield"></i> Reiseversicherung
</a>
</li>
<li role="menuitem" itemprop="name">
<a itemprop="url" href="/reisefuehrer" title="Reiseführer">
<i class="fa fa-flag"></i> Reiseführer
</a>
</li>
<li role="menuitem" itemprop="name">
<a itemprop="url" href="/reisemagazin" title="Reisemagazin">
<i class="fa fa-book"></i> Reisemagazin
</a>
</li>
<li role="menuitem" itemprop="name">
<a itemprop="url" href="/reisenews" title="Reisenews">
<i class="fa fa-newspaper-o"></i> Reisenews
</a>
</li>
</ul>
</li>
</ul>
</nav>
</div>
</div>
</header>
</div>

View file

@ -0,0 +1,414 @@
# Navigation API Dokumentation
Die Navigation-API stellt Endpunkte bereit, um die Frontend-Navigationsstruktur im Backend abzurufen.
## Basis-URL
Alle Endpunkte sind unter `/api/navigation/` verfügbar.
## Endpunkte
### 1. Kompletter Navigationsbaum
**GET** `/api/navigation/tree`
Gibt den kompletten hierarchischen Navigationsbaum mit allen Seiten zurück.
**Response:**
```json
{
"success": true,
"data": [
{
"id": 1,
"title": "Startseite",
"title_short": null,
"before_title": null,
"slug": "startseite",
"real_url_path": "/",
"url": "/",
"status": 1,
"show_in_navi": 1,
"order": 1,
"template": "home",
"lvl": 0,
"parent_id": null,
"pagetitle": "Willkommen",
"description": "Startseite",
"keywords": "start, home",
"canonical_url": null,
"lft": 1,
"rgt": 10,
"tree_root": 1,
"box_body": null,
"box_image_url": null,
"box_star": null,
"box_discount": null,
"is_travel_program": false,
"is_fewo_lodging": false,
"is_country_page": false,
"cms_settings": null,
"children": [
{
"id": 2,
"title": "Über uns",
"slug": "ueber-uns",
"url": "/ueber-uns",
"children": [],
"has_children": false
}
],
"has_children": true
}
],
"meta": {
"total_nodes": 42,
"generated_at": "2024-01-15T10:30:00+00:00"
}
}
```
---
### 2. Nur aktive Navigationspunkte
**GET** `/api/navigation/tree/active`
Gibt nur aktive Seiten zurück (`status = 1` und `show_in_navi = 1`).
**Response:** Gleiche Struktur wie oben, aber gefiltert.
---
### 3. Teilbaum ab einem bestimmten Knoten
**GET** `/api/navigation/tree/{rootId}`
Gibt einen Teilbaum zurück, beginnend mit der angegebenen Page-ID.
**Parameter:**
- `rootId` (integer): Die ID der Seite, ab der der Baum aufgebaut werden soll
**Beispiel:** `/api/navigation/tree/5`
**Response:**
```json
{
"success": true,
"data": {
"id": 5,
"title": "Reisen",
"slug": "reisen",
"url": "/reisen",
"children": [
{
"id": 6,
"title": "Türkei",
"slug": "tuerkei",
"url": "/reisen/tuerkei",
"children": [],
"has_children": false
}
],
"has_children": true
},
"meta": {
"total_nodes": 15,
"generated_at": "2024-01-15T10:30:00+00:00"
}
}
```
---
### 4. Flache Liste (ohne Hierarchie)
**GET** `/api/navigation/flat`
Gibt alle Navigationspunkte als flache Liste zurück (ohne parent-child Beziehung).
**Response:**
```json
{
"success": true,
"data": [
{
"id": 1,
"title": "Startseite",
"slug": "startseite",
"url": "/",
"status": 1,
"show_in_navi": 1,
"order": 1,
"template": "home",
"lvl": 0,
"parent_id": null,
"is_travel_program": false,
"is_fewo_lodging": false,
"is_country_page": false
},
{
"id": 2,
"title": "Über uns",
"slug": "ueber-uns",
"url": "/ueber-uns",
"status": 1,
"show_in_navi": 1,
"order": 2,
"template": "default",
"lvl": 0,
"parent_id": null,
"is_travel_program": false,
"is_fewo_lodging": false,
"is_country_page": false
}
],
"meta": {
"total_nodes": 42,
"generated_at": "2024-01-15T10:30:00+00:00"
}
}
```
---
### 5. Breadcrumb-Pfad
**GET** `/api/navigation/breadcrumb/{pageId}`
Gibt den Breadcrumb-Pfad für eine bestimmte Seite zurück (von der Wurzel bis zur Zielseite).
**Parameter:**
- `pageId` (integer): Die ID der Seite, für die der Breadcrumb erstellt werden soll
**Beispiel:** `/api/navigation/breadcrumb/15`
**Response:**
```json
{
"success": true,
"data": [
{
"id": 1,
"title": "Startseite",
"title_short": null,
"url": "/",
"slug": "startseite"
},
{
"id": 5,
"title": "Reisen",
"title_short": null,
"url": "/reisen",
"slug": "reisen"
},
{
"id": 10,
"title": "Türkei",
"title_short": null,
"url": "/reisen/tuerkei",
"slug": "tuerkei"
},
{
"id": 15,
"title": "Istanbul",
"title_short": null,
"url": "/reisen/tuerkei/istanbul",
"slug": "istanbul"
}
],
"meta": {
"depth": 4,
"generated_at": "2024-01-15T10:30:00+00:00"
}
}
```
---
## Felder-Beschreibung
Jeder Navigationspunkt enthält folgende Informationen:
### Basis-Informationen
- `id`: Eindeutige ID der Seite
- `title`: Vollständiger Titel
- `title_short`: Kurzer Titel (optional)
- `before_title`: Text vor dem Titel (optional)
- `slug`: URL-freundlicher Name
- `real_url_path`: Der definierte URL-Pfad aus der Datenbank
- `url`: Der vollständige URL-Pfad (entweder `real_url_path` oder automatisch generiert)
### Navigationsinformationen
- `status`: Status der Seite (0 = inaktiv, 1 = aktiv)
- `show_in_navi`: Soll in Navigation angezeigt werden (0 = nein, 1 = ja)
- `order`: Sortierreihenfolge
- `lvl`: Hierarchie-Ebene (0 = Root)
- `parent_id`: ID der Eltern-Seite (null = Root)
### SEO-Informationen
- `pagetitle`: SEO-Titel
- `description`: Meta-Beschreibung
- `keywords`: Meta-Keywords
- `canonical_url`: Kanonische URL
### Template & Layout
- `template`: Template-Name (z.B. "home", "overview", "default")
- `box_body`: Inhalt für Box-Darstellung
- `box_image_url`: Bild-URL für Box
- `box_star`: Sternebewertung
- `box_discount`: Rabatt-Information
### Tree-Struktur
- `lft`, `rgt`: Nested Set Tree Werte
- `tree_root`: ID der Baumwurzel
### Content-Type Flags
- `is_travel_program`: Ist ein Reiseprogramm
- `is_fewo_lodging`: Ist eine Ferienwohnung
- `is_country_page`: Ist eine Länderseite
### Beziehungen (wenn vorhanden)
**Travel Program:**
```json
"travel_program": {
"id": 123,
"title": "7 Tage Istanbul",
"subtitle": "Die schönsten Orte",
"status": 1,
"position": 1,
"duration": 7,
"price_from": 899.00
}
```
**Fewo Lodging:**
```json
"fewo_lodging": {
"id": 45,
"name": "Villa am Meer",
"status": 1
}
```
**Country:**
```json
"country": {
"id": 10,
"name": "Türkei",
"title": "Türkei Reisen",
"slug": "tuerkei"
}
```
### Hierarchie-Informationen
- `children`: Array von Child-Knoten (gleiche Struktur)
- `has_children`: Boolean, ob Kinder vorhanden sind
---
## Fehlerbehandlung
Bei Fehlern wird ein JSON-Response mit folgendem Format zurückgegeben:
```json
{
"success": false,
"error": "Fehlerbeschreibung"
}
```
**HTTP Status Codes:**
- `200`: Erfolgreiche Anfrage
- `404`: Seite/Ressource nicht gefunden
- `500`: Serverfehler
---
## Verwendungsbeispiele
### JavaScript/Fetch
```javascript
// Kompletten Navigationsbaum abrufen
fetch("/api/navigation/tree")
.then((response) => response.json())
.then((data) => {
console.log("Navigation Tree:", data.data);
console.log("Total Nodes:", data.meta.total_nodes);
});
// Breadcrumb für eine Seite abrufen
fetch("/api/navigation/breadcrumb/15")
.then((response) => response.json())
.then((data) => {
console.log("Breadcrumb:", data.data);
});
```
### PHP/Laravel
```php
use Illuminate\Support\Facades\Http;
// Kompletten Navigationsbaum abrufen
$response = Http::get('http://yourdomain.com/api/navigation/tree');
$navigationTree = $response->json()['data'];
// Teilbaum abrufen
$response = Http::get('http://yourdomain.com/api/navigation/tree/5');
$subTree = $response->json()['data'];
```
### cURL
```bash
# Kompletten Baum
curl -X GET http://yourdomain.com/api/navigation/tree
# Nur aktive Navigationspunkte
curl -X GET http://yourdomain.com/api/navigation/tree/active
# Breadcrumb
curl -X GET http://yourdomain.com/api/navigation/breadcrumb/15
```
---
## Performance-Hinweise
- Der komplette Navigationsbaum kann bei großen Websites viele Knoten enthalten
- Verwenden Sie `/api/navigation/tree/active` für Frontend-Navigationen
- Verwenden Sie `/api/navigation/tree/{rootId}` um nur relevante Teilbäume zu laden
- Erwägen Sie Caching für häufig abgerufene Navigationsstrukturen
---
## Implementierung
Die Navigation-API basiert auf folgenden Komponenten:
1. **Controller:** `App\Http\Controllers\API\NavigationController`
2. **Service:** `App\Services\NavigationTreeService`
3. **Model:** `App\Models\Page`
4. **Routen:** Definiert in `routes/api.php`
Der Service verwendet rekursive Methoden, um den hierarchischen Baum aufzubauen und kann einfach erweitert werden.

View file

@ -0,0 +1,47 @@
# Routing & Navigations-Logik (KernelControllerListener)
Die Klasse `src/AppBundle/Listener/KernelControllerListener.php` implementiert die zentrale Routing-Logik für das CMS. Sie greift ein, wenn der Symfony-Router den Request an `AppBundle\Controller\DefaultController::defaultAction` leitet.
## Funktionsweise
Der Listener analysiert den Request-Pfad (`pathInfo`) und entscheidet, welcher Controller tatsächlich ausgeführt werden soll.
### 1. API-Lookup (Priorität 1)
Zuerst wird geprüft, ob der Pfad über einen externen API-Call (`Util::loadFromApi('cms/search', ...)`) aufgelöst werden kann.
- **Ziel-Controller:** `AppBundle:Cms:iqTravelGuide`
- **Template:** `TravelGuide`
### 2. Datenbank-Suche (Page Entity)
Wenn die API nichts zurückgibt, wird in der lokalen Datenbank (`AppBundle:Page`) gesucht.
- **Methode A (Direkt):** Suche nach Übereinstimmung im Feld `realUrlPath`.
- **Methode B (Tree-Traversierung):** Der Pfad wird anhand der Slashes (`/`) zerlegt. Es wird versucht, den Pfad hierarchisch über `slug` und die Eltern-Kind-Beziehung (`parent`) aufzulösen. Die anfällige Nested-Set-Logik (`lft`/`rgt`, `lvl`) wird hier bewusst umgangen, um 404-Fehler bei inkonsistenten Trees zu vermeiden.
### 3. Redirects
- **Hardcoded:** Es gibt eine Liste fester 301-Weiterleitungen (z.B. alte Reiseführer-URLs).
- **Datenbank:** Tabelle `AppBundle:Redirect`. Wenn keine Page gefunden wird, wird hier geprüft.
### 4. Controller-Zuweisung
Sobald eine `Page`-Entität (`$node`) gefunden wurde, wird der Controller basierend auf dem Inhaltstyp bestimmt:
| Inhaltstyp | URL-Suffix | Controller | Action |
| ----------------- | ------------------------- | ----------------------- | ------------------------- |
| **TravelProgram** | `/buchen`, `/berechne...` | `AppBundle:Booking` | `index` |
| **TravelProgram** | `/pdf` | `AppBundle:Cms` | `pdf` |
| **TravelProgram** | _(keins)_ | `AppBundle:Cms` | `travelProgram` |
| **FewoLodging** | `/buchen` | `AppBundle:FewoBooking` | `index` |
| **FewoLodging** | _(keins)_ | `AppBundle:Cms` | `fewoLodging` |
| **Standard Page** | _(keins)_ | `AppBundle:Cms` | _Dyn. nach Template-Name_ |
**Fallback für Standard Pages:**
Wenn die Page ein Template (z.B. "About") definiert hat, versucht das System `AppBundle:Cms:About` aufzurufen. Existiert diese Action nicht, wird `AppBundle:Cms:Default` verwendet und der Template-Name als Parameter übergeben.
### 5. Fehlerbehandlung
- **404:** Wenn Pfad nicht gefunden und nicht in der Whitelist (`buchen`, `pdf` etc.) -> `HttpException(404)`.
- **Inaktiv:** Wenn Page-Status `0` ist -> `NotFoundHttpException`.

View file

@ -0,0 +1,496 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Navigation API Tester</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #f5f5f5;
padding: 20px;
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
color: #333;
margin-bottom: 10px;
}
.intro {
background: #fff;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.config {
background: #fff;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.config input {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
margin-top: 5px;
}
.endpoint-group {
background: #fff;
padding: 20px;
border-radius: 8px;
margin-bottom: 15px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.endpoint-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.endpoint-title {
font-size: 18px;
font-weight: bold;
color: #333;
}
.endpoint-method {
display: inline-block;
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
color: #fff;
}
.method-get {
background: #61affe;
}
.method-post {
background: #49cc90;
}
.endpoint-url {
background: #f7f7f7;
padding: 10px;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 14px;
margin-bottom: 15px;
word-break: break-all;
}
.endpoint-description {
color: #666;
margin-bottom: 15px;
}
.test-button {
background: #4CAF50;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
}
.test-button:hover {
background: #45a049;
}
.test-button:disabled {
background: #ccc;
cursor: not-allowed;
}
.result {
margin-top: 15px;
padding: 15px;
border-radius: 4px;
display: none;
}
.result.success {
background: #d4edda;
border: 1px solid #c3e6cb;
color: #155724;
}
.result.error {
background: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
}
.result-header {
font-weight: bold;
margin-bottom: 10px;
}
.result-meta {
margin-bottom: 10px;
font-size: 14px;
}
.result-data {
background: #f7f7f7;
padding: 10px;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 12px;
max-height: 400px;
overflow: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
.loading {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid #f3f3f3;
border-top: 3px solid #4CAF50;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-left: 10px;
vertical-align: middle;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.test-all {
background: #2196F3;
color: white;
border: none;
padding: 15px 30px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
margin-bottom: 20px;
transition: background 0.3s;
display: block;
width: 100%;
}
.test-all:hover {
background: #0b7dda;
}
.summary {
background: #fff;
padding: 20px;
border-radius: 8px;
margin-top: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
display: none;
}
.summary h2 {
margin-bottom: 15px;
color: #333;
}
.summary-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-top: 15px;
}
.stat-card {
padding: 15px;
border-radius: 4px;
text-align: center;
}
.stat-card.total {
background: #e3f2fd;
}
.stat-card.success {
background: #d4edda;
}
.stat-card.error {
background: #f8d7da;
}
.stat-number {
font-size: 32px;
font-weight: bold;
margin-bottom: 5px;
}
.stat-label {
font-size: 14px;
color: #666;
}
</style>
</head>
<body>
<div class="container">
<h1>🗺️ Navigation API Tester</h1>
<div class="intro">
<p>Dieses Tool testet alle verfügbaren Endpunkte der Navigation API und zeigt die Ergebnisse in einer strukturierten Form.</p>
</div>
<div class="config">
<label for="baseUrl"><strong>Basis-URL:</strong></label>
<input type="text" id="baseUrl" value="http://localhost/api/navigation" placeholder="http://localhost/api/navigation">
</div>
<button class="test-all" onclick="testAllEndpoints()">Alle Endpunkte testen</button>
<!-- Endpoint 1: Kompletter Navigationsbaum -->
<div class="endpoint-group">
<div class="endpoint-header">
<div class="endpoint-title">Kompletter Navigationsbaum</div>
<span class="endpoint-method method-get">GET</span>
</div>
<div class="endpoint-url">/tree</div>
<div class="endpoint-description">
Gibt den kompletten hierarchischen Navigationsbaum mit allen Seiten zurück.
</div>
<button class="test-button" onclick="testEndpoint('tree', 'GET')">Testen</button>
<div class="result" id="result-tree"></div>
</div>
<!-- Endpoint 2: Aktive Navigationspunkte -->
<div class="endpoint-group">
<div class="endpoint-header">
<div class="endpoint-title">Nur aktive Navigationspunkte</div>
<span class="endpoint-method method-get">GET</span>
</div>
<div class="endpoint-url">/tree/active</div>
<div class="endpoint-description">
Gibt nur aktive Seiten zurück (status = 1 und show_in_navi = 1).
</div>
<button class="test-button" onclick="testEndpoint('tree/active', 'GET')">Testen</button>
<div class="result" id="result-tree-active"></div>
</div>
<!-- Endpoint 3: Teilbaum -->
<div class="endpoint-group">
<div class="endpoint-header">
<div class="endpoint-title">Teilbaum ab Root-ID</div>
<span class="endpoint-method method-get">GET</span>
</div>
<div class="endpoint-url">/tree/{rootId}</div>
<div class="endpoint-description">
Gibt einen Teilbaum zurück, beginnend mit der angegebenen Page-ID.<br>
<input type="number" id="subtreeId" placeholder="Root Page ID" value="1" style="margin-top: 10px; padding: 8px; width: 200px;">
</div>
<button class="test-button" onclick="testEndpoint('tree/' + document.getElementById('subtreeId').value, 'GET')">Testen</button>
<div class="result" id="result-tree-subtree"></div>
</div>
<!-- Endpoint 4: Flache Liste -->
<div class="endpoint-group">
<div class="endpoint-header">
<div class="endpoint-title">Flache Liste</div>
<span class="endpoint-method method-get">GET</span>
</div>
<div class="endpoint-url">/flat</div>
<div class="endpoint-description">
Gibt alle Navigationspunkte als flache Liste zurück (ohne parent-child Beziehung).
</div>
<button class="test-button" onclick="testEndpoint('flat', 'GET')">Testen</button>
<div class="result" id="result-flat"></div>
</div>
<!-- Endpoint 5: Breadcrumb -->
<div class="endpoint-group">
<div class="endpoint-header">
<div class="endpoint-title">Breadcrumb-Pfad</div>
<span class="endpoint-method method-get">GET</span>
</div>
<div class="endpoint-url">/breadcrumb/{pageId}</div>
<div class="endpoint-description">
Gibt den Breadcrumb-Pfad für eine bestimmte Seite zurück.<br>
<input type="number" id="breadcrumbId" placeholder="Page ID" value="1" style="margin-top: 10px; padding: 8px; width: 200px;">
</div>
<button class="test-button" onclick="testEndpoint('breadcrumb/' + document.getElementById('breadcrumbId').value, 'GET')">Testen</button>
<div class="result" id="result-breadcrumb"></div>
</div>
<!-- Endpoint 6: Cache leeren -->
<div class="endpoint-group">
<div class="endpoint-header">
<div class="endpoint-title">Cache leeren</div>
<span class="endpoint-method method-post">POST</span>
</div>
<div class="endpoint-url">/cache/clear</div>
<div class="endpoint-description">
Löscht den kompletten Navigation-Cache.
</div>
<button class="test-button" onclick="testEndpoint('cache/clear', 'POST')">Testen</button>
<div class="result" id="result-cache-clear"></div>
</div>
<!-- Zusammenfassung -->
<div class="summary" id="summary">
<h2>Test-Zusammenfassung</h2>
<div class="summary-stats">
<div class="stat-card total">
<div class="stat-number" id="stat-total">0</div>
<div class="stat-label">Gesamt</div>
</div>
<div class="stat-card success">
<div class="stat-number" id="stat-success">0</div>
<div class="stat-label">Erfolgreich</div>
</div>
<div class="stat-card error">
<div class="stat-number" id="stat-error">0</div>
<div class="stat-label">Fehlgeschlagen</div>
</div>
</div>
</div>
</div>
<script>
let testResults = {};
function getBaseUrl() {
return document.getElementById('baseUrl').value.replace(/\/$/, '');
}
async function testEndpoint(endpoint, method = 'GET') {
const resultId = 'result-' + endpoint.replace(/\//g, '-').replace(/\d+/g, 'subtree');
const resultDiv = document.getElementById(resultId);
resultDiv.style.display = 'block';
resultDiv.className = 'result';
resultDiv.innerHTML = '<div class="result-header">Teste...<span class="loading"></span></div>';
const url = getBaseUrl() + '/' + endpoint;
try {
const startTime = performance.now();
const response = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
});
const endTime = performance.now();
const duration = Math.round(endTime - startTime);
const data = await response.json();
testResults[endpoint] = response.ok;
if (response.ok) {
resultDiv.className = 'result success';
let html = '<div class="result-header">✓ Erfolgreich</div>';
html += '<div class="result-meta">';
html += `<strong>HTTP Status:</strong> ${response.status}<br>`;
html += `<strong>Dauer:</strong> ${duration}ms<br>`;
if (data.meta) {
html += '<strong>Metadaten:</strong><br>';
for (const [key, value] of Object.entries(data.meta)) {
html += `&nbsp;&nbsp;${key}: ${value}<br>`;
}
}
html += '</div>';
if (data.data) {
html += '<div class="result-data">';
html += JSON.stringify(data.data, null, 2);
html += '</div>';
}
resultDiv.innerHTML = html;
} else {
resultDiv.className = 'result error';
let html = '<div class="result-header">✗ Fehler</div>';
html += '<div class="result-meta">';
html += `<strong>HTTP Status:</strong> ${response.status}<br>`;
html += `<strong>Dauer:</strong> ${duration}ms<br>`;
if (data.error) {
html += `<strong>Fehlermeldung:</strong> ${data.error}`;
}
html += '</div>';
resultDiv.innerHTML = html;
}
} catch (error) {
testResults[endpoint] = false;
resultDiv.className = 'result error';
resultDiv.innerHTML = `
<div class="result-header">✗ Fehler</div>
<div class="result-meta">
<strong>Fehlermeldung:</strong> ${error.message}<br>
<br>
Mögliche Ursachen:<br>
• Server läuft nicht<br>
• CORS-Probleme<br>
• Falsche Base-URL<br>
• Netzwerkfehler
</div>
`;
}
}
async function testAllEndpoints() {
testResults = {};
const endpoints = [
{ path: 'tree', method: 'GET' },
{ path: 'tree/active', method: 'GET' },
{ path: 'tree/1', method: 'GET' },
{ path: 'flat', method: 'GET' },
{ path: 'breadcrumb/1', method: 'GET' },
{ path: 'cache/clear', method: 'POST' }
];
for (const endpoint of endpoints) {
await testEndpoint(endpoint.path, endpoint.method);
await new Promise(resolve => setTimeout(resolve, 500)); // Kurze Pause zwischen Tests
}
updateSummary();
}
function updateSummary() {
const total = Object.keys(testResults).length;
const success = Object.values(testResults).filter(r => r === true).length;
const error = total - success;
document.getElementById('stat-total').textContent = total;
document.getElementById('stat-success').textContent = success;
document.getElementById('stat-error').textContent = error;
document.getElementById('summary').style.display = 'block';
}
</script>
</body>
</html>

View file

@ -0,0 +1,249 @@
<?php
/**
* Test-Script für die Navigation API
*
* Dieses Script testet alle verfügbaren Endpunkte der Navigation API
* und zeigt die Ergebnisse in einer strukturierten Form.
*
* Verwendung:
* 1. Passe die BASE_URL an deine Umgebung an
* 2. Führe aus: php test-api.php
*/
// Konfiguration
define('BASE_URL', 'http://localhost'); // Passe dies an deine Umgebung an
define('API_PREFIX', '/api/navigation');
// Terminal Farben
define('COLOR_GREEN', "\033[0;32m");
define('COLOR_RED', "\033[0;31m");
define('COLOR_YELLOW', "\033[0;33m");
define('COLOR_BLUE', "\033[0;34m");
define('COLOR_RESET', "\033[0m");
/**
* Führt einen API-Request aus
*/
function apiRequest($endpoint, $method = 'GET')
{
$url = BASE_URL . API_PREFIX . $endpoint;
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
$startTime = microtime(true);
$response = curl_exec($ch);
$endTime = microtime(true);
$duration = round(($endTime - $startTime) * 1000, 2);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($error) {
return [
'success' => false,
'error' => $error,
'duration' => $duration
];
}
return [
'success' => true,
'http_code' => $httpCode,
'data' => json_decode($response, true),
'duration' => $duration
];
}
/**
* Ausgabe-Helfer
*/
function printHeader($text)
{
echo "\n" . COLOR_BLUE . str_repeat('=', 80) . COLOR_RESET . "\n";
echo COLOR_BLUE . $text . COLOR_RESET . "\n";
echo COLOR_BLUE . str_repeat('=', 80) . COLOR_RESET . "\n\n";
}
function printSuccess($text)
{
echo COLOR_GREEN . "" . $text . COLOR_RESET . "\n";
}
function printError($text)
{
echo COLOR_RED . "" . $text . COLOR_RESET . "\n";
}
function printInfo($text)
{
echo COLOR_YELLOW . " " . $text . COLOR_RESET . "\n";
}
function printJson($data, $maxDepth = 3, $currentDepth = 0)
{
if ($currentDepth >= $maxDepth) {
echo "[... gekürzt nach Tiefe $maxDepth]\n";
return;
}
echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
}
/**
* Testet einen einzelnen Endpunkt
*/
function testEndpoint($name, $endpoint, $method = 'GET', $showFullData = false)
{
printInfo("Teste: $name");
echo " Endpunkt: $endpoint\n";
echo " Methode: $method\n";
$result = apiRequest($endpoint, $method);
if (!$result['success']) {
printError("Request fehlgeschlagen: " . $result['error']);
return false;
}
$httpCode = $result['http_code'];
$data = $result['data'];
$duration = $result['duration'];
echo " HTTP Status: $httpCode\n";
echo " Dauer: {$duration}ms\n";
if ($httpCode === 200) {
printSuccess("Erfolgreich");
if (isset($data['success']) && $data['success']) {
if (isset($data['meta'])) {
echo "\n Metadaten:\n";
foreach ($data['meta'] as $key => $value) {
echo " $key: $value\n";
}
}
if ($showFullData && isset($data['data'])) {
echo "\n Daten (gekürzt):\n";
$preview = $data['data'];
if (is_array($preview) && count($preview) > 2) {
$preview = array_slice($preview, 0, 2);
echo " " . json_encode($preview, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n";
echo " ... und " . (count($data['data']) - 2) . " weitere Einträge\n";
} else {
printJson($preview, 2);
}
}
}
} else {
printError("HTTP-Fehler $httpCode");
if (isset($data['error'])) {
echo " Fehlermeldung: " . $data['error'] . "\n";
}
}
echo "\n";
return $httpCode === 200;
}
// Hauptprogramm
printHeader("Navigation API Test Suite");
echo "Base URL: " . BASE_URL . "\n";
echo "API Prefix: " . API_PREFIX . "\n\n";
$results = [];
// Test 1: Kompletter Navigationsbaum
printHeader("Test 1: Kompletter Navigationsbaum");
$results['tree'] = testEndpoint(
"Kompletter Navigationsbaum",
"/tree",
"GET",
true
);
// Test 2: Nur aktive Navigationspunkte
printHeader("Test 2: Nur aktive Navigationspunkte");
$results['tree_active'] = testEndpoint(
"Aktive Navigationspunkte",
"/tree/active",
"GET",
true
);
// Test 3: Teilbaum (ersetze 1 mit einer existierenden Page-ID)
printHeader("Test 3: Teilbaum ab Root-ID 1");
$results['subtree'] = testEndpoint(
"Teilbaum ab Root-ID 1",
"/tree/1",
"GET",
true
);
// Test 4: Flache Liste
printHeader("Test 4: Flache Liste aller Navigationspunkte");
$results['flat'] = testEndpoint(
"Flache Liste",
"/flat",
"GET",
true
);
// Test 5: Breadcrumb (ersetze 1 mit einer existierenden Page-ID)
printHeader("Test 5: Breadcrumb für Page-ID 1");
$results['breadcrumb'] = testEndpoint(
"Breadcrumb für Page-ID 1",
"/breadcrumb/1",
"GET",
true
);
// Test 6: Cache leeren
printHeader("Test 6: Cache leeren");
$results['cache_clear'] = testEndpoint(
"Cache leeren",
"/cache/clear",
"POST",
false
);
// Zusammenfassung
printHeader("Test-Zusammenfassung");
$total = count($results);
$passed = count(array_filter($results));
$failed = $total - $passed;
echo "Gesamt: $total Tests\n";
printSuccess("Erfolgreich: $passed");
if ($failed > 0) {
printError("Fehlgeschlagen: $failed");
}
$percentage = round(($passed / $total) * 100, 1);
echo "\nErfolgsrate: $percentage%\n";
if ($percentage === 100.0) {
printSuccess("\nAlle Tests bestanden! 🎉");
} else {
printError("\nEinige Tests sind fehlgeschlagen. Bitte überprüfen Sie die Ausgabe oben.");
}
echo "\n";
// Weitere Hinweise
printHeader("Weitere Informationen");
echo "Vollständige API-Dokumentation: dev/frontend-navigation/navigation-api.md\n";
echo "README: dev/frontend-navigation/README.md\n\n";
echo "Hinweis: Wenn Tests fehlschlagen, überprüfen Sie:\n";
echo " 1. Ist die BASE_URL korrekt?\n";
echo " 2. Läuft der Server?\n";
echo " 3. Sind Page-Einträge in der Datenbank vorhanden?\n";
echo " 4. Sind die Routen korrekt registriert?\n\n";