Seiten-Sitemap mit Vorschaubild-Verwaltung im CRM ergänzen.

Der Nested-Set-Baum zeigt box_image_url, box_body und Reiseprogramm-Bilder
inklusive Bearbeitung per Modal und Mediathek, damit fehlende Kachelbilder
schneller gefunden und korrigiert werden können.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin Adametz 2026-06-18 10:46:22 +00:00
parent 6df9c401af
commit c39d86f80b
6 changed files with 1305 additions and 136 deletions

View file

@ -2,9 +2,11 @@
namespace App\Http\Controllers;
use App\Models\Page;
use App\Services\NavigationTreeService;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use IqContent\LaravelFilemanager\Lfm;
class NavigationTreeController extends Controller
{
@ -22,7 +24,9 @@ class NavigationTreeController extends Controller
*/
public function index()
{
return view('navigation.index');
return view('navigation.index', [
'lfm_helper' => app(Lfm::class),
]);
}
/**
@ -34,7 +38,25 @@ class NavigationTreeController extends Controller
public function getData(Request $request): JsonResponse
{
try {
$includeHidden = $request->get('include_hidden', true);
$mode = $request->get('mode', 'sitemap');
$includeHidden = $request->get('include_hidden', false);
$onlyActive = !$includeHidden;
if ($mode === 'sitemap') {
$tree = $this->navigationService->getSitemapTree($onlyActive);
return response()->json([
'success' => true,
'data' => $tree,
'meta' => [
'total_nodes' => $this->navigationService->countNodes($tree),
'missing_preview_images' => $this->navigationService->countMissingPreviewImages($tree),
'include_hidden' => $includeHidden,
'structure' => 'sitemap',
],
]);
}
$tree = $this->navigationService->getFrontendNavigationTree($includeHidden);
return response()->json([
@ -43,8 +65,8 @@ class NavigationTreeController extends Controller
'meta' => [
'total_nodes' => $this->navigationService->countNodes($tree),
'include_hidden' => $includeHidden,
'structure' => 'frontend'
]
'structure' => 'header',
],
]);
} catch (\Exception $e) {
return response()->json([
@ -98,10 +120,17 @@ class NavigationTreeController extends Controller
public function export(Request $request)
{
try {
$includeHidden = $request->get('include_hidden', true);
$tree = $this->navigationService->getFrontendNavigationTree($includeHidden);
$mode = $request->get('mode', 'sitemap');
$includeHidden = $request->get('include_hidden', false);
$onlyActive = !$includeHidden;
$filename = 'navigation-tree-frontend-' . date('Y-m-d-His') . '.json';
if ($mode === 'sitemap') {
$tree = $this->navigationService->getSitemapTree($onlyActive);
$filename = 'sitemap-tree-' . date('Y-m-d-His') . '.json';
} else {
$tree = $this->navigationService->getFrontendNavigationTree($includeHidden);
$filename = 'navigation-tree-frontend-' . date('Y-m-d-His') . '.json';
}
$json = json_encode($tree, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return response($json)
@ -117,12 +146,21 @@ class NavigationTreeController extends Controller
*
* @return \Illuminate\Http\RedirectResponse
*/
public function clearCache()
public function clearCache(Request $request)
{
try {
$this->navigationService->clearCache();
if ($request->expectsJson() || $request->ajax()) {
return response()->json(['success' => true]);
}
return back()->with('success', 'Navigation-Cache erfolgreich gelöscht');
} catch (\Exception $e) {
if ($request->expectsJson() || $request->ajax()) {
return response()->json(['success' => false, 'error' => $e->getMessage()], 500);
}
return back()->with('error', 'Cache-Löschung fehlgeschlagen: ' . $e->getMessage());
}
}
@ -149,6 +187,9 @@ class NavigationTreeController extends Controller
'travel_programs' => count(array_filter($flatList, fn($n) => $n['is_travel_program'])),
'fewo_lodgings' => count(array_filter($flatList, fn($n) => $n['is_fewo_lodging'])),
'country_pages' => count(array_filter($flatList, fn($n) => $n['is_country_page'])),
'missing_preview_images' => $this->navigationService->countMissingPreviewImages(
$this->navigationService->getSitemapTree(false)
),
];
return response()->json([
@ -162,4 +203,76 @@ class NavigationTreeController extends Controller
], 500);
}
}
/**
* Setzt oder löscht box_image_url einer Seite.
*/
public function updateBoxImage(Request $request, int $id): JsonResponse
{
try {
$page = Page::findOrFail($id);
$action = $request->input('action');
if ($action === 'clear') {
if (empty($page->box_image_url)) {
return response()->json([
'success' => false,
'error' => 'Für diese Seite ist kein verknüpftes Seitenbild (box_image_url) hinterlegt.',
], 422);
}
if (!$this->navigationService->pageCanClearBoxImage($page)) {
return response()->json([
'success' => false,
'error' => 'Das Seitenbild kann hier nicht entfernt werden, weil kein Reiseprogramm-Bild als Ersatz vorhanden ist.',
], 422);
}
$page->box_image_url = null;
$page->save();
} elseif ($action === 'clear_box_body') {
if (!$this->navigationService->pageHasBoxBodyPreview($page)) {
return response()->json([
'success' => false,
'error' => 'Für diese Seite ist kein Legacy-Bild (box_body) hinterlegt.',
], 422);
}
$page->box_body = null;
$page->save();
} elseif ($action === 'set') {
$url = trim((string) $request->input('box_image_url', ''));
if ($url === '') {
return response()->json([
'success' => false,
'error' => 'Bitte wählen Sie zuerst ein Bild aus der Mediathek.',
], 422);
}
$page->box_image_url = $url;
$page->save();
} else {
return response()->json([
'success' => false,
'error' => 'Ungültige Aktion.',
], 422);
}
$this->navigationService->clearCache();
return response()->json([
'success' => true,
'message' => match ($action) {
'clear' => 'Das verknüpfte Seitenbild wurde entfernt.',
'clear_box_body' => 'Das Legacy-Bild (box_body) wurde entfernt.',
default => 'Das neue Seitenbild wurde gespeichert.',
},
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => $e->getMessage(),
], 500);
}
}
}

View file

@ -5,6 +5,7 @@ namespace App\Services;
use App\Models\Page;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class NavigationTreeService
{
@ -20,6 +21,14 @@ class NavigationTreeService
*/
protected $cacheEnabled = true;
/** @var array<int, object> */
protected $travelProgramImageMap = [];
/** @var array<int, object> */
protected $fewoImageMap = [];
protected $previewImagesPreloaded = false;
/**
* Aktiviert oder deaktiviert Caching
*
@ -55,6 +64,10 @@ class NavigationTreeService
Cache::forget('navigation_tree_active');
Cache::forget('navigation_flat_full');
Cache::forget('navigation_flat_active');
Cache::forget('navigation_tree_sitemap_full');
Cache::forget('navigation_tree_sitemap_active');
Cache::forget('navigation_tree_frontend_with_hidden');
Cache::forget('navigation_tree_frontend_visible');
// Lösche auch alle Subtree-Caches
$keys = Cache::get('navigation_subtree_keys', []);
@ -102,6 +115,421 @@ class NavigationTreeService
return $this->buildFrontendNavigationTree($includeHidden);
}
/**
* Vollständiger Seiten-Sitemap-Baum (Nested Set), von Root bis Reiseprogramm.
*
* @param bool $onlyActive
* @return array
*/
public function getSitemapTree(bool $onlyActive = false): array
{
$cacheKey = $onlyActive ? 'navigation_tree_sitemap_active' : 'navigation_tree_sitemap_full';
if ($this->cacheEnabled) {
return Cache::remember($cacheKey, $this->cacheTime, function () use ($onlyActive) {
return $this->buildSitemapTree($onlyActive);
});
}
return $this->buildSitemapTree($onlyActive);
}
/**
* @param bool $onlyActive
* @return array
*/
protected function buildSitemapTree(bool $onlyActive = false): array
{
$this->preloadPreviewImageMaps();
$roots = $this->getNestedSetRootPages($onlyActive);
$children = [];
foreach ($roots as $rootPage) {
$children[] = $this->buildSitemapNode($rootPage, $onlyActive);
}
return [
[
'id' => 'homepage',
'title' => 'Startseite',
'slug' => null,
'url' => '/',
'frontend_url' => $this->buildFrontendUrl('/'),
'status' => 1,
'show_in_navi' => 1,
'node_type' => 'homepage',
'node_type_label' => 'Startseite',
'is_virtual' => true,
'has_preview_image' => null,
'preview_image_source' => null,
'children' => $children,
'has_children' => count($children) > 0,
],
];
}
/**
* @param bool $onlyActive
* @return \Illuminate\Database\Eloquent\Collection
*/
protected function getNestedSetRootPages(bool $onlyActive = false)
{
$query = Page::where('lvl', 0)
->orderBy('lft')
->orderBy('order')
->orderBy('title');
if ($onlyActive) {
$query->where('status', 1);
}
return $query->get();
}
/**
* @param Page $page
* @param bool $onlyActive
* @return array
*/
protected function buildSitemapNode(Page $page, bool $onlyActive = false): array
{
$node = $this->buildNodeData($page, true);
$node['node_type'] = $this->resolveSitemapNodeType($page);
$node['node_type_label'] = $this->resolveSitemapNodeTypeLabel($node['node_type']);
$node['is_virtual'] = false;
if ($this->nodeNeedsPreviewImage($node['node_type'])) {
if (empty($node['preview_image_url'])) {
$node['preview_image_url'] = $this->getNoPreviewImageUrl();
}
} else {
$node['has_preview_image'] = null;
}
$children = $this->getNestedSetChildren($page, $onlyActive);
$childNodes = [];
foreach ($children as $child) {
$childNodes[] = $this->buildSitemapNode($child, $onlyActive);
}
$node['children'] = $childNodes;
$node['has_children'] = count($childNodes) > 0;
return $node;
}
/**
* Direkte Kinder per Nested-Set (wie im Symfony-Frontend).
*
* @param Page $parent
* @param bool $onlyActive
* @return \Illuminate\Database\Eloquent\Collection
*/
protected function getNestedSetChildren(Page $parent, bool $onlyActive = false)
{
if ($parent->lft === null || $parent->rgt === null || $parent->lvl === null) {
return Page::where('parent_id', $parent->id)
->orderBy('order')
->orderBy('title')
->when($onlyActive, function ($query) {
$query->where('status', 1);
})
->get();
}
$query = Page::where('lvl', $parent->lvl + 1)
->where('lft', '>', $parent->lft)
->where('rgt', '<', $parent->rgt)
->when($parent->tree_root !== null, function ($query) use ($parent) {
$query->where('tree_root', $parent->tree_root);
})
->orderBy('lft')
->orderBy('order')
->orderBy('title');
if ($onlyActive) {
$query->where('status', 1);
}
return $query->get();
}
protected function resolveSitemapNodeType(Page $page): string
{
if (!empty($page->travel_program)) {
return 'travel_program';
}
if (!empty($page->fewo_lodging)) {
return 'fewo_lodging';
}
if (!empty($page->travel_guide_content_id)) {
return 'travel_guide';
}
if (!empty($page->country_id) && $page->template === 'overview' && (int) $page->lvl === 0) {
return 'country_root';
}
if ($page->template === 'overview') {
return 'category';
}
if (in_array($page->template, ['travelProgram', 'travelProgramOverview', 'nilecruise'], true)) {
return 'program_overview';
}
return 'content';
}
protected function resolveSitemapNodeTypeLabel(string $nodeType): string
{
$labels = [
'homepage' => 'Startseite',
'country_root' => 'Land / Reisekategorie',
'category' => 'Unterkategorie',
'program_overview' => 'Programm-Übersicht',
'travel_program' => 'Einzelreise',
'fewo_lodging' => 'Ferienwohnung',
'travel_guide' => 'Reiseführer',
'content' => 'Inhaltsseite',
];
return $labels[$nodeType] ?? 'Seite';
}
protected function nodeNeedsPreviewImage(string $nodeType): bool
{
return in_array($nodeType, [
'country_root',
'category',
'program_overview',
'travel_program',
'fewo_lodging',
], true);
}
/**
* @param Page $page
* @return array{has_preview_image: bool, preview_image_source: string|null, preview_image_url: string|null}
*/
protected function resolvePreviewImage(Page $page): array
{
$this->preloadPreviewImageMaps();
$boxBodyThumb = $this->getBoxBodyThumbUrl($page->box_body);
if ($boxBodyThumb !== null) {
return $this->previewImageResult(true, 'box_body', $boxBodyThumb);
}
if (!empty($page->box_image_url)) {
return $this->previewImageResult(true, 'box_image_url', $page->box_image_url);
}
if (!empty($page->travel_program)) {
$image = $this->travelProgramImageMap[$page->travel_program] ?? null;
if ($image !== null) {
$source = (int) $image->type === 2
? 'travel_program_image'
: 'travel_program_image_fallback';
return [
'has_preview_image' => true,
'preview_image_source' => $source,
'preview_image_url' => $this->buildTravelProgramImageUrl($image, false),
];
}
}
if (!empty($page->fewo_lodging)) {
$image = $this->fewoImageMap[$page->fewo_lodging] ?? null;
if ($image !== null && !empty($image->full_file_name)) {
return $this->previewImageResult(
true,
'fewo_lodging_image',
'_uploads/images/' . $image->full_file_name
);
}
}
return $this->previewImageResult(false, null, null);
}
/**
* @param bool $hasPreview
* @param string|null $source
* @param string|null $path
* @return array{has_preview_image: bool, preview_image_source: string|null, preview_image_url: string|null}
*/
protected function previewImageResult(bool $hasPreview, ?string $source, ?string $path): array
{
return [
'has_preview_image' => $hasPreview,
'preview_image_source' => $source,
'preview_image_url' => $hasPreview ? $this->buildFrontendAssetUrl($path) : null,
];
}
protected function resolveTravelProgramPreviewUrl(Page $page, bool $useThumb = true): ?string
{
if (empty($page->travel_program)) {
return null;
}
$this->preloadPreviewImageMaps();
$image = $this->travelProgramImageMap[$page->travel_program] ?? null;
if ($image === null) {
return null;
}
return $this->buildTravelProgramImageUrl($image, $useThumb);
}
public function pageCanClearBoxImage(Page $page): bool
{
if (empty($page->box_image_url) || empty($page->travel_program)) {
return false;
}
return $this->resolveTravelProgramPreviewUrl($page, false) !== null;
}
public function pageHasBoxBodyPreview(Page $page): bool
{
return $this->getBoxBodyThumbUrl($page->box_body) !== null;
}
public function pageCanClearBoxBody(Page $page): bool
{
return $this->pageHasBoxBodyPreview($page);
}
/**
* Reiseprogramm-Bilder liegen auf v2 (nicht sterntours.de).
*
* @param object $image
* @param bool $useThumb
* @return string
*/
protected function buildTravelProgramImageUrl(object $image, bool $useThumb = true): string
{
$fileName = $image->file_name;
if ($useThumb) {
$fileName .= '_thumb';
}
$path = 'uploads/travel_program/' . $fileName . $image->extension;
return rtrim((string) config('app.url_v2'), '/') . '/' . ltrim($path, '/');
}
protected function preloadPreviewImageMaps(): void
{
if ($this->previewImagesPreloaded) {
return;
}
$travelImages = DB::connection('mysql_stern')
->table('travel_program_image')
->orderBy('id')
->get();
foreach ($travelImages->groupBy('program_id') as $programId => $images) {
$preferred = $images->first(function ($image) {
return (int) $image->type === 2;
});
$this->travelProgramImageMap[$programId] = $preferred ?? $images->first();
}
$fewoImages = DB::connection('mysql_stern')
->table('fewo_lodging_image')
->orderBy('pos')
->orderBy('id')
->get();
foreach ($fewoImages->groupBy('lodging_id') as $lodgingId => $images) {
$this->fewoImageMap[$lodgingId] = $images->first();
}
$this->previewImagesPreloaded = true;
}
protected function getBoxBodyThumbUrl(?string $boxBody): ?string
{
if (empty($boxBody)) {
return null;
}
$decoded = json_decode($boxBody, true);
if (!is_array($decoded) || empty($decoded['thumb_url'])) {
return null;
}
return $decoded['thumb_url'];
}
protected function getNoPreviewImageUrl(): string
{
return $this->buildFrontendAssetUrl('bundles/app/images/no-picture.png');
}
/**
* @param string|null $path
* @return string|null
*/
protected function buildFrontendAssetUrl(?string $path): ?string
{
if ($path === null || $path === '') {
return null;
}
if (preg_match('/^https?:\/\//i', $path)) {
return $path;
}
$base = rtrim((string) config('app.url_stern'), '/');
return $base . (str_starts_with($path, '/') ? $path : '/' . $path);
}
protected function hasBoxBodyPreviewImage(?string $boxBody): bool
{
return $this->getBoxBodyThumbUrl($boxBody) !== null;
}
/**
* Zählt Knoten ohne Vorschaubild im Sitemap-Baum.
*
* @param array $tree
* @return int
*/
public function countMissingPreviewImages(array $tree): int
{
$count = 0;
foreach ($tree as $node) {
if (!empty($node['is_virtual'])) {
if (!empty($node['children'])) {
$count += $this->countMissingPreviewImages($node['children']);
}
continue;
}
if (isset($node['has_preview_image']) && $node['has_preview_image'] === false) {
$count++;
}
if (!empty($node['children'])) {
$count += $this->countMissingPreviewImages($node['children']);
}
}
return $count;
}
/**
* Baut den Frontend-Navigationsbaum auf
*
@ -110,6 +538,7 @@ class NavigationTreeService
*/
protected function buildFrontendNavigationTree(bool $includeHidden = false): array
{
$this->preloadPreviewImageMaps();
$tree = [];
// 1. Länderseiten (Hauptnavigation)
@ -492,6 +921,8 @@ class NavigationTreeService
*/
protected function buildNodeData(Page $page, bool $includeRelations = true): array
{
$path = $page->real_url_path ?: $this->buildUrlPath($page);
$data = [
'id' => $page->id,
'title' => $page->title,
@ -499,7 +930,8 @@ class NavigationTreeService
'before_title' => $page->before_title,
'slug' => $page->slug,
'real_url_path' => $page->real_url_path,
'url' => $page->real_url_path ?: $this->buildUrlPath($page),
'url' => $path,
'frontend_url' => $this->buildFrontendUrl($path),
'status' => $page->status,
'show_in_navi' => $page->show_in_navi,
'order' => $page->order,
@ -521,6 +953,9 @@ class NavigationTreeService
// Box-Informationen (für Karten/Boxen in der Navigation)
'box_body' => $page->box_body,
'box_image_url' => $page->box_image_url,
'box_image_frontend_url' => !empty($page->box_image_url)
? $this->buildFrontendAssetUrl($page->box_image_url)
: null,
'box_star' => $page->box_star,
'box_discount' => $page->box_discount,
@ -583,9 +1018,37 @@ class NavigationTreeService
}
}
$preview = $this->resolvePreviewImage($page);
$data['has_preview_image'] = $preview['has_preview_image'];
$data['preview_image_source'] = $preview['preview_image_source'];
$data['preview_image_url'] = $preview['preview_image_url'];
$data['travel_program_preview_url'] = $this->resolveTravelProgramPreviewUrl($page, true);
$data['travel_program_preview_url_full'] = $this->resolveTravelProgramPreviewUrl($page, false);
$data['can_clear_box_image'] = $this->pageCanClearBoxImage($page);
$data['has_box_body_preview'] = $this->pageHasBoxBodyPreview($page);
$data['box_body_preview_url'] = $this->getBoxBodyThumbUrl($page->box_body);
$data['can_clear_box_body'] = $this->pageCanClearBoxBody($page);
return $data;
}
/**
* Baut die vollständige Frontend-URL für eine Seite.
*
* @param string|null $path
* @return string|null
*/
protected function buildFrontendUrl(?string $path): ?string
{
if ($path === null || $path === '') {
return null;
}
$base = rtrim((string) config('app.url_stern'), '/');
return $base . ($path === '/' ? '/' : (str_starts_with($path, '/') ? $path : '/' . $path));
}
/**
* Baut den URL-Pfad für eine Page rekursiv auf (Fallback wenn real_url_path nicht gesetzt)
*

View file

@ -59,7 +59,7 @@ return [
'url_v2' => env('APP_URL_V2', 'https://v2.stern-tours.de'),
'url_stern' => env('APP_URL_STERN', 'https://www.sterntours.de'),
'url_stern' => env('APP_URL_STERN', 'https://sterntours.de'),
'domain_tld' => env('APP_DOMAIN_TLD', 'test'),

View file

@ -434,10 +434,10 @@
</li>
@endif
@if (Auth::user()->isPermission('crm-nav-api'))
<li class="sidenav-item{{ Request::is(['navigation-api']) ? ' active' : '' }}">
<li class="sidenav-item{{ Request::is(['navigation-api*']) ? ' active' : '' }}">
<a href="{{ route('navigation_api') }}" class="sidenav-link"><i
class="sidenav-icon ion ion-md-map"></i>
<div>Navigation API</div>
<div>Seiten-Sitemap</div>
</a>
</li>
@endif

File diff suppressed because it is too large Load diff

View file

@ -429,6 +429,8 @@ Route::group(['middleware' => ['admin', '2fa']], function () {
Route::get('/navigation-api/export', 'NavigationTreeController@export')->name('navigation_api_export');
Route::post('/navigation-api/clear-cache', 'NavigationTreeController@clearCache')->name('navigation_api_clear_cache');
Route::get('/navigation-api/stats', 'NavigationTreeController@stats')->name('navigation_api_stats');
Route::post('/navigation-api/page/{id}/box-image', 'NavigationTreeController@updateBoxImage')->name('navigation_api_page_box_image');
Route::get('/navigation-api/iq-assets/modal', 'IQ\ContentAssetController@modal')->name('navigation_api_iq_assets_modal');
});
});