b2in/resources/views/livewire/admin/cms/references-index.blade.php
Kevin Adametz 90aa079cf7 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>
2026-09-15 14:58:45 +00:00

367 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Models\CmsBrand;
use App\Models\CmsReference;
use Flux\Flux;
use FluxCms\Core\Models\CmsMedia;
use function Livewire\Volt\{computed, layout, on, state, title};
layout('components.layouts.app');
title('Referenzen verwalten');
state([
'search' => '',
'showForm' => false,
'editingId' => null,
'editLocale' => 'de',
'referenceTitle' => '',
'description' => '',
'location' => '',
'year' => '',
'image' => '',
'gallery' => [],
'brandSlugs' => [],
'is_published' => true,
'order' => 0,
]);
on(['media-selected' => function ($mediaId, $url, $field) {
$media = $mediaId ? CmsMedia::find($mediaId) : null;
if (! $media) {
return;
}
if ($field === 'reference_image') {
$this->image = $media->filename;
}
if (str_starts_with($field, 'reference_gallery:')) {
$index = (int) substr($field, strlen('reference_gallery:'));
if (isset($this->gallery[$index])) {
$this->gallery[$index]['image'] = $media->filename;
}
}
}]);
$references = computed(
fn () => CmsReference::query()
->when($this->search, fn ($q, $s) => $q->where('location', 'like', "%{$s}%")
->orWhereRaw("JSON_UNQUOTE(JSON_EXTRACT(title, '$.de')) LIKE ?", ["%{$s}%"]))
->ordered()
->get(),
);
$brands = computed(fn () => CmsBrand::query()->ordered()->get());
$openCreate = function () {
$this->editingId = null;
$this->reset(['referenceTitle', 'description', 'location', 'year', 'image']);
$this->gallery = [];
$this->brandSlugs = [];
$this->is_published = true;
$this->order = (CmsReference::max('order') ?? 0) + 1;
$this->showForm = true;
};
$loadLocaleFields = function (CmsReference $reference, string $locale) {
$this->referenceTitle = $reference->getTranslation('title', $locale) ?? '';
$this->description = $reference->getTranslation('description', $locale) ?? '';
};
$openEdit = function (int $id) {
$reference = CmsReference::find($id);
if (! $reference) {
return;
}
$this->editingId = $id;
$this->location = $reference->location ?? '';
$this->year = $reference->year ?? '';
$this->image = $reference->image ?? '';
$this->gallery = CmsReference::normalizeGallery($reference->gallery);
$this->brandSlugs = array_values(array_map('strval', $reference->brand_slugs ?? []));
$this->is_published = $reference->is_published;
$this->order = $reference->order ?? 0;
$this->loadLocaleFields($reference, $this->editLocale);
$this->showForm = true;
};
$switchLocale = function (string $locale) {
$this->editLocale = $locale;
if ($this->editingId) {
$reference = CmsReference::find($this->editingId);
if ($reference) {
$this->loadLocaleFields($reference, $locale);
}
}
};
$addGalleryImage = fn () => $this->gallery[] = ['image' => '', 'caption' => ''];
$removeGalleryImage = function (int $index) {
unset($this->gallery[$index]);
$this->gallery = array_values($this->gallery);
};
$moveGalleryImage = function (int $index, int $direction) {
$target = $index + $direction;
if (! isset($this->gallery[$index], $this->gallery[$target])) {
return;
}
[$this->gallery[$index], $this->gallery[$target]] = [$this->gallery[$target], $this->gallery[$index]];
};
$save = function () {
$validated = validator([
'referenceTitle' => $this->referenceTitle,
'description' => $this->description,
'location' => $this->location,
'year' => $this->year,
'image' => $this->image,
'brandSlugs' => $this->brandSlugs,
'is_published' => $this->is_published,
'order' => $this->order,
], [
'referenceTitle' => 'required|string|max:255',
'description' => 'nullable|string|max:4000',
'location' => 'nullable|string|max:255',
'year' => 'nullable|string|max:20',
'image' => 'nullable|string|max:500',
'brandSlugs' => 'array',
'brandSlugs.*' => 'string|exists:cms_brands,slug',
'is_published' => 'boolean',
'order' => 'integer|min:0',
], [
'referenceTitle.required' => 'Bitte einen Titel angeben.',
'brandSlugs.*.exists' => 'Eine der gewählten Marken gibt es nicht mehr.',
])->validate();
$reference = $this->editingId
? CmsReference::findOrFail($this->editingId)
: CmsReference::query()->make();
$reference->setTranslation('title', $this->editLocale, trim($validated['referenceTitle']));
$reference->setTranslation('description', $this->editLocale, trim((string) ($validated['description'] ?? '')));
$reference->location = trim((string) ($validated['location'] ?? '')) ?: null;
$reference->year = trim((string) ($validated['year'] ?? '')) ?: null;
$reference->image = trim((string) ($validated['image'] ?? '')) ?: null;
$reference->gallery = CmsReference::normalizeGallery($this->gallery);
$reference->brand_slugs = array_values(array_unique($validated['brandSlugs']));
$reference->is_published = $validated['is_published'];
$reference->order = $validated['order'];
$reference->save();
$this->showForm = false;
$this->editingId = null;
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Referenz wurde erfolgreich gespeichert.');
};
$togglePublished = function (int $id) {
$reference = CmsReference::findOrFail($id);
$reference->update(['is_published' => ! $reference->is_published]);
Flux::toast(heading: 'Status geändert', text: $reference->is_published ? 'Veröffentlicht' : 'Entwurf');
};
$deleteReference = function (int $id) {
CmsReference::findOrFail($id)->delete();
Flux::toast(variant: 'success', heading: 'Gelöscht', text: 'Referenz wurde entfernt.');
};
$cancelForm = function () {
$this->showForm = false;
$this->editingId = null;
};
?>
<div>
<div class="mb-6 flex items-center justify-between">
<div>
<flux:heading size="xl">Referenzen</flux:heading>
<flux:text class="mt-1">Realisierte Projekte für die Seite /referenzen. Nur ausgeführte Aufträge – Entwürfe und Visualisierungen gehören in die Galerie der jeweiligen Marke.</flux:text>
</div>
<div class="flex items-center gap-2">
@foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label)
<flux:button size="xs" :variant="$editLocale === $code ? 'primary' : 'ghost'"
wire:click="switchLocale('{{ $code }}')">{{ strtoupper($code) }}</flux:button>
@endforeach
<flux:button variant="primary" icon="plus" wire:click="openCreate">Neue Referenz</flux:button>
</div>
</div>
<div class="mb-4">
<flux:input wire:model.live.debounce.300ms="search" placeholder="Referenz suchen..." icon="magnifying-glass"
size="sm" class="w-64" />
</div>
@if ($showForm)
<flux:card class="mb-6">
<div class="mb-4 flex items-center justify-between">
<flux:heading size="lg">{{ $editingId ? 'Referenz bearbeiten' : 'Neue Referenz' }}</flux:heading>
<div class="flex gap-1">
@foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label)
<flux:button size="xs" :variant="$editLocale === $code ? 'primary' : 'ghost'"
wire:click="switchLocale('{{ $code }}')">{{ strtoupper($code) }}</flux:button>
@endforeach
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="md:col-span-2">
<flux:input wire:model="referenceTitle" label="Titel ({{ strtoupper($editLocale) }})"
placeholder="Ankleide unter der Dachschräge" />
@error('referenceTitle') <span class="text-sm text-red-600">{{ $message }}</span> @enderror
</div>
<flux:input wire:model="location" label="Ort (optional)" placeholder="Bielefeld" />
<flux:input wire:model="year" label="Jahr (optional)" placeholder="2025" />
<div class="md:col-span-2">
<flux:textarea wire:model="description" label="Beschreibung ({{ strtoupper($editLocale) }})" rows="4"
placeholder="Was wurde gebaut, was war die Aufgabe, was ist das Besondere?" />
</div>
<flux:input wire:model="order" label="Sortierung" type="number" />
</div>
{{-- Beteiligte Marken --}}
<div class="mt-6">
<flux:heading size="sm" class="mb-1">Beteiligte Marken</flux:heading>
<flux:text class="mb-3 text-sm">Jede gewählte Marke wird unter der Referenz genannt und auf ihre Markenseite verlinkt.</flux:text>
<div class="flex flex-wrap gap-4">
@foreach ($this->brands as $brand)
<flux:checkbox wire:model="brandSlugs" value="{{ $brand->slug }}"
label="{{ $brand->getTranslation('name', 'de') }}" wire:key="brand-check-{{ $brand->slug }}" />
@endforeach
</div>
@error('brandSlugs.*') <span class="text-sm text-red-600">{{ $message }}</span> @enderror
</div>
{{-- Titelbild --}}
<div class="mt-6">
<label class="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300">Titelbild</label>
<div class="flex items-start gap-3">
@if ($image)
<div class="h-20 w-28 shrink-0 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800">
<img src="{{ media_url($image) }}" class="h-full w-full object-cover" alt="" />
</div>
@endif
<div class="flex-1 space-y-2">
<livewire:admin.cms.media-picker :value="null" field="reference_image" type="image" profile="card"
label="Bild wählen" :key="'reference-img-' . ($editingId ?? 'new')" />
<flux:input wire:model="image" size="sm" placeholder="Oder Dateiname manuell..." />
</div>
</div>
</div>
{{-- Weitere Bilder --}}
<div class="mt-6">
<div class="mb-2 flex items-center justify-between">
<flux:heading size="sm">Weitere Bilder</flux:heading>
<flux:button size="xs" variant="ghost" icon="plus" wire:click="addGalleryImage">Bild hinzufügen</flux:button>
</div>
@if (empty($gallery))
<p class="text-sm text-zinc-400">Noch keine weiteren Bilder.</p>
@else
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
@foreach ($gallery as $i => $item)
<div wire:key="reference-gallery-{{ $i }}" class="rounded-lg border border-zinc-200 p-3 dark:border-zinc-700">
@if (!empty($item['image']))
<div class="mb-2 h-24 w-full overflow-hidden rounded-md">
<img src="{{ media_url($item['image']) }}" class="h-full w-full object-cover" alt="" />
</div>
@endif
<flux:input wire:model="gallery.{{ $i }}.caption" size="sm" class="mb-2"
placeholder="Bildunterschrift (optional)" />
<div class="flex items-center justify-between gap-2">
<livewire:admin.cms.media-picker :value="null" field="reference_gallery:{{ $i }}"
type="image" profile="card" label="Wählen"
:key="'reference-gallery-' . ($editingId ?? 'new') . '-' . $i" />
<div class="flex gap-1">
<flux:button size="xs" variant="ghost" icon="chevron-left" wire:click="moveGalleryImage({{ $i }}, -1)" />
<flux:button size="xs" variant="ghost" icon="chevron-right" wire:click="moveGalleryImage({{ $i }}, 1)" />
<flux:button size="xs" variant="ghost" icon="trash" wire:click="removeGalleryImage({{ $i }})" />
</div>
</div>
<flux:input wire:model="gallery.{{ $i }}.image" size="sm" class="mt-2"
placeholder="Oder Dateiname manuell..." />
</div>
@endforeach
</div>
@endif
</div>
<div class="mt-4">
<flux:switch wire:model="is_published" label="Veröffentlicht" />
</div>
<div class="mt-4 flex gap-2">
<flux:button variant="primary" wire:click="save">Speichern</flux:button>
<flux:button variant="ghost" wire:click="cancelForm">Abbrechen</flux:button>
</div>
</flux:card>
@endif
<flux:card>
<div class="divide-y dark:divide-zinc-700">
@forelse ($this->references as $reference)
<div wire:key="reference-{{ $reference->id }}" class="flex items-center justify-between gap-4 py-3">
<div class="flex min-w-0 flex-1 items-center gap-3">
@if ($reference->image)
<div class="h-12 w-20 shrink-0 overflow-hidden rounded-lg bg-zinc-100 dark:bg-zinc-700">
<img src="{{ media_url($reference->image) }}" alt="" class="h-full w-full object-cover" loading="lazy" />
</div>
@else
<div class="flex h-12 w-20 shrink-0 items-center justify-center rounded-lg bg-zinc-100 dark:bg-zinc-700">
<x-heroicon-o-photo class="h-6 w-6 text-zinc-400" />
</div>
@endif
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="truncate font-medium text-zinc-800 dark:text-zinc-200">
{{ $reference->getTranslation('title', $editLocale) ?: $reference->getTranslation('title', 'de') }}
</span>
@foreach ($reference->brand_slugs ?? [] as $slug)
<flux:badge size="sm" color="blue">{{ $slug }}</flux:badge>
@endforeach
@unless ($reference->is_published)
<flux:badge size="sm" color="amber">Entwurf</flux:badge>
@endunless
</div>
<p class="text-sm text-zinc-500">
{{ $reference->location ?: '–' }} · {{ $reference->year ?: '–' }} · {{ count(CmsReference::normalizeGallery($reference->gallery)) + ($reference->image ? 1 : 0) }} Bilder
</p>
</div>
</div>
<div class="flex shrink-0 items-center gap-1">
<flux:button size="sm" variant="ghost" icon="pencil" wire:click="openEdit({{ $reference->id }})" />
<flux:button size="sm" variant="ghost" :icon="$reference->is_published ? 'eye' : 'eye-slash'"
wire:click="togglePublished({{ $reference->id }})" />
<flux:button size="sm" variant="ghost" icon="trash"
wire:click="deleteReference({{ $reference->id }})"
wire:confirm="Referenz wirklich löschen?" />
</div>
</div>
@empty
<div class="py-12 text-center">
<x-heroicon-o-photo class="mx-auto mb-4 h-12 w-12 text-zinc-400" />
<flux:heading>Keine Referenzen</flux:heading>
<flux:text>Solange hier nichts steht, zeigt /referenzen die als „Realisiertes Projekt“ markierten Bilder aus den Markengalerien.</flux:text>
</div>
@endforelse
</div>
</flux:card>
</div>