CMS: Standorte an der Marke, Seitenbaum mit Vorschau, Referenzen-Modul
Nachtrag 11: Herkunfts-Sektion liest Ort, Entfernung und Rolle aus der Markenverwaltung (drei neue Felder an cms_brands, BrandOriginSeeder), Kartenpositionen im Code, „Marken“ in der Sidebar. Nachtrag 12: Inhalte-Editor zeigt die Seiten der Website mit ihren Sektionen in Reihenfolge (config/cms_pages.php, CmsPageTree), Felder in Klartext, fehlende Sektionen aus der Sprachdatei anlegbar, Vorschau der echten Seite im iframe mit Sprung zur Sektion; Anker an allen lebenden Sektionen nachgezogen. Nachtrag 13: Referenzen als eigenes Modul (CmsReference, Portal-Seite, /referenzen mit Fallback auf die Markengalerien); Immobilien-Projekte aus Sidebar und CMS-Übersicht genommen, Route bleibt. Dazu .gitignore für _b2in_interior_shop/ und docs/. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
7953a8c9a1
commit
90aa079cf7
50 changed files with 2580 additions and 324 deletions
|
|
@ -6,11 +6,30 @@ use App\Models\CmsBrand;
|
|||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* „Aus der Region gefertigt“: die vier Standorte der Kernmarken rund um Bielefeld
|
||||
* (Arbeitsplan 14.09.2026, Punkt 1.3). Inhalte aus dem CMS, Fallback Lang-Datei.
|
||||
* „Aus der Region gefertigt“: die Standorte der Kernmarken rund um Bielefeld
|
||||
* (Arbeitsplan 14.09.2026, Punkt 1.3, Nachtrag 11).
|
||||
*
|
||||
* Überschrift, Karte, Drehscheibe und Ablauf-Block kommen aus dem CMS (Fallback Lang-Datei).
|
||||
* Die Standorte kommen aus den veröffentlichten Marken: Ort, Entfernung und Rolle pflegt
|
||||
* Marcel in der Markenverwaltung. Die Positionen in der schematischen Karte gehören zur
|
||||
* Zeichnung und bleiben hier im Code.
|
||||
*/
|
||||
class OriginSection extends Component
|
||||
{
|
||||
/**
|
||||
* Position je Marke in der Karte (viewBox 640×400) und Seite der Beschriftung.
|
||||
*
|
||||
* Eine Marke ohne Eintrag erscheint in den Kacheln, aber nicht in der Karte.
|
||||
*
|
||||
* @var array<string, array{x: int, y: int, label: string}>
|
||||
*/
|
||||
public const MAP_POSITIONS = [
|
||||
'rotpunkt' => ['x' => 330, 'y' => 55, 'label' => 'left'],
|
||||
'februe' => ['x' => 480, 'y' => 90, 'label' => 'right'],
|
||||
'tischlerei' => ['x' => 295, 'y' => 285, 'label' => 'left'],
|
||||
'cabinet' => ['x' => 80, 'y' => 345, 'label' => 'right'],
|
||||
];
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $content = [];
|
||||
|
||||
|
|
@ -24,58 +43,39 @@ class OriginSection extends Component
|
|||
}
|
||||
|
||||
/**
|
||||
* Standorte mit ausgefüllten Pflichtfeldern, damit ein leerer CMS-Eintrag die Karte nicht zerreißt.
|
||||
* Veröffentlichte Marken mit gepflegtem Fertigungsort, in der Reihenfolge der Marken.
|
||||
*
|
||||
* Der Link zur Markenseite kommt nicht aus dem CMS, sondern aus den veröffentlichten Marken:
|
||||
* eine unveröffentlichte Marke bekommt keinen Link, ihr Standort bleibt aber sichtbar.
|
||||
* Eine unveröffentlichte Marke fällt heraus, eine Marke ohne Ort ebenfalls. Der Link zur
|
||||
* Markenseite hängt daran, ob die Marke eine Detailseite hat (Lang-Fallback: keine).
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
* @return list<array{slug: string, city: string, brand: string, role: string, distance: string, link: ?string, map: ?array{x: int, y: int, label: string}}>
|
||||
*/
|
||||
public function getLocationsProperty(): array
|
||||
{
|
||||
$locations = $this->content['locations'] ?? [];
|
||||
|
||||
if (! is_array($locations)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$brands = CmsBrand::forFrontend();
|
||||
|
||||
$out = [];
|
||||
|
||||
foreach ($locations as $location) {
|
||||
if (! is_array($location) || empty($location['brand']) || empty($location['city'])) {
|
||||
foreach (CmsBrand::forFrontend() as $slug => $brand) {
|
||||
$city = trim((string) ($brand['origin_city'] ?? ''));
|
||||
$name = trim((string) ($brand['name'] ?? ''));
|
||||
|
||||
if ($city === '' || $name === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$location['link'] = $this->brandLink($location, $brands);
|
||||
$out[] = $location;
|
||||
$out[] = [
|
||||
'slug' => (string) $slug,
|
||||
'city' => $city,
|
||||
'brand' => $name,
|
||||
'role' => trim((string) ($brand['origin_role'] ?? '')),
|
||||
'distance' => trim((string) ($brand['origin_distance'] ?? '')),
|
||||
'link' => ! empty($brand['has_detail_page']) ? '/marken/'.$slug : null,
|
||||
'map' => self::MAP_POSITIONS[$slug] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $location
|
||||
* @param array<string, array<string, mixed>> $brands
|
||||
*/
|
||||
private function brandLink(array $location, array $brands): ?string
|
||||
{
|
||||
$slug = $location['brand_slug'] ?? null;
|
||||
|
||||
if (is_string($slug) && isset($brands[$slug])) {
|
||||
return '/marken/'.$slug;
|
||||
}
|
||||
|
||||
foreach ($brands as $brandSlug => $brand) {
|
||||
if (($brand['name'] ?? null) === $location['brand']) {
|
||||
return '/marken/'.$brandSlug;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.web.components.sections.origin-section');
|
||||
|
|
|
|||
|
|
@ -3,14 +3,16 @@
|
|||
namespace App\Livewire\Web\Components\Sections;
|
||||
|
||||
use App\Models\CmsBrand;
|
||||
use App\Models\CmsReference;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Realisierte Projekte aus den Markengalerien, über alle Marken hinweg gesammelt.
|
||||
* Realisierte Projekte auf /referenzen.
|
||||
*
|
||||
* Keine zweite Pflegestelle: Marcel pflegt die Bilder weiterhin je Marke im Kernmarken-Modul.
|
||||
* Hier erscheinen ausschließlich Einträge der Rubrik „Realisiertes Projekt" – Visualisierungen
|
||||
* und Entwürfe bleiben bewusst draußen, damit „Referenzen" hält, was das Wort verspricht.
|
||||
* Seit Nachtrag 13 pflegt Marcel Referenzen als eigene Einträge im Portal (Titelbild, weitere
|
||||
* Bilder, beteiligte Marken). Solange dort nichts veröffentlicht ist, greift der Stand aus AP5:
|
||||
* die als „Realisiertes Projekt“ markierten Bilder aus den Markengalerien, über alle Marken
|
||||
* hinweg gesammelt. Visualisierungen und Entwürfe bleiben in beiden Fällen draußen.
|
||||
*/
|
||||
class ReferenceProjects extends Component
|
||||
{
|
||||
|
|
@ -30,13 +32,56 @@ class ReferenceProjects extends Component
|
|||
$this->content = is_array($section) ? $section : [];
|
||||
$this->bg = $bg;
|
||||
$this->limit = $limit;
|
||||
$this->projects = $this->collectProjects();
|
||||
|
||||
$projects = $this->fromReferences();
|
||||
|
||||
if ($projects === []) {
|
||||
$projects = $this->fromBrandGalleries();
|
||||
}
|
||||
|
||||
$this->projects = $this->limit > 0 ? array_slice($projects, 0, $this->limit) : $projects;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function collectProjects(): array
|
||||
private function fromReferences(): array
|
||||
{
|
||||
$references = CmsReference::query()->published()->ordered()->get();
|
||||
|
||||
if ($references->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$brands = CmsBrand::forFrontend();
|
||||
$projects = [];
|
||||
|
||||
foreach ($references as $reference) {
|
||||
$gallery = CmsReference::normalizeGallery($reference->gallery);
|
||||
$image = (string) ($reference->image ?: ($gallery[0]['image'] ?? ''));
|
||||
|
||||
if ($image === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$projects[] = [
|
||||
'key' => 'ref-'.$reference->id,
|
||||
'image' => $image,
|
||||
'caption' => (string) $reference->title,
|
||||
'meta' => implode(' · ', array_filter([$reference->location, $reference->year])),
|
||||
'description' => (string) $reference->description,
|
||||
'gallery' => array_values(array_filter($gallery, fn (array $item) => $item['image'] !== $image)),
|
||||
'brands' => $reference->resolveBrands($brands),
|
||||
];
|
||||
}
|
||||
|
||||
return $projects;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function fromBrandGalleries(): array
|
||||
{
|
||||
$projects = [];
|
||||
|
||||
|
|
@ -46,14 +91,19 @@ class ReferenceProjects extends Component
|
|||
'key' => $slug.'-'.$index,
|
||||
'image' => $item['image'],
|
||||
'caption' => $item['caption'],
|
||||
'brand' => (string) ($brand['name'] ?? ''),
|
||||
'brand_slug' => $slug,
|
||||
'has_detail_page' => (bool) ($brand['has_detail_page'] ?? false),
|
||||
'meta' => '',
|
||||
'description' => '',
|
||||
'gallery' => [],
|
||||
'brands' => [[
|
||||
'slug' => $slug,
|
||||
'name' => (string) ($brand['name'] ?? ''),
|
||||
'link' => ! empty($brand['has_detail_page']) ? '/marken/'.$slug : null,
|
||||
]],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $this->limit > 0 ? array_slice($projects, 0, $this->limit) : $projects;
|
||||
return $projects;
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@ class CmsBrand extends Model
|
|||
'gallery',
|
||||
'website_url',
|
||||
'showroom',
|
||||
'origin_city',
|
||||
'origin_distance',
|
||||
'origin_role',
|
||||
'is_published',
|
||||
'order',
|
||||
];
|
||||
|
|
@ -59,6 +62,9 @@ class CmsBrand extends Model
|
|||
'widget_label',
|
||||
'cta_title',
|
||||
'cta_text',
|
||||
'origin_city',
|
||||
'origin_distance',
|
||||
'origin_role',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
|
|
@ -139,6 +145,9 @@ class CmsBrand extends Model
|
|||
'gallery' => [],
|
||||
'website_url' => '',
|
||||
'showroom' => '',
|
||||
'origin_city' => '',
|
||||
'origin_distance' => '',
|
||||
'origin_role' => '',
|
||||
'has_detail_page' => false,
|
||||
], $brand, ['slug' => $slug, 'has_detail_page' => false]);
|
||||
|
||||
|
|
@ -311,6 +320,9 @@ class CmsBrand extends Model
|
|||
'gallery' => self::normalizeGallery($this->gallery),
|
||||
'website_url' => $this->website_url,
|
||||
'showroom' => $this->showroom,
|
||||
'origin_city' => (string) $this->origin_city,
|
||||
'origin_distance' => (string) $this->origin_distance,
|
||||
'origin_role' => (string) $this->origin_role,
|
||||
'has_detail_page' => true,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
115
app/Models/CmsReference.php
Normal file
115
app/Models/CmsReference.php
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Spatie\Translatable\HasTranslations;
|
||||
|
||||
/**
|
||||
* Referenzprojekt der Interior-Welt: ein realisierter Auftrag mit Titelbild, weiteren Bildern
|
||||
* und den beteiligten Kernmarken (Nachtrag 13). Erscheint auf /referenzen.
|
||||
*
|
||||
* Nicht zu verwechseln mit {@see CmsProject} – das sind die Immobilienprojekte.
|
||||
*/
|
||||
class CmsReference extends Model
|
||||
{
|
||||
use HasFactory, HasTranslations;
|
||||
|
||||
protected $table = 'cms_references';
|
||||
|
||||
protected $fillable = [
|
||||
'title',
|
||||
'location',
|
||||
'year',
|
||||
'description',
|
||||
'image',
|
||||
'gallery',
|
||||
'brand_slugs',
|
||||
'is_published',
|
||||
'order',
|
||||
];
|
||||
|
||||
/** @var array<string> */
|
||||
public array $translatable = ['title', 'description'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'gallery' => 'array',
|
||||
'brand_slugs' => 'array',
|
||||
'is_published' => 'boolean',
|
||||
'order' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function scopePublished(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_published', true);
|
||||
}
|
||||
|
||||
public function scopeOrdered(Builder $query): Builder
|
||||
{
|
||||
return $query->orderBy('order')->orderBy('id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Galerie in eine feste Form bringen: nur Einträge mit Bild, Bildunterschrift getrimmt.
|
||||
*
|
||||
* @param mixed $gallery
|
||||
* @return list<array{image: string, caption: string}>
|
||||
*/
|
||||
public static function normalizeGallery($gallery): array
|
||||
{
|
||||
if (! is_array($gallery)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
|
||||
foreach ($gallery as $item) {
|
||||
$image = trim((string) (is_array($item) ? ($item['image'] ?? '') : $item));
|
||||
|
||||
if ($image === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$out[] = [
|
||||
'image' => $image,
|
||||
'caption' => is_array($item) ? trim((string) ($item['caption'] ?? '')) : '',
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Beteiligte Marken als Liste mit Name und Link, aufgelöst gegen die veröffentlichten Marken.
|
||||
*
|
||||
* Eine unveröffentlichte oder gelöschte Marke fällt heraus.
|
||||
*
|
||||
* @param array<string, array<string, mixed>> $brands Ergebnis von CmsBrand::forFrontend()
|
||||
* @return list<array{slug: string, name: string, link: ?string}>
|
||||
*/
|
||||
public function resolveBrands(array $brands): array
|
||||
{
|
||||
$out = [];
|
||||
|
||||
foreach ($this->brand_slugs ?? [] as $slug) {
|
||||
$slug = (string) $slug;
|
||||
|
||||
if (! isset($brands[$slug])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$out[] = [
|
||||
'slug' => $slug,
|
||||
'name' => (string) ($brands[$slug]['name'] ?? $slug),
|
||||
'link' => ! empty($brands[$slug]['has_detail_page']) ? '/marken/'.$slug : null,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
187
app/Services/CmsPageTree.php
Normal file
187
app/Services/CmsPageTree.php
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use FluxCms\Core\Models\CmsContent;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Seitenbaum für den Inhalte-Editor (config/cms_pages.php, Nachtrag 12).
|
||||
*
|
||||
* Verbindet die Registry mit dem Datenbestand: je Sektion, ob sie in flux_cms_contents liegt
|
||||
* (dann mit `content_id`), und was sonst noch in der Datenbank steht, aber in keiner Seite
|
||||
* eingetragen ist („Nicht mehr verwendet“).
|
||||
*/
|
||||
class CmsPageTree
|
||||
{
|
||||
/** Schlüssel des Sammelpunkts für nicht eingetragene Sektionen. */
|
||||
public const UNLISTED = 'unlisted';
|
||||
|
||||
/**
|
||||
* Alle Seiten mit ihren Sektionen in Website-Reihenfolge.
|
||||
*
|
||||
* @return array<string, array{title: string, path: ?string, sections: list<array<string, mixed>>}>
|
||||
*/
|
||||
public function pages(): array
|
||||
{
|
||||
$rows = CmsContent::query()
|
||||
->get(['id', 'group', 'key'])
|
||||
->keyBy(fn (CmsContent $row) => $row->group.'/'.$row->key);
|
||||
|
||||
$pages = [];
|
||||
|
||||
foreach (config('cms_pages.pages', []) as $pageKey => $page) {
|
||||
$sections = [];
|
||||
|
||||
foreach ($page['sections'] ?? [] as $section) {
|
||||
$id = $section['group'].'/'.$section['key'];
|
||||
|
||||
$sections[] = array_merge($section, [
|
||||
'page' => $pageKey,
|
||||
'path' => $section['path'] ?? $page['path'] ?? null,
|
||||
'anchor' => $section['anchor'] ?? null,
|
||||
'content_id' => $rows[$id]->id ?? null,
|
||||
'theme_key' => $this->themeKey($section['group'], $section['key']),
|
||||
]);
|
||||
}
|
||||
|
||||
$pages[$pageKey] = [
|
||||
'title' => $page['title'],
|
||||
'path' => $page['path'] ?? null,
|
||||
'sections' => $sections,
|
||||
];
|
||||
}
|
||||
|
||||
$unlisted = $this->unlistedSections($rows->keys()->all(), $rows);
|
||||
|
||||
if ($unlisted !== []) {
|
||||
$pages[self::UNLISTED] = [
|
||||
'title' => config('cms_pages.unlisted.title', 'Nicht mehr verwendet'),
|
||||
'path' => null,
|
||||
'sections' => $unlisted,
|
||||
];
|
||||
}
|
||||
|
||||
return $pages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Sektion aus der Registry samt Datenbankbezug, oder null.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function section(string $group, string $key): ?array
|
||||
{
|
||||
foreach ($this->pages() as $page) {
|
||||
foreach ($page['sections'] as $section) {
|
||||
if ($section['group'] === $group && $section['key'] === $key) {
|
||||
return $section;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Klartext-Beschriftung eines Feldes: Sektions-Override, dann Wörterbuch, sonst lesbar gemacht.
|
||||
*/
|
||||
public function fieldLabel(string $field, ?string $group = null, ?string $key = null): string
|
||||
{
|
||||
if ($group !== null && $key !== null) {
|
||||
foreach (config('cms_pages.pages', []) as $page) {
|
||||
foreach ($page['sections'] ?? [] as $section) {
|
||||
if ($section['group'] === $group && $section['key'] === $key && isset($section['fields'][$field])) {
|
||||
return $section['fields'][$field];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$labels = config('cms_pages.field_labels', []);
|
||||
|
||||
if (isset($labels[$field])) {
|
||||
return $labels[$field];
|
||||
}
|
||||
|
||||
return Str::ucfirst(str_replace('_', ' ', $field));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adresse der Vorschau für eine Sektion: Frontend-Domain, Pfad, Cache-Brecher und Sprungmarke.
|
||||
*/
|
||||
public function previewUrl(?array $section, int $version = 0): ?string
|
||||
{
|
||||
$path = $section['path'] ?? null;
|
||||
|
||||
if ($path === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = rtrim(config('domains.protocol', 'https://').config('domains.domain_b2in'), '/')
|
||||
.'/'.ltrim($path, '/')
|
||||
.'?cms-preview='.$version;
|
||||
|
||||
if (! empty($section['anchor'])) {
|
||||
$url .= '#'.$section['anchor'];
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme-Section-Schlüssel (Lang-Datei) zu Gruppe/Key, für „Aus Vorlage anlegen“.
|
||||
*/
|
||||
public function themeKey(string $group, string $key): ?string
|
||||
{
|
||||
foreach (config('cms_section_map.sections', []) as $themeKey => $target) {
|
||||
if ($target === [$group, $key]) {
|
||||
return $themeKey;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $ids
|
||||
* @param \Illuminate\Support\Collection<string, CmsContent> $rows
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function unlistedSections(array $ids, $rows): array
|
||||
{
|
||||
$listed = [];
|
||||
|
||||
foreach (config('cms_pages.pages', []) as $page) {
|
||||
foreach ($page['sections'] ?? [] as $section) {
|
||||
$listed[$section['group'].'/'.$section['key']] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$out = [];
|
||||
|
||||
foreach ($ids as $id) {
|
||||
if (isset($listed[$id])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$row = $rows[$id];
|
||||
|
||||
$out[] = [
|
||||
'group' => $row->group,
|
||||
'key' => $row->key,
|
||||
'page' => self::UNLISTED,
|
||||
'path' => null,
|
||||
'anchor' => null,
|
||||
'title' => $row->group.' / '.$row->key,
|
||||
'description' => null,
|
||||
'content_id' => $row->id,
|
||||
'theme_key' => null,
|
||||
];
|
||||
}
|
||||
|
||||
usort($out, fn (array $a, array $b) => [$a['group'], $a['key']] <=> [$b['group'], $b['key']]);
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue