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>
367 lines
16 KiB
PHP
367 lines
16 KiB
PHP
<?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>
|