10-04-2026
This commit is contained in:
parent
4d6b4930b2
commit
4bb89aad8c
836 changed files with 52961 additions and 5950 deletions
367
resources/views/livewire/admin/cms/articles-index.blade.php
Normal file
367
resources/views/livewire/admin/cms/articles-index.blade.php
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
<?php
|
||||
|
||||
use App\Models\CmsArticle;
|
||||
use Flux\Flux;
|
||||
use function Livewire\Volt\{layout, title, state, computed, on};
|
||||
|
||||
layout('components.layouts.app');
|
||||
title('Magazin verwalten');
|
||||
|
||||
state([
|
||||
'search' => '',
|
||||
'showForm' => false,
|
||||
'editingId' => null,
|
||||
'editLocale' => 'de',
|
||||
'slug' => '',
|
||||
'articleTitle' => '',
|
||||
'subtitle' => '',
|
||||
'image' => '',
|
||||
'category' => '',
|
||||
'date_label' => '',
|
||||
'read_time' => '',
|
||||
'authorName' => '',
|
||||
'authorBio' => '',
|
||||
'authorAvatar' => '',
|
||||
'intro' => '',
|
||||
'sections' => [],
|
||||
'is_published' => true,
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
on(['media-selected' => function ($mediaId, $url, $field) {
|
||||
$media = $mediaId ? \FluxCms\Core\Models\CmsMedia::find($mediaId) : null;
|
||||
if ($field === 'article_image' && $media) {
|
||||
$this->image = $media->filename;
|
||||
}
|
||||
if ($field === 'author_avatar' && $media) {
|
||||
$this->authorAvatar = $media->filename;
|
||||
}
|
||||
}]);
|
||||
|
||||
$articles = computed(
|
||||
fn () => CmsArticle::query()
|
||||
->when($this->search, fn ($q, $s) => $q->where('slug', 'like', "%{$s}%")
|
||||
->orWhereRaw("JSON_UNQUOTE(JSON_EXTRACT(title, '$.de')) LIKE ?", ["%{$s}%"]))
|
||||
->ordered()
|
||||
->get(),
|
||||
);
|
||||
|
||||
$openCreate = function () {
|
||||
$this->editingId = null;
|
||||
$this->reset(['slug', 'articleTitle', 'subtitle', 'image', 'category', 'date_label', 'read_time', 'authorName', 'authorBio', 'authorAvatar', 'intro', 'sections']);
|
||||
$this->is_published = true;
|
||||
$this->order = 0;
|
||||
$this->sections = [['title' => '', 'content' => '']];
|
||||
$this->showForm = true;
|
||||
};
|
||||
|
||||
$openEdit = function (int $id) {
|
||||
$article = CmsArticle::find($id);
|
||||
if (! $article) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->editingId = $id;
|
||||
$this->slug = $article->slug;
|
||||
$this->image = $article->image ?? '';
|
||||
$this->category = $article->category ?? '';
|
||||
$this->date_label = $article->date_label ?? '';
|
||||
$this->read_time = $article->read_time ?? '';
|
||||
$this->is_published = $article->is_published;
|
||||
$this->order = $article->order ?? 0;
|
||||
|
||||
$author = $article->author ?? [];
|
||||
$this->authorName = $author['name'] ?? '';
|
||||
$this->authorBio = $author['bio'] ?? '';
|
||||
$this->authorAvatar = $author['avatar'] ?? '';
|
||||
|
||||
$this->loadLocaleFields($article, $this->editLocale);
|
||||
$this->showForm = true;
|
||||
};
|
||||
|
||||
$loadLocaleFields = function (CmsArticle $article, string $locale) {
|
||||
$this->articleTitle = $article->getTranslation('title', $locale) ?? '';
|
||||
$this->subtitle = $article->getTranslation('subtitle', $locale) ?? '';
|
||||
|
||||
$content = $article->getTranslation('content', $locale);
|
||||
$this->intro = $content['intro'] ?? '';
|
||||
$this->sections = $content['sections'] ?? [['title' => '', 'content' => '']];
|
||||
};
|
||||
|
||||
$switchLocale = function (string $locale) {
|
||||
$this->editLocale = $locale;
|
||||
if ($this->editingId) {
|
||||
$article = CmsArticle::find($this->editingId);
|
||||
if ($article) {
|
||||
$this->loadLocaleFields($article, $locale);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$addSection = function () {
|
||||
$this->sections[] = ['title' => '', 'content' => ''];
|
||||
};
|
||||
|
||||
$removeSection = function (int $index) {
|
||||
unset($this->sections[$index]);
|
||||
$this->sections = array_values($this->sections);
|
||||
};
|
||||
|
||||
$save = function () {
|
||||
$validated = validator([
|
||||
'slug' => $this->slug,
|
||||
'articleTitle' => $this->articleTitle,
|
||||
'subtitle' => $this->subtitle,
|
||||
'image' => $this->image,
|
||||
'category' => $this->category,
|
||||
'date_label' => $this->date_label,
|
||||
'read_time' => $this->read_time,
|
||||
'authorName' => $this->authorName,
|
||||
'intro' => $this->intro,
|
||||
'is_published' => $this->is_published,
|
||||
'order' => $this->order,
|
||||
], [
|
||||
'slug' => 'required|string|max:255',
|
||||
'articleTitle' => 'required|string|max:500',
|
||||
'subtitle' => 'nullable|string|max:1000',
|
||||
'image' => 'nullable|string|max:500',
|
||||
'category' => 'nullable|string|max:255',
|
||||
'date_label' => 'nullable|string|max:100',
|
||||
'read_time' => 'nullable|string|max:50',
|
||||
'authorName' => 'nullable|string|max:255',
|
||||
'intro' => 'nullable|string',
|
||||
'is_published' => 'boolean',
|
||||
'order' => 'integer|min:0',
|
||||
])->validate();
|
||||
|
||||
$article = $this->editingId
|
||||
? CmsArticle::findOrFail($this->editingId)
|
||||
: CmsArticle::query()->make();
|
||||
|
||||
$article->slug = $validated['slug'];
|
||||
$article->setTranslation('title', $this->editLocale, $validated['articleTitle']);
|
||||
$article->setTranslation('subtitle', $this->editLocale, $validated['subtitle'] ?? '');
|
||||
|
||||
$contentData = [
|
||||
'intro' => $validated['intro'] ?? '',
|
||||
'sections' => collect($this->sections)
|
||||
->filter(fn ($s) => ! empty($s['title']) || ! empty($s['content']))
|
||||
->values()
|
||||
->toArray(),
|
||||
];
|
||||
$article->setTranslation('content', $this->editLocale, $contentData);
|
||||
|
||||
$article->image = $validated['image'] ?? null;
|
||||
$article->category = $validated['category'] ?? null;
|
||||
$article->date_label = $validated['date_label'] ?? null;
|
||||
$article->read_time = $validated['read_time'] ?? null;
|
||||
$article->author = [
|
||||
'name' => $this->authorName,
|
||||
'bio' => $this->authorBio,
|
||||
'avatar' => $this->authorAvatar,
|
||||
];
|
||||
$article->is_published = $validated['is_published'];
|
||||
$article->order = $validated['order'];
|
||||
$article->save();
|
||||
|
||||
$this->showForm = false;
|
||||
$this->editingId = null;
|
||||
|
||||
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Artikel wurde erfolgreich gespeichert.');
|
||||
};
|
||||
|
||||
$togglePublished = function (int $id) {
|
||||
$article = CmsArticle::findOrFail($id);
|
||||
$article->update(['is_published' => ! $article->is_published]);
|
||||
Flux::toast(heading: 'Status geändert', text: $article->is_published ? 'Veröffentlicht' : 'Entwurf');
|
||||
};
|
||||
|
||||
$deleteArticle = function (int $id) {
|
||||
$article = CmsArticle::findOrFail($id);
|
||||
$article->delete();
|
||||
Flux::toast(variant: 'success', heading: 'Gelöscht', text: 'Artikel wurde entfernt.');
|
||||
};
|
||||
|
||||
$cancelForm = function () {
|
||||
$this->showForm = false;
|
||||
$this->editingId = null;
|
||||
};
|
||||
|
||||
?>
|
||||
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<flux:heading size="xl">Magazin Beiträge</flux:heading>
|
||||
<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">Neuer Artikel</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<flux:input wire:model.live.debounce.300ms="search" placeholder="Artikel 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 ? 'Artikel bearbeiten' : 'Neuer Artikel' }}</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">
|
||||
<flux:input wire:model="slug" label="Slug" placeholder="escrow-system-dubai-investoren" />
|
||||
<flux:input wire:model="articleTitle" label="Titel ({{ strtoupper($editLocale) }})" placeholder="Artikeltitel" />
|
||||
<div class="md:col-span-2">
|
||||
<flux:input wire:model="subtitle" label="Untertitel ({{ strtoupper($editLocale) }})" placeholder="Kurze Beschreibung" />
|
||||
</div>
|
||||
<flux:input wire:model="category" label="Kategorie" placeholder="z.B. Dubai Investment" />
|
||||
<flux:input wire:model="date_label" label="Datum (Anzeige)" placeholder="März 10, 2026" />
|
||||
<flux:input wire:model="read_time" label="Lesezeit" placeholder="6 min read" />
|
||||
<flux:input wire:model="order" label="Sortierung" type="number" />
|
||||
</div>
|
||||
|
||||
{{-- Bild --}}
|
||||
<div class="mt-4">
|
||||
<label class="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300">Artikelbild</label>
|
||||
<div class="flex items-start gap-3">
|
||||
@if ($image)
|
||||
<div class="h-16 w-24 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" />
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex-1">
|
||||
<livewire:admin.cms.media-picker
|
||||
:value="null"
|
||||
field="article_image"
|
||||
type="image"
|
||||
profile="card"
|
||||
label="Bild wählen"
|
||||
:key="'article-img-' . ($editingId ?? 'new')" />
|
||||
<p class="mt-1 text-xs text-zinc-400">{{ $image ?: 'Kein Bild' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Autor --}}
|
||||
<div class="mt-6">
|
||||
<flux:heading size="sm" class="mb-3">Autor</flux:heading>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<flux:input wire:model="authorName" label="Name" placeholder="Marcel Scheibe" />
|
||||
<flux:input wire:model="authorBio" label="Bio" placeholder="Kurze Biografie" />
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300">Avatar</label>
|
||||
<div class="flex items-center gap-2">
|
||||
@if ($authorAvatar)
|
||||
<div class="h-10 w-10 shrink-0 overflow-hidden rounded-full border border-zinc-200 dark:border-zinc-700">
|
||||
<img src="{{ media_url($authorAvatar) }}" class="h-full w-full object-cover" />
|
||||
</div>
|
||||
@endif
|
||||
<livewire:admin.cms.media-picker
|
||||
:value="null"
|
||||
field="author_avatar"
|
||||
type="image"
|
||||
label="Avatar wählen"
|
||||
:key="'author-avatar-' . ($editingId ?? 'new')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Inhalt --}}
|
||||
<div class="mt-6">
|
||||
<flux:heading size="sm" class="mb-3">Inhalt ({{ strtoupper($editLocale) }})</flux:heading>
|
||||
<flux:textarea wire:model="intro" label="Einleitung" rows="4" placeholder="Einleitungstext des Artikels..." />
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<label class="text-sm font-medium text-zinc-700 dark:text-zinc-300">Abschnitte</label>
|
||||
<flux:button size="xs" variant="ghost" icon="plus" wire:click="addSection">Abschnitt hinzufügen</flux:button>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
@foreach ($sections as $i => $section)
|
||||
<div wire:key="section-{{ $i }}" class="rounded-lg border border-zinc-200 p-4 dark:border-zinc-700">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-zinc-500">Abschnitt {{ $i + 1 }}</span>
|
||||
@if (count($sections) > 1)
|
||||
<flux:button size="xs" variant="ghost" icon="trash" wire:click="removeSection({{ $i }})" />
|
||||
@endif
|
||||
</div>
|
||||
<flux:input wire:model="sections.{{ $i }}.title" label="Überschrift" placeholder="Abschnitt-Titel" class="mb-3" />
|
||||
<flux:textarea wire:model="sections.{{ $i }}.content" label="Text" rows="3" placeholder="Abschnitt-Inhalt..." />
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</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->articles as $article)
|
||||
<div wire:key="article-{{ $article->id }}" class="flex items-center justify-between gap-4 py-3">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||
@if ($article->image)
|
||||
<div class="h-12 w-20 shrink-0 overflow-hidden rounded-lg bg-zinc-100 dark:bg-zinc-700">
|
||||
<img src="{{ media_url($article->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-newspaper 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">
|
||||
{{ strip_tags($article->getTranslation('title', $editLocale)) }}
|
||||
</span>
|
||||
@if ($article->category)
|
||||
<flux:badge size="sm" color="blue">{{ $article->category }}</flux:badge>
|
||||
@endif
|
||||
@unless ($article->is_published)
|
||||
<flux:badge size="sm" color="amber">Entwurf</flux:badge>
|
||||
@endunless
|
||||
</div>
|
||||
<p class="text-sm text-zinc-500">
|
||||
{{ $article->author['name'] ?? '–' }} · {{ $article->date_label ?? '–' }} · {{ $article->read_time ?? '–' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<flux:button size="sm" variant="ghost" icon="pencil" wire:click="openEdit({{ $article->id }})" />
|
||||
<flux:button size="sm" variant="ghost" :icon="$article->is_published ? 'eye' : 'eye-slash'"
|
||||
wire:click="togglePublished({{ $article->id }})" />
|
||||
<flux:button size="sm" variant="ghost" icon="trash"
|
||||
wire:click="deleteArticle({{ $article->id }})"
|
||||
wire:confirm="Artikel wirklich löschen?" />
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="py-12 text-center">
|
||||
<x-heroicon-o-newspaper class="mx-auto mb-4 h-12 w-12 text-zinc-400" />
|
||||
<flux:heading>Keine Artikel</flux:heading>
|
||||
<flux:text>Erstelle den ersten Magazin-Beitrag mit dem Button oben.</flux:text>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
165
resources/views/livewire/admin/cms/cabinet-info-tablet.blade.php
Normal file
165
resources/views/livewire/admin/cms/cabinet-info-tablet.blade.php
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
<div>
|
||||
<flux:header class="mb-6">
|
||||
<flux:heading size="xl">{{ __('Cabinet Info-Tablet') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Einstellungen für das Schaufenster-Tablet') }}</flux:subheading>
|
||||
</flux:header>
|
||||
|
||||
{{-- Hilfe-Banner --}}
|
||||
<flux:card class="mb-6 bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800">
|
||||
<div class="flex items-start gap-4">
|
||||
<flux:icon.information-circle class="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<div class="flex-1">
|
||||
<h3 class="font-semibold text-blue-900 dark:text-blue-100 mb-2">{{ __('Info-Tablet Steuerung') }}</h3>
|
||||
<div class="text-sm text-blue-800 dark:text-blue-200 space-y-1">
|
||||
<p>Das Info-Tablet im Schaufenster zeigt Store-Status, Öffnungszeiten und den nächsten freien Termin.</p>
|
||||
<p>• <strong>Automatisch:</strong> Offen/Geschlossen wird automatisch aus den hinterlegten Öffnungszeiten berechnet.</p>
|
||||
<p>• <strong>API-Endpunkt:</strong> <code class="px-2 py-0.5 bg-blue-100 dark:bg-blue-800 rounded">{{ url('/api/cabinet-tablet/status') }}</code></p>
|
||||
<p>• <strong>Website-Endpunkt:</strong> <code class="px-2 py-0.5 bg-blue-100 dark:bg-blue-800 rounded"><a target="_blank" href="https://cabinet.b2in.eu/info">https://cabinet.b2in.eu/info</a></code></p>
|
||||
<p>• <strong>Quick-Status-Endpunkt:</strong> <code class="px-2 py-0.5 bg-blue-100 dark:bg-blue-800 rounded"><a target="_blank" href="https://portal.b2in.eu/info/status?key={{ config('domains.cabinet_status_key') }}">https://portal.b2in.eu/info/status?key={{ config('domains.cabinet_status_key') }}</a></code></p>
|
||||
<p>• <strong>Quick-Status-Hinweis:</strong> Der Quick-Status-Endpunkt ist ein kleines Tool, um den Store-Status schnell zu ändern. Auf dem Mobiltelefon den Link öffen und dem Home-Screen hinzufügen.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Success-Meldungen --}}
|
||||
@if (session()->has('success'))
|
||||
<x-success-alert>
|
||||
{{ session('success') }}
|
||||
</x-success-alert>
|
||||
@endif
|
||||
|
||||
<form wire:submit.prevent="save"
|
||||
x-data
|
||||
x-on:submit.prevent="
|
||||
const hours = {};
|
||||
document.querySelectorAll('[data-hours-prop]').forEach(el => {
|
||||
const prop = el.dataset.hoursProp;
|
||||
const picker = el.querySelector('ui-time-picker');
|
||||
hours[prop] = picker ? (picker.value ?? '') : '';
|
||||
});
|
||||
$wire.save(hours);
|
||||
">
|
||||
<div class="space-y-6">
|
||||
|
||||
{{-- Store-Status --}}
|
||||
<flux:card>
|
||||
<flux:heading size="lg" class="mb-4">{{ __('Store-Status') }}</flux:heading>
|
||||
|
||||
<div class="space-y-4">
|
||||
<flux:select wire:model.live="storeStatus" label="Modus">
|
||||
<option value="auto">Automatisch (aus Öffnungszeiten)</option>
|
||||
<option value="notice">Hinweis (Orange)</option>
|
||||
<option value="warning">Warnung (Rot)</option>
|
||||
<option value="closed">Manuell geschlossen (Gelb)</option>
|
||||
</flux:select>
|
||||
|
||||
@if($storeStatus !== 'auto')
|
||||
@php
|
||||
$noticeColors = match($storeStatus) {
|
||||
'notice' => 'bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800',
|
||||
'warning' => 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800',
|
||||
default => 'bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800',
|
||||
};
|
||||
@endphp
|
||||
<div class="space-y-4 p-4 rounded-lg {{ $noticeColors }}">
|
||||
<flux:input wire:model="noticeHeadline" label="Headline" placeholder="z.B. Heute erst ab 11:00 Uhr" maxlength="40" />
|
||||
@error('noticeHeadline') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||
|
||||
<flux:input wire:model="noticeSubtext" label="Subtext (optional)" placeholder="z.B. Wegen eines Kundentermins öffnen wir heute später." maxlength="80" />
|
||||
@error('noticeSubtext') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Öffnungszeiten --}}
|
||||
<flux:card>
|
||||
<flux:heading size="lg" class="mb-1">{{ __('Öffnungszeiten') }}</flux:heading>
|
||||
<p class="text-sm text-zinc-500 dark:text-zinc-400 mb-4">Felder leer lassen = Geschlossen. Der Status "Geöffnet/Geschlossen" wird automatisch aus diesen Zeiten berechnet.</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
@foreach([
|
||||
['label' => 'Montag', 'open' => 'hoursMondayOpen', 'close' => 'hoursMondayClose'],
|
||||
['label' => 'Dienstag', 'open' => 'hoursTuesdayOpen', 'close' => 'hoursTuesdayClose'],
|
||||
['label' => 'Mittwoch', 'open' => 'hoursWednesdayOpen', 'close' => 'hoursWednesdayClose'],
|
||||
['label' => 'Donnerstag', 'open' => 'hoursThursdayOpen', 'close' => 'hoursThursdayClose'],
|
||||
['label' => 'Freitag', 'open' => 'hoursFridayOpen', 'close' => 'hoursFridayClose'],
|
||||
['label' => 'Samstag', 'open' => 'hoursSaturdayOpen', 'close' => 'hoursSaturdayClose'],
|
||||
['label' => 'Sonntag', 'open' => 'hoursSundayOpen', 'close' => 'hoursSundayClose'],
|
||||
] as $row)
|
||||
@php
|
||||
$isClosedDay = empty($this->{$row['open']}) && empty($this->{$row['close']});
|
||||
@endphp
|
||||
<div class="grid grid-cols-[120px_1fr_1fr_80px] items-center gap-3 py-2 border-b border-zinc-100 dark:border-zinc-800 last:border-0">
|
||||
<span class="text-sm font-medium text-zinc-700 dark:text-zinc-300">{{ $row['label'] }}</span>
|
||||
<div data-hours-prop="{{ $row['open'] }}">
|
||||
<flux:time-picker wire:model.live="{{ $row['open'] }}" label="Öffnung" time-format="24-hour" interval="15" clearable placeholder="–" />
|
||||
</div>
|
||||
<div data-hours-prop="{{ $row['close'] }}">
|
||||
<flux:time-picker wire:model.live="{{ $row['close'] }}" label="Schluss" time-format="24-hour" interval="15" clearable placeholder="–" />
|
||||
</div>
|
||||
<span class="text-xs text-center {{ $isClosedDay ? 'text-red-500 dark:text-red-400' : 'text-emerald-600 dark:text-emerald-400' }}">
|
||||
{{ $isClosedDay ? 'Geschlossen' : 'Geöffnet' }}
|
||||
</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Sonderöffnung heute
|
||||
<flux:card>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<flux:heading size="lg">{{ __('Sonderöffnung heute') }}</flux:heading>
|
||||
@if($overrideOpenToday || $overrideCloseToday)
|
||||
<flux:button wire:click="clearOverrides" size="sm" variant="ghost" icon="x-mark">
|
||||
{{ __('Zurücksetzen') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
</div>
|
||||
<p class="text-sm text-zinc-500 dark:text-zinc-400 mb-4">Überschreibt die reguläre Öffnungszeit für heute. Wird um Mitternacht automatisch zurückgesetzt.</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<flux:time-picker wire:model="overrideOpenToday" label="Öffnung" time-format="24-hour" interval="15" clearable placeholder="–" />
|
||||
@error('overrideOpenToday') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||
|
||||
<flux:time-picker wire:model="overrideCloseToday" label="Schluss" time-format="24-hour" interval="15" clearable placeholder="–" />
|
||||
@error('overrideCloseToday') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
</flux:card>--}}
|
||||
|
||||
{{-- Nächster Termin
|
||||
<flux:card>
|
||||
<flux:heading size="lg" class="mb-4">{{ __('Nächster freier Termin') }}</flux:heading>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<flux:input wire:model="nextAppointmentDate" type="date" label="Datum" />
|
||||
@error('nextAppointmentDate') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||
|
||||
<flux:time-picker wire:model="nextAppointmentTime" label="Uhrzeit" time-format="24-hour" interval="15" clearable placeholder="–" />
|
||||
@error('nextAppointmentTime') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
</flux:card>--}}
|
||||
|
||||
{{-- Kontakt --}}
|
||||
<flux:card>
|
||||
<flux:heading size="lg" class="mb-4">{{ __('Kontakt') }}</flux:heading>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<flux:input wire:model="contactPhone" label="Telefon" placeholder="z.B. 0521 98620100" />
|
||||
@error('contactPhone') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||
|
||||
<flux:input wire:model="contactEmail" label="E-Mail" type="email" placeholder="z.B. info@cabinet-bielefeld.de" />
|
||||
@error('contactEmail') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Speichern --}}
|
||||
<div class="flex justify-end">
|
||||
<flux:button type="submit" variant="primary" icon="check">
|
||||
{{ __('Einstellungen speichern') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
782
resources/views/livewire/admin/cms/content-index.blade.php
Normal file
782
resources/views/livewire/admin/cms/content-index.blade.php
Normal file
|
|
@ -0,0 +1,782 @@
|
|||
<?php
|
||||
|
||||
use App\Services\CmsFluxEditorHtmlTransformer;
|
||||
use Flux\Flux;
|
||||
use FluxCms\Core\Models\CmsContent;
|
||||
use FluxCms\Core\Models\CmsMedia;
|
||||
use FluxCms\Core\Services\CmsContentService;
|
||||
use FluxCms\Core\Services\HeroiconOutlineList;
|
||||
use function Livewire\Volt\{layout, title, state, computed, on};
|
||||
|
||||
if (! function_exists('_cmsParseJsonItems')) {
|
||||
function _cmsParseJsonItems(array $value): array
|
||||
{
|
||||
if (empty($value)) {
|
||||
return [false, []];
|
||||
}
|
||||
|
||||
$isList = array_is_list($value);
|
||||
$stringify = fn ($v) => is_array($v) ? json_encode($v, JSON_UNESCAPED_UNICODE) : (string) $v;
|
||||
|
||||
if ($isList && ! is_array($value[0])) {
|
||||
return [true, array_map(fn ($v) => ['_value' => $stringify($v)], $value)];
|
||||
}
|
||||
|
||||
if ($isList) {
|
||||
return [false, array_map(fn ($item) => is_array($item)
|
||||
? array_map($stringify, $item)
|
||||
: ['_value' => (string) $item], $value)];
|
||||
}
|
||||
|
||||
return [false, [array_map($stringify, $value)]];
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('_cmsFieldLooksLikeImage')) {
|
||||
function _cmsFieldLooksLikeImage(string $fieldKey, mixed $fieldValue): bool
|
||||
{
|
||||
if (! is_string($fieldValue) || trim($fieldValue) === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$key = strtolower($fieldKey);
|
||||
if (in_array($key, ['image', 'photo', 'avatar', 'picture', 'thumbnail', 'img', 'hero_image', 'background_image', 'cover_image'], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (preg_match('/_(image|photo|avatar|picture|thumb)$/i', $fieldKey)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (bool) preg_match('/\.(jpe?g|png|gif|webp|svg)$/i', $fieldValue);
|
||||
}
|
||||
}
|
||||
|
||||
layout('components.layouts.app');
|
||||
title('CMS Inhalte');
|
||||
|
||||
state([
|
||||
'selectedGroup' => null,
|
||||
'search' => '',
|
||||
'editingId' => null,
|
||||
'editingField' => null,
|
||||
'editLocale' => 'de',
|
||||
'editValue' => '',
|
||||
'editMediaId' => null,
|
||||
'showJsonModal' => false,
|
||||
'jsonItems' => [],
|
||||
'jsonIsStringArray' => false,
|
||||
'jsonEditingKey' => '',
|
||||
'editingFieldType' => 'text',
|
||||
]);
|
||||
|
||||
on(['media-selected' => function (string $field, ?int $mediaId, ?string $url) {
|
||||
if ($field === 'content_image') {
|
||||
$media = $mediaId ? CmsMedia::find($mediaId) : null;
|
||||
if ($media) {
|
||||
$this->editValue = $media->filename;
|
||||
$this->editMediaId = $mediaId;
|
||||
} else {
|
||||
$this->editValue = '';
|
||||
$this->editMediaId = null;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (str_starts_with($field, 'jsonimg:')) {
|
||||
$parts = explode(':', $field, 3);
|
||||
if (count($parts) === 3 && $mediaId) {
|
||||
$media = CmsMedia::find($mediaId);
|
||||
if ($media) {
|
||||
$idx = (int) $parts[1];
|
||||
$fname = $parts[2];
|
||||
if (isset($this->jsonItems[$idx]) && is_array($this->jsonItems[$idx])) {
|
||||
$this->jsonItems[$idx][$fname] = $media->filename;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}]);
|
||||
|
||||
$groups = computed(fn () => CmsContent::query()
|
||||
->selectRaw('`group`, count(*) as count')
|
||||
->groupBy('group')
|
||||
->orderBy('group')
|
||||
->pluck('count', 'group')
|
||||
->toArray());
|
||||
|
||||
$flatContents = computed(function () {
|
||||
if (! $this->selectedGroup) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$contents = CmsContent::forGroup($this->selectedGroup)
|
||||
->orderBy('order')
|
||||
->get();
|
||||
|
||||
$rows = [];
|
||||
|
||||
foreach ($contents as $content) {
|
||||
$value = $content->getTranslation('value', $this->editLocale);
|
||||
|
||||
if ($content->type === 'json' && is_array($value) && ! array_is_list($value)) {
|
||||
foreach ($value as $fieldKey => $fieldValue) {
|
||||
if ($this->search && ! str_contains(strtolower($content->key . '.' . $fieldKey), strtolower($this->search))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fieldType = 'text';
|
||||
if ($this->selectedGroup === 'legal' && $fieldKey === 'content') {
|
||||
$fieldType = 'legal_html';
|
||||
} elseif (is_array($fieldValue)) {
|
||||
$fieldType = 'json';
|
||||
} elseif (is_string($fieldValue) && preg_match('/<[^>]+>/', $fieldValue)) {
|
||||
$fieldType = 'html';
|
||||
} elseif (_cmsFieldLooksLikeImage($fieldKey, $fieldValue)) {
|
||||
$fieldType = 'image';
|
||||
}
|
||||
|
||||
$rows[] = (object) [
|
||||
'content_id' => $content->id,
|
||||
'section_key' => $content->key,
|
||||
'field_key' => $fieldKey,
|
||||
'display_key' => $content->key . '.' . $fieldKey,
|
||||
'type' => $fieldType,
|
||||
'value' => $fieldValue,
|
||||
'is_subfield' => true,
|
||||
];
|
||||
}
|
||||
} else {
|
||||
if ($this->search && ! str_contains(strtolower($content->key), strtolower($this->search))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[] = (object) [
|
||||
'content_id' => $content->id,
|
||||
'section_key' => $content->key,
|
||||
'field_key' => null,
|
||||
'display_key' => $content->key,
|
||||
'type' => $content->type ?? 'text',
|
||||
'value' => $value,
|
||||
'is_subfield' => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return collect($rows);
|
||||
});
|
||||
|
||||
$availableIcons = computed(fn () => HeroiconOutlineList::names());
|
||||
|
||||
$selectGroup = function (string $group) {
|
||||
$this->showJsonModal = false;
|
||||
$this->jsonItems = [];
|
||||
$this->jsonIsStringArray = false;
|
||||
$this->jsonEditingKey = '';
|
||||
$this->editingId = null;
|
||||
$this->editingField = null;
|
||||
$this->editValue = '';
|
||||
$this->editMediaId = null;
|
||||
$this->editingFieldType = 'text';
|
||||
$this->selectedGroup = $group;
|
||||
};
|
||||
|
||||
$startFieldEdit = function (int $contentId, ?string $fieldKey = null) {
|
||||
$content = CmsContent::find($contentId);
|
||||
if (! $content) {
|
||||
return;
|
||||
}
|
||||
|
||||
$fullValue = $content->getTranslation('value', $this->editLocale);
|
||||
|
||||
if ($fieldKey !== null && is_array($fullValue)) {
|
||||
$fieldValue = $fullValue[$fieldKey] ?? '';
|
||||
|
||||
if (is_array($fieldValue)) {
|
||||
$this->editingId = $contentId;
|
||||
$this->editingField = $fieldKey;
|
||||
$this->editingFieldType = 'text';
|
||||
$this->jsonEditingKey = $content->key . '.' . $fieldKey;
|
||||
|
||||
$isList = array_is_list($fieldValue);
|
||||
if ($isList) {
|
||||
[$this->jsonIsStringArray, $this->jsonItems] = _cmsParseJsonItems($fieldValue);
|
||||
} else {
|
||||
$this->jsonIsStringArray = false;
|
||||
$this->jsonItems = [array_map(
|
||||
fn ($v) => is_array($v) ? json_encode($v, JSON_UNESCAPED_UNICODE) : (string) $v,
|
||||
$fieldValue,
|
||||
)];
|
||||
}
|
||||
|
||||
$this->jsonItems = CmsFluxEditorHtmlTransformer::toEditorJsonItems($this->jsonItems, $this->jsonIsStringArray);
|
||||
|
||||
$this->showJsonModal = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->editingId = $contentId;
|
||||
$this->editingField = $fieldKey;
|
||||
$this->editValue = (string) $fieldValue;
|
||||
|
||||
if (_cmsFieldLooksLikeImage($fieldKey, $fieldValue)) {
|
||||
$this->editingFieldType = 'image';
|
||||
$this->editMediaId = CmsMedia::where('filename', $this->editValue)->first()?->id;
|
||||
} elseif ($content->group === 'legal' && $fieldKey === 'content') {
|
||||
$this->editingFieldType = 'legal_html';
|
||||
$this->editMediaId = null;
|
||||
} else {
|
||||
$this->editingFieldType = is_string($fieldValue) && preg_match('/<[^>]+>/', $fieldValue) ? 'html' : 'text';
|
||||
$this->editMediaId = null;
|
||||
}
|
||||
|
||||
if ($this->editingFieldType === 'html') {
|
||||
$this->editValue = CmsFluxEditorHtmlTransformer::toEditor($this->editValue);
|
||||
}
|
||||
} else {
|
||||
$this->editingId = $contentId;
|
||||
$this->editingField = null;
|
||||
|
||||
if ($content->type === 'json') {
|
||||
$this->jsonEditingKey = $content->key;
|
||||
if (is_array($fullValue)) {
|
||||
[$this->jsonIsStringArray, $this->jsonItems] = _cmsParseJsonItems($fullValue);
|
||||
$this->jsonItems = CmsFluxEditorHtmlTransformer::toEditorJsonItems($this->jsonItems, $this->jsonIsStringArray);
|
||||
} else {
|
||||
$this->jsonIsStringArray = false;
|
||||
$this->jsonItems = [];
|
||||
}
|
||||
$this->showJsonModal = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->editValue = is_array($fullValue)
|
||||
? json_encode($fullValue, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
|
||||
: ((string) ($fullValue ?? ''));
|
||||
|
||||
if ($content->type === 'image') {
|
||||
$this->editingFieldType = 'image';
|
||||
$this->editMediaId = CmsMedia::where('filename', $this->editValue)->first()?->id;
|
||||
} elseif ($content->type === 'html') {
|
||||
$this->editingFieldType = 'html';
|
||||
$this->editMediaId = null;
|
||||
$this->editValue = CmsFluxEditorHtmlTransformer::toEditor($this->editValue);
|
||||
} else {
|
||||
$this->editingFieldType = 'text';
|
||||
$this->editMediaId = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$switchLocale = function (string $locale) {
|
||||
$this->editLocale = $locale;
|
||||
|
||||
if ($this->editingId && $this->showJsonModal) {
|
||||
$content = CmsContent::find($this->editingId);
|
||||
if (! $content) {
|
||||
return;
|
||||
}
|
||||
|
||||
$fullValue = $content->getTranslation('value', $locale);
|
||||
|
||||
if ($this->editingField !== null && is_array($fullValue)) {
|
||||
$fieldValue = $fullValue[$this->editingField] ?? [];
|
||||
if (is_array($fieldValue)) {
|
||||
[$this->jsonIsStringArray, $this->jsonItems] = _cmsParseJsonItems($fieldValue);
|
||||
$this->jsonItems = CmsFluxEditorHtmlTransformer::toEditorJsonItems($this->jsonItems, $this->jsonIsStringArray);
|
||||
}
|
||||
} elseif (is_array($fullValue)) {
|
||||
[$this->jsonIsStringArray, $this->jsonItems] = _cmsParseJsonItems($fullValue);
|
||||
$this->jsonItems = CmsFluxEditorHtmlTransformer::toEditorJsonItems($this->jsonItems, $this->jsonIsStringArray);
|
||||
}
|
||||
} elseif ($this->editingId) {
|
||||
$content = CmsContent::find($this->editingId);
|
||||
if (! $content) {
|
||||
return;
|
||||
}
|
||||
|
||||
$fullValue = $content->getTranslation('value', $locale);
|
||||
|
||||
if ($this->editingField !== null && is_array($fullValue)) {
|
||||
$fieldValue = $fullValue[$this->editingField] ?? '';
|
||||
$this->editValue = is_array($fieldValue)
|
||||
? json_encode($fieldValue, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
|
||||
: (string) $fieldValue;
|
||||
if (! is_array($fieldValue)) {
|
||||
if (_cmsFieldLooksLikeImage((string) $this->editingField, $fieldValue)) {
|
||||
$this->editingFieldType = 'image';
|
||||
$this->editMediaId = CmsMedia::where('filename', $this->editValue)->first()?->id;
|
||||
} elseif ($content->group === 'legal' && $this->editingField === 'content') {
|
||||
$this->editingFieldType = 'legal_html';
|
||||
$this->editMediaId = null;
|
||||
} else {
|
||||
$this->editingFieldType = is_string($fieldValue) && preg_match('/<[^>]+>/', $fieldValue) ? 'html' : 'text';
|
||||
$this->editMediaId = null;
|
||||
}
|
||||
if ($this->editingFieldType === 'html') {
|
||||
$this->editValue = CmsFluxEditorHtmlTransformer::toEditor($this->editValue);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$this->editValue = is_array($fullValue)
|
||||
? json_encode($fullValue, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
|
||||
: (string) ($fullValue ?? '');
|
||||
if ($content->type === 'image') {
|
||||
$this->editingFieldType = 'image';
|
||||
$this->editMediaId = CmsMedia::where('filename', $this->editValue)->first()?->id;
|
||||
} elseif ($content->type === 'html') {
|
||||
$this->editingFieldType = 'html';
|
||||
$this->editMediaId = null;
|
||||
$this->editValue = CmsFluxEditorHtmlTransformer::toEditor($this->editValue);
|
||||
} else {
|
||||
$this->editingFieldType = 'text';
|
||||
$this->editMediaId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$saveEdit = function () {
|
||||
$content = CmsContent::find($this->editingId);
|
||||
if (! $content) {
|
||||
return;
|
||||
}
|
||||
|
||||
$valueToSave = $this->editValue;
|
||||
if ($this->editingFieldType === 'html') {
|
||||
$valueToSave = CmsFluxEditorHtmlTransformer::fromEditor($valueToSave);
|
||||
}
|
||||
|
||||
if ($this->editingField !== null) {
|
||||
$fullValue = $content->getTranslation('value', $this->editLocale);
|
||||
if (! is_array($fullValue)) {
|
||||
$fullValue = [];
|
||||
}
|
||||
$fullValue[$this->editingField] = $valueToSave;
|
||||
$content->setTranslation('value', $this->editLocale, $fullValue);
|
||||
} else {
|
||||
$content->setTranslation('value', $this->editLocale, $valueToSave);
|
||||
}
|
||||
|
||||
$content->save();
|
||||
app(CmsContentService::class)->clearCache($this->selectedGroup);
|
||||
|
||||
$this->editingId = null;
|
||||
$this->editingField = null;
|
||||
$this->editValue = '';
|
||||
$this->editingFieldType = 'text';
|
||||
$this->editMediaId = null;
|
||||
|
||||
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Inhalt wurde erfolgreich aktualisiert.');
|
||||
};
|
||||
|
||||
$addJsonItem = function () {
|
||||
if ($this->jsonIsStringArray) {
|
||||
$this->jsonItems[] = ['_value' => ''];
|
||||
} elseif (! empty($this->jsonItems)) {
|
||||
$template = array_map(fn () => '', $this->jsonItems[0]);
|
||||
$this->jsonItems[] = $template;
|
||||
}
|
||||
};
|
||||
|
||||
$removeJsonItem = function (int $index) {
|
||||
unset($this->jsonItems[$index]);
|
||||
$this->jsonItems = array_values($this->jsonItems);
|
||||
};
|
||||
|
||||
$saveJsonModal = function () {
|
||||
$content = CmsContent::find($this->editingId);
|
||||
if (! $content) {
|
||||
return;
|
||||
}
|
||||
|
||||
$itemsForSave = CmsFluxEditorHtmlTransformer::fromEditorJsonItems($this->jsonItems, $this->jsonIsStringArray);
|
||||
|
||||
if ($this->jsonIsStringArray) {
|
||||
$newValue = array_values(array_map(fn ($item) => $item['_value'] ?? '', $itemsForSave));
|
||||
} else {
|
||||
$newValue = array_values(
|
||||
array_map(function ($item) {
|
||||
$cleaned = [];
|
||||
foreach ($item as $k => $v) {
|
||||
if (is_string($v) && (str_starts_with($v, '[') || str_starts_with($v, '{'))) {
|
||||
$decoded = json_decode($v, true);
|
||||
$cleaned[$k] = json_last_error() === JSON_ERROR_NONE ? $decoded : $v;
|
||||
} else {
|
||||
$cleaned[$k] = $v;
|
||||
}
|
||||
}
|
||||
|
||||
return $cleaned;
|
||||
}, $itemsForSave),
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->editingField !== null) {
|
||||
$fullValue = $content->getTranslation('value', $this->editLocale);
|
||||
if (! is_array($fullValue)) {
|
||||
$fullValue = [];
|
||||
}
|
||||
$fullValue[$this->editingField] = $newValue;
|
||||
$content->setTranslation('value', $this->editLocale, $fullValue);
|
||||
} else {
|
||||
$content->setTranslation('value', $this->editLocale, $newValue);
|
||||
}
|
||||
|
||||
$content->save();
|
||||
app(CmsContentService::class)->clearCache($this->selectedGroup);
|
||||
|
||||
$this->showJsonModal = false;
|
||||
$this->editingId = null;
|
||||
$this->editingField = null;
|
||||
$this->jsonItems = [];
|
||||
|
||||
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Inhalt wurde erfolgreich aktualisiert.');
|
||||
};
|
||||
|
||||
$cancelEdit = function () {
|
||||
$this->editingId = null;
|
||||
$this->editingField = null;
|
||||
$this->editValue = '';
|
||||
$this->editingFieldType = 'text';
|
||||
$this->editMediaId = null;
|
||||
};
|
||||
|
||||
$cancelJsonModal = function () {
|
||||
$this->showJsonModal = false;
|
||||
$this->editingId = null;
|
||||
$this->editingField = null;
|
||||
$this->jsonItems = [];
|
||||
};
|
||||
|
||||
?>
|
||||
|
||||
@php
|
||||
$cmsGroupLabels = [
|
||||
'legal' => 'Rechtliches',
|
||||
];
|
||||
@endphp
|
||||
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<flux:heading size="xl">Inhalte verwalten</flux:heading>
|
||||
<div class="flex items-center gap-2">
|
||||
<flux:badge color="blue">{{ array_sum($this->groups) }} Einträge</flux:badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-4">
|
||||
<div class="lg:col-span-1">
|
||||
<flux:card>
|
||||
<flux:heading size="sm" class="mb-3">Seiten / Gruppen</flux:heading>
|
||||
<div class="flex flex-col gap-1">
|
||||
@foreach ($this->groups as $group => $count)
|
||||
<button wire:click="selectGroup('{{ $group }}')"
|
||||
class="flex items-center justify-between rounded-lg px-3 py-2 text-sm transition-colors {{ $selectedGroup === $group ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' : 'hover:bg-zinc-100 dark:hover:bg-zinc-700' }}">
|
||||
<span>{{ $cmsGroupLabels[$group] ?? $group }}</span>
|
||||
<flux:badge size="sm">{{ $count }}</flux:badge>
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-3" wire:key="cms-content-panel-{{ $selectedGroup ?? 'empty' }}">
|
||||
@if ($selectedGroup)
|
||||
<flux:card>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<flux:heading size="lg">{{ $cmsGroupLabels[$selectedGroup] ?? $selectedGroup }}</flux:heading>
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:input wire:model.live.debounce.300ms="search" placeholder="Suchen..." size="sm"
|
||||
icon="magnifying-glass" class="w-48" />
|
||||
<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>
|
||||
|
||||
@php $lastSection = null; @endphp
|
||||
<div class="divide-y dark:divide-zinc-700">
|
||||
@forelse ($this->flatContents as $row)
|
||||
@if ($row->section_key !== $lastSection)
|
||||
@php $lastSection = $row->section_key; @endphp
|
||||
<div class="bg-zinc-50 dark:bg-zinc-800/50 px-3 py-2">
|
||||
<span class="text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">{{ $row->section_key }}</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@php
|
||||
$isEditing = $editingId === $row->content_id
|
||||
&& $editingField === $row->field_key
|
||||
&& ! $showJsonModal;
|
||||
$editKey = $row->field_key !== null
|
||||
? $row->content_id . ",'" . $row->field_key . "'"
|
||||
: $row->content_id . ',null';
|
||||
@endphp
|
||||
|
||||
<div wire:key="row-{{ $row->content_id }}-{{ $row->field_key ?? 'root' }}" class="py-3 px-1">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="mb-1 flex items-center gap-2">
|
||||
<code class="rounded bg-zinc-100 px-1.5 py-0.5 text-xs text-zinc-500 dark:bg-zinc-700 dark:text-zinc-400">{{ $row->field_key ?? $row->section_key }}</code>
|
||||
@php
|
||||
$typeColors = ['html' => 'amber', 'legal_html' => 'blue', 'image' => 'green', 'json' => 'violet', 'link' => 'rose'];
|
||||
$badgeColor = $typeColors[$row->type] ?? 'zinc';
|
||||
@endphp
|
||||
<flux:badge size="sm" :color="$badgeColor">{{ $row->type }}</flux:badge>
|
||||
</div>
|
||||
|
||||
<div
|
||||
wire:key="field-value-{{ $row->content_id }}-{{ $row->field_key ?? 'root' }}-{{ $isEditing ? 'edit' : 'view' }}"
|
||||
>
|
||||
@if ($isEditing)
|
||||
<div class="mt-2">
|
||||
@if ($editingFieldType === 'image')
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start">
|
||||
@if ($editValue)
|
||||
<div class="h-24 w-36 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($editValue) }}" alt="" class="h-full w-full object-cover" />
|
||||
</div>
|
||||
@endif
|
||||
<div class="min-w-0 flex-1 space-y-2">
|
||||
<livewire:admin.cms.media-picker
|
||||
:value="$editMediaId"
|
||||
field="content_image"
|
||||
type="image"
|
||||
profile="card"
|
||||
label="Bild aus Medienbibliothek wählen"
|
||||
:key="'cms-content-img-' . $selectedGroup . '-' . $editingId . '-' . ($editingField ?? 'root') . '-' . $editLocale"
|
||||
/>
|
||||
<flux:input wire:model="editValue" size="sm" label="Dateiname (optional manuell)" placeholder="z. B. b2in/hero.jpg" />
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($editingFieldType === 'html')
|
||||
<flux:editor wire:model="editValue" toolbar="bold italic highlight"
|
||||
class="**:data-[slot=content]:min-h-[60px]!" />
|
||||
@elseif ($editingFieldType === 'legal_html')
|
||||
{{-- natives textarea, Inhalt nur per Livewire-JS (kein </textarea> im HTML); verhindert DOM-Bruch bei Impressum-HTML --}}
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium text-zinc-700 dark:text-zinc-300" for="legal-html-{{ $row->content_id }}-{{ $row->field_key ?? 'root' }}">HTML (Rohtext)</label>
|
||||
<textarea
|
||||
id="legal-html-{{ $row->content_id }}-{{ $row->field_key ?? 'root' }}"
|
||||
wire:model.defer="editValue"
|
||||
rows="28"
|
||||
spellcheck="false"
|
||||
class="block min-h-[24rem] w-full resize-y rounded-lg border border-zinc-200 bg-white p-3 font-mono text-xs text-zinc-800 shadow-xs dark:border-white/10 dark:bg-white/10 dark:text-zinc-200"
|
||||
></textarea>
|
||||
</div>
|
||||
@else
|
||||
<flux:textarea wire:model="editValue" rows="8" class="font-mono text-sm" />
|
||||
@endif
|
||||
<div class="mt-2 flex gap-2">
|
||||
<flux:button size="sm" variant="primary" wire:click="saveEdit" wire:loading.attr="disabled" wire:target="saveEdit">
|
||||
<span wire:loading.remove wire:target="saveEdit">Speichern</span>
|
||||
<span wire:loading wire:target="saveEdit">Speichern…</span>
|
||||
</flux:button>
|
||||
<flux:button size="sm" variant="ghost" wire:click="cancelEdit" wire:loading.attr="disabled" wire:target="saveEdit">Abbrechen</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
@if ($row->type === 'json' && is_array($row->value))
|
||||
@php
|
||||
$isList = array_is_list($row->value);
|
||||
$cnt = count($row->value);
|
||||
@endphp
|
||||
<div class="flex items-center gap-2 text-sm text-zinc-500">
|
||||
<flux:badge size="sm" color="violet">{{ $cnt }} {{ $isList ? 'Einträge' : 'Felder' }}</flux:badge>
|
||||
@if ($isList && $cnt > 0 && is_array($row->value[0] ?? null))
|
||||
<span class="text-xs text-zinc-400">{{ implode(', ', array_keys($row->value[0])) }}</span>
|
||||
@elseif (! $isList)
|
||||
<span class="text-xs text-zinc-400">{{ implode(', ', array_slice(array_keys($row->value), 0, 5)) }}{{ $cnt > 5 ? ', …' : '' }}</span>
|
||||
@endif
|
||||
</div>
|
||||
@elseif ($row->type === 'image' && is_string($row->value) && $row->value !== '')
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-14 w-24 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($row->value) }}" alt="" class="h-full w-full object-cover" loading="lazy" />
|
||||
</div>
|
||||
<span class="truncate text-xs text-zinc-500">{{ $row->value }}</span>
|
||||
</div>
|
||||
@elseif ($row->type === 'legal_html')
|
||||
<p class="line-clamp-3 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{{ \Illuminate\Support\Str::limit(strip_tags((string) $row->value), 200) }}
|
||||
</p>
|
||||
@elseif ($row->type === 'html')
|
||||
<div class="prose prose-sm max-w-none text-zinc-800 dark:prose-invert dark:text-zinc-200 line-clamp-2">
|
||||
{!! \Illuminate\Support\Str::limit((string) $row->value, 200) !!}
|
||||
</div>
|
||||
@else
|
||||
<p class="truncate text-sm text-zinc-800 dark:text-zinc-200">
|
||||
{{ \Illuminate\Support\Str::limit(strip_tags(is_array($row->value) ? json_encode($row->value, JSON_UNESCAPED_UNICODE) : (string) ($row->value ?? '')), 120) }}
|
||||
</p>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (! $isEditing)
|
||||
<flux:button size="sm" variant="ghost" icon="pencil"
|
||||
wire:click="startFieldEdit({{ $editKey }})" />
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<flux:text class="py-8 text-center">Keine Einträge gefunden.</flux:text>
|
||||
@endforelse
|
||||
</div>
|
||||
</flux:card>
|
||||
@else
|
||||
<flux:card>
|
||||
<div class="py-12 text-center">
|
||||
<flux:icon name="document-text" class="mx-auto mb-4 h-12 w-12 text-zinc-400" />
|
||||
<flux:heading>Seite auswählen</flux:heading>
|
||||
<flux:text>Wähle links eine Seite/Gruppe aus, um deren Inhalte zu bearbeiten.</flux:text>
|
||||
</div>
|
||||
</flux:card>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- JSON-Editor: kein flux:modal — vermeidet ReferenceError fluxModal bei jedem Livewire-Render (z. B. bei legal_html). --}}
|
||||
@if ($showJsonModal)
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6"
|
||||
wire:key="cms-json-editor-overlay-{{ $selectedGroup ?? 'none' }}"
|
||||
x-data
|
||||
x-on:keydown.escape.window.prevent="$wire.cancelJsonModal()"
|
||||
>
|
||||
<div class="fixed inset-0 bg-zinc-950/50 backdrop-blur-sm" wire:click="cancelJsonModal" aria-hidden="true">
|
||||
</div>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
class="relative z-10 flex w-full max-w-5xl max-h-[90vh] flex-col overflow-y-auto space-y-6 rounded-xl border border-zinc-200 bg-white p-6 shadow-xl dark:border-zinc-700 dark:bg-zinc-800"
|
||||
wire:click.stop
|
||||
>
|
||||
<div>
|
||||
<flux:heading size="lg">{{ $jsonEditingKey }}</flux:heading>
|
||||
<flux:text class="mt-1">
|
||||
{{ $jsonIsStringArray ? 'Einfache Liste' : 'Strukturierte Einträge' }}
|
||||
({{ count($jsonItems) }} Einträge) — {{ strtoupper($editLocale) }}
|
||||
</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
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
@foreach ($jsonItems as $idx => $item)
|
||||
<div wire:key="json-item-{{ $idx }}"
|
||||
class="rounded-lg border border-zinc-200 dark:border-zinc-700 p-4">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<span class="text-xs font-semibold text-zinc-500">Eintrag {{ $idx + 1 }}</span>
|
||||
<flux:button size="xs" variant="ghost" icon="trash"
|
||||
wire:click="removeJsonItem({{ $idx }})"
|
||||
wire:confirm="Eintrag {{ $idx + 1 }} wirklich entfernen?" />
|
||||
</div>
|
||||
|
||||
@if ($jsonIsStringArray)
|
||||
<flux:input wire:model="jsonItems.{{ $idx }}._value" placeholder="Wert" />
|
||||
@else
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
@foreach ($item as $field => $fieldValue)
|
||||
@php
|
||||
$isIcon = in_array($field, ['icon']);
|
||||
$isRichText = in_array($field, ['description', 'text', 'content', 'help', 'answer', 'quote']);
|
||||
$isNestedJson = is_string($fieldValue) && (str_starts_with($fieldValue, '[') || str_starts_with($fieldValue, '{'));
|
||||
$isImageField = _cmsFieldLooksLikeImage($field, $fieldValue);
|
||||
$jsonImageMediaId = is_string($fieldValue) && $fieldValue !== '' ? \FluxCms\Core\Models\CmsMedia::where('filename', $fieldValue)->first()?->id : null;
|
||||
@endphp
|
||||
|
||||
@if ($isIcon)
|
||||
<div class="md:col-span-2">
|
||||
<div class="flex items-end gap-3">
|
||||
<div class="flex-1">
|
||||
<flux:select wire:model="jsonItems.{{ $idx }}.{{ $field }}"
|
||||
variant="listbox" searchable label="{{ ucfirst($field) }}"
|
||||
placeholder="Icon auswählen...">
|
||||
<flux:select.option value="">— Kein Icon —</flux:select.option>
|
||||
@foreach ($this->availableIcons as $iconName)
|
||||
<flux:select.option value="{{ $iconName }}">{{ $iconName }}</flux:select.option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
</div>
|
||||
@if (! empty($fieldValue))
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-zinc-200 dark:border-zinc-700">
|
||||
<x-dynamic-component :component="'heroicon-o-' . $fieldValue" class="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($isImageField)
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300">{{ ucfirst($field) }}</label>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start">
|
||||
@if (is_string($fieldValue) && $fieldValue !== '')
|
||||
<div class="h-20 w-32 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($fieldValue) }}" alt="" class="h-full w-full object-cover" />
|
||||
</div>
|
||||
@endif
|
||||
<div class="min-w-0 flex-1 space-y-2">
|
||||
<livewire:admin.cms.media-picker
|
||||
:value="$jsonImageMediaId"
|
||||
:field="'jsonimg:' . $idx . ':' . $field"
|
||||
type="image"
|
||||
profile="card"
|
||||
label="Bild wählen"
|
||||
:key="'json-img-' . ($selectedGroup ?? '') . '-' . $jsonEditingKey . '-' . $idx . '-' . $field . '-' . $editLocale"
|
||||
/>
|
||||
<flux:input wire:model="jsonItems.{{ $idx }}.{{ $field }}" size="sm" placeholder="Dateiname (optional manuell)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($isRichText)
|
||||
<div class="md:col-span-2">
|
||||
<flux:editor wire:model="jsonItems.{{ $idx }}.{{ $field }}"
|
||||
label="{{ ucfirst($field) }}" toolbar="bold italic highlight"
|
||||
class="**:data-[slot=content]:min-h-[60px]!" />
|
||||
</div>
|
||||
@elseif ($isNestedJson)
|
||||
<div class="md:col-span-2">
|
||||
<flux:textarea wire:model="jsonItems.{{ $idx }}.{{ $field }}"
|
||||
label="{{ ucfirst($field) }} (JSON)" rows="3"
|
||||
class="font-mono text-xs" />
|
||||
</div>
|
||||
@else
|
||||
<flux:input wire:model="jsonItems.{{ $idx }}.{{ $field }}"
|
||||
label="{{ ucfirst($field) }}" />
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between border-t border-zinc-200 dark:border-zinc-700 pt-4">
|
||||
<flux:button size="sm" variant="ghost" icon="plus" wire:click="addJsonItem">
|
||||
Eintrag hinzufügen
|
||||
</flux:button>
|
||||
<div class="flex gap-2">
|
||||
<flux:button variant="ghost" wire:click="cancelJsonModal">Abbrechen</flux:button>
|
||||
<flux:button variant="primary" wire:click="saveJsonModal">Speichern</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
156
resources/views/livewire/admin/cms/dashboard.blade.php
Normal file
156
resources/views/livewire/admin/cms/dashboard.blade.php
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
<?php
|
||||
|
||||
use App\Models\CmsArticle;
|
||||
use App\Models\CmsProject;
|
||||
use FluxCms\Core\Models\CmsContent;
|
||||
use FluxCms\Core\Models\CmsMedia;
|
||||
use function Livewire\Volt\{layout, title, computed};
|
||||
|
||||
layout('components.layouts.app');
|
||||
title('CMS Dashboard');
|
||||
|
||||
$stats = computed(fn () => [
|
||||
'contents' => CmsContent::count(),
|
||||
'groups' => CmsContent::distinct()->pluck('group')->count(),
|
||||
'projects' => CmsProject::count(),
|
||||
'projects_published' => CmsProject::published()->count(),
|
||||
'articles' => CmsArticle::count(),
|
||||
'articles_published' => CmsArticle::published()->count(),
|
||||
'media' => CmsMedia::count(),
|
||||
]);
|
||||
|
||||
?>
|
||||
|
||||
<div>
|
||||
<div class="mb-6">
|
||||
<flux:heading size="xl">CMS</flux:heading>
|
||||
<flux:text class="mt-1">Inhalte, Projekte und Medien verwalten.</flux:text>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<a href="{{ route('cms.content.index') }}" wire:navigate>
|
||||
<flux:card class="hover:border-blue-500 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:icon name="document-text" class="text-blue-500" />
|
||||
<div>
|
||||
<flux:heading size="lg">{{ $this->stats['contents'] }}</flux:heading>
|
||||
<flux:text class="text-sm">Inhalte in {{ $this->stats['groups'] }} Gruppen</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('cms.projects.index') }}" wire:navigate>
|
||||
<flux:card class="hover:border-emerald-500 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:icon name="building-office" class="text-emerald-500" />
|
||||
<div>
|
||||
<flux:heading size="lg">{{ $this->stats['projects'] }}</flux:heading>
|
||||
<flux:text class="text-sm">Projekte ({{ $this->stats['projects_published'] }} veröffentlicht)</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('cms.articles.index') }}" wire:navigate>
|
||||
<flux:card class="hover:border-amber-500 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:icon name="newspaper" class="text-amber-500" />
|
||||
<div>
|
||||
<flux:heading size="lg">{{ $this->stats['articles'] }}</flux:heading>
|
||||
<flux:text class="text-sm">Magazin-Artikel ({{ $this->stats['articles_published'] }} veröffentlicht)</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('cms.media.index') }}" wire:navigate>
|
||||
<flux:card class="hover:border-violet-500 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:icon name="photo" class="text-violet-500" />
|
||||
<div>
|
||||
<flux:heading size="lg">{{ $this->stats['media'] }}</flux:heading>
|
||||
<flux:text class="text-sm">Medien</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<flux:card class="mt-6 max-w-4xl">
|
||||
<flux:heading size="lg" class="mb-4">So funktioniert das CMS</flux:heading>
|
||||
<div class="space-y-5 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
|
||||
<div>
|
||||
<flux:heading size="sm" class="mb-1 flex items-center gap-2">
|
||||
<flux:icon name="squares-2x2" class="size-4 text-zinc-500" />
|
||||
Aufbau & Navigation
|
||||
</flux:heading>
|
||||
<p>
|
||||
Das CMS ist die zentrale Stelle, um alle Inhalte der öffentlichen Website zu pflegen.
|
||||
Es gliedert sich in vier Module, die Sie über die Kacheln oben erreichen:
|
||||
</p>
|
||||
<ul class="mt-2 ml-5 list-disc space-y-1">
|
||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Inhalte</strong> – Texte, Abschnitte und strukturierte Daten der Website (z. B. Startseite, Netzwerk, rechtliche Texte). Inhalte sind in <em>Gruppen</em> organisiert, sodass zusammengehörige Felder gebündelt bearbeitet werden können.</li>
|
||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Projekte</strong> – Immobilien-Referenzobjekte mit Bildern, Preisen und Beschreibungen. Jedes Projekt kann als Entwurf angelegt und später veröffentlicht werden.</li>
|
||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Magazin</strong> – Redaktionelle Artikel (z. B. über Dubai-Immobilien, Supply-Chain oder Einrichtung). Artikel unterstützen Kategorien, Autoren und Lesezeiten.</li>
|
||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Medien</strong> – Zentrale Bild- und Dateiverwaltung. Alle Uploads landen hier und können in jedem Modul wiederverwendet werden.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<flux:separator />
|
||||
|
||||
<div>
|
||||
<flux:heading size="sm" class="mb-1 flex items-center gap-2">
|
||||
Medienbibliothek & Bildfelder
|
||||
</flux:heading>
|
||||
<p>
|
||||
Jede Datei, die auf der Website erscheint, wird in der <strong class="font-medium text-zinc-800 dark:text-zinc-200">Medienbibliothek</strong> verwaltet.
|
||||
Sie haben zwei Wege, Dateien hochzuladen:
|
||||
</p>
|
||||
<ol class="mt-2 ml-5 list-decimal space-y-1">
|
||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Vorab</strong> – Im Modul „Medien" Dateien hochladen, mit Titel und Alt-Text versehen und organisieren.</li>
|
||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Im Kontext</strong> – Beim Bearbeiten eines Inhalts, Projekts oder Artikels auf „Bild auswählen" klicken. Im Auswahldialog können Sie ein bestehendes Medium wählen <em>oder</em> per Drag-and-drop direkt hochladen. Dieser Schnell-Upload wird automatisch in der Medienbibliothek gespeichert.</li>
|
||||
</ol>
|
||||
<p class="mt-2">
|
||||
Egal welchen Weg Sie wählen: Es gibt immer einen zentralen Bibliothekseintrag. So behalten Sie den Überblick und können dasselbe Bild an mehreren Stellen einsetzen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<flux:separator />
|
||||
|
||||
<div>
|
||||
<flux:heading size="sm" class="mb-1 flex items-center gap-2">
|
||||
<flux:icon name="document-text" class="size-4 text-blue-500" />
|
||||
Inhalte bearbeiten
|
||||
</flux:heading>
|
||||
<p>
|
||||
Inhalte sind nach <strong class="font-medium text-zinc-800 dark:text-zinc-200">Gruppen</strong> geordnet – z. B. <em>homepage</em>, <em>netzwerk</em>, <em>legal</em>.
|
||||
Innerhalb einer Gruppe sehen Sie die einzelnen Felder (Texte, Rich-Text, Bilder oder JSON-Strukturen).
|
||||
</p>
|
||||
<ul class="mt-2 ml-5 list-disc space-y-1">
|
||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Mehrsprachigkeit:</strong> Wo mehrere Sprachen vorgesehen sind, wählen Sie die gewünschte Sprache (DE / EN) im Editor. Jede Sprachversion wird einzeln gespeichert.</li>
|
||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Sofort live:</strong> Gespeicherte Änderungen erscheinen auf der öffentlichen Website, sobald die jeweilige Seite den entsprechenden CMS-Key einbindet – ein zusätzliches „Veröffentlichen" ist nicht nötig.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<flux:separator />
|
||||
|
||||
<div>
|
||||
<flux:heading size="sm" class="mb-1 flex items-center gap-2">
|
||||
<flux:icon name="building-office" class="size-4 text-emerald-500" />
|
||||
Projekte & Magazin verwalten
|
||||
</flux:heading>
|
||||
<p>
|
||||
Projekte und Magazin-Artikel sind eigenständige Einträge mit eigenem Lebenszyklus:
|
||||
</p>
|
||||
<ul class="mt-2 ml-5 list-disc space-y-1">
|
||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Entwurf / Veröffentlicht:</strong> Neue Einträge starten als Entwurf. Erst nach dem Veröffentlichen sind sie auf der Website sichtbar. Sie können Einträge jederzeit wieder auf „Entwurf" setzen, um sie temporär auszublenden.</li>
|
||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Reihenfolge:</strong> Über das Sortierfeld steuern Sie, in welcher Reihenfolge Projekte und Artikel auf der Website erscheinen (z. B. Portfolio-Listen, Magazin-Übersicht).</li>
|
||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Bilder:</strong> Projekt- und Artikelbilder werden über die Medienauswahl zugeordnet (siehe oben).</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
294
resources/views/livewire/admin/cms/display-dashboard.blade.php
Normal file
294
resources/views/livewire/admin/cms/display-dashboard.blade.php
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\DisplayVersionType;
|
||||
use App\Models\CabinetTabletSetting;
|
||||
use App\Models\Display;
|
||||
use App\Models\DisplayMedia;
|
||||
use App\Models\DisplayVersion;
|
||||
use App\Models\DisplayVersionItem;
|
||||
use function Livewire\Volt\{layout, title, computed};
|
||||
|
||||
layout('components.layouts.app');
|
||||
title('Store Displays');
|
||||
|
||||
$stats = computed(fn () => [
|
||||
'displays' => Display::count(),
|
||||
'displays_active' => Display::where('is_active', true)->count(),
|
||||
'versions' => DisplayVersion::count(),
|
||||
'versions_active' => DisplayVersion::active()->count(),
|
||||
'items' => DisplayVersionItem::count(),
|
||||
'items_active' => DisplayVersionItem::where('is_active', true)->count(),
|
||||
'type_video' => DisplayVersion::ofType(DisplayVersionType::VideoDisplay)->count(),
|
||||
'type_b2in' => DisplayVersion::ofType(DisplayVersionType::B2in)->count(),
|
||||
'type_offers' => DisplayVersion::ofType(DisplayVersionType::Offers)->count(),
|
||||
'media_total' => DisplayMedia::count(),
|
||||
'media_uploads' => DisplayMedia::uploads()->count(),
|
||||
'media_externals' => DisplayMedia::externals()->count(),
|
||||
]);
|
||||
|
||||
$tabletStatus = computed(function () {
|
||||
try {
|
||||
$settings = CabinetTabletSetting::current();
|
||||
|
||||
return $settings->computeStatus()['status'];
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
?>
|
||||
|
||||
<div>
|
||||
<div class="mb-6">
|
||||
<flux:heading size="xl">Store Displays</flux:heading>
|
||||
<flux:text class="mt-1">Displays, Inhalts-Versionen und Info-Tablet im Cabinet Showroom verwalten.</flux:text>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<a href="{{ route('admin.cms.display-media') }}" wire:navigate>
|
||||
<flux:card class="hover:border-violet-500 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:icon name="photo" class="text-violet-500" />
|
||||
<div>
|
||||
<flux:heading size="lg">{{ $this->stats['media_total'] }}</flux:heading>
|
||||
<flux:text class="text-sm">Medien ({{ $this->stats['media_uploads'] }} Uploads, {{ $this->stats['media_externals'] }} extern)</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('admin.cms.display-versions') }}" wire:navigate>
|
||||
<flux:card class="hover:border-purple-500 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:icon name="rectangle-group" class="text-purple-500" />
|
||||
<div>
|
||||
<flux:heading size="lg">{{ $this->stats['versions'] }}</flux:heading>
|
||||
<flux:text class="text-sm">Versionen ({{ $this->stats['versions_active'] }} aktiv)</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('admin.cms.displays') }}" wire:navigate>
|
||||
<flux:card class="hover:border-blue-500 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:icon name="tv" class="text-blue-500" />
|
||||
<div>
|
||||
<flux:heading size="lg">{{ $this->stats['displays'] }}</flux:heading>
|
||||
<flux:text class="text-sm">Displays ({{ $this->stats['displays_active'] }} aktiv)</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('admin.cms.cabinet-tablet') }}" wire:navigate>
|
||||
<flux:card class="hover:border-teal-500 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:icon name="device-tablet" class="text-teal-500" />
|
||||
<div>
|
||||
<flux:heading size="lg">Info-Tablet</flux:heading>
|
||||
<flux:text class="text-sm">
|
||||
@if($this->tabletStatus)
|
||||
Status:
|
||||
<flux:badge size="sm" :color="match($this->tabletStatus) {
|
||||
'open' => 'green',
|
||||
'closed' => 'red',
|
||||
'notice' => 'amber',
|
||||
'warning' => 'orange',
|
||||
default => 'zinc',
|
||||
}">
|
||||
{{ match($this->tabletStatus) {
|
||||
'open' => 'Geöffnet',
|
||||
'closed' => 'Geschlossen',
|
||||
'notice' => 'Hinweis',
|
||||
'warning' => 'Warnung',
|
||||
default => $this->tabletStatus,
|
||||
} }}
|
||||
</flux:badge>
|
||||
@else
|
||||
Öffnungszeiten & Status
|
||||
@endif
|
||||
</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
</a>
|
||||
|
||||
<flux:card>
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:icon name="queue-list" class="text-zinc-400" />
|
||||
<div>
|
||||
<flux:heading size="lg">{{ $this->stats['items'] }}</flux:heading>
|
||||
<flux:text class="text-sm">Inhalte gesamt ({{ $this->stats['items_active'] }} aktiv)</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
||||
{{-- Versions-Typen Übersicht --}}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3 mt-4">
|
||||
<flux:card>
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:icon name="film" class="text-purple-400" />
|
||||
<div>
|
||||
<flux:text class="text-sm font-medium text-zinc-800 dark:text-zinc-200">Video-Display</flux:text>
|
||||
<flux:text class="text-xs">{{ $this->stats['type_video'] }} {{ $this->stats['type_video'] === 1 ? 'Version' : 'Versionen' }}</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
<flux:card>
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:icon name="photo" class="text-blue-400" />
|
||||
<div>
|
||||
<flux:text class="text-sm font-medium text-zinc-800 dark:text-zinc-200">B2in Display</flux:text>
|
||||
<flux:text class="text-xs">{{ $this->stats['type_b2in'] }} {{ $this->stats['type_b2in'] === 1 ? 'Version' : 'Versionen' }}</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
<flux:card>
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:icon name="tag" class="text-amber-400" />
|
||||
<div>
|
||||
<flux:text class="text-sm font-medium text-zinc-800 dark:text-zinc-200">Angebote</flux:text>
|
||||
<flux:text class="text-xs">{{ $this->stats['type_offers'] }} {{ $this->stats['type_offers'] === 1 ? 'Version' : 'Versionen' }}</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
||||
{{-- Beschreibung --}}
|
||||
<flux:card class="mt-6 max-w-4xl">
|
||||
<flux:heading size="lg" class="mb-4">So funktioniert das Display-System</flux:heading>
|
||||
<div class="space-y-5 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
|
||||
<div>
|
||||
<flux:heading size="sm" class="mb-1 flex items-center gap-2">
|
||||
<flux:icon name="squares-2x2" class="size-4 text-zinc-500" />
|
||||
Überblick
|
||||
</flux:heading>
|
||||
<p>
|
||||
Das Display-System steuert alle Bildschirme im Cabinet Showroom Bielefeld.
|
||||
Es besteht aus drei Bereichen, die Sie über die Kacheln oben erreichen:
|
||||
</p>
|
||||
<ul class="mt-2 ml-5 list-disc space-y-1">
|
||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Mediathek</strong> – Zentrale Verwaltung aller Bilder und Videos fuer die Displays. Dateien bis 50 MB direkt hochladen oder groessere Videos als externe URL (Google Drive, OneDrive) einbinden.</li>
|
||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Versionen</strong> – Content-Pakete, die auf den Displays abgespielt werden. Jede Version hat einen bestimmten Typ und enthält passende Inhalte (Videos, Bilder oder Angebots-Slides).</li>
|
||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Displays</strong> – Die physischen Bildschirme im Showroom. Jedem Display werden eine oder mehrere Versionen als Playlist zugewiesen.</li>
|
||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Info-Tablet</strong> – Das Tablet an der Eingangstür des Showrooms. Hier verwalten Sie Öffnungszeiten, den aktuellen Store-Status und Hinweise für Besucher.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<flux:separator />
|
||||
|
||||
<div>
|
||||
<flux:heading size="sm" class="mb-1 flex items-center gap-2">
|
||||
<flux:icon name="photo" class="size-4 text-violet-500" />
|
||||
Mediathek
|
||||
</flux:heading>
|
||||
<p>
|
||||
Die <strong class="font-medium text-zinc-800 dark:text-zinc-200">Display-Mediathek</strong> verwaltet alle Bilder und Videos, die auf den Displays im Showroom angezeigt werden.
|
||||
Sie ist unabhängig von der Website-Mediathek (Flux CMS) und speziell auf die Anforderungen der Displays zugeschnitten.
|
||||
</p>
|
||||
<ul class="mt-2 ml-5 list-disc space-y-1">
|
||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Direkt-Upload:</strong> Bilder und Videos bis 50 MB direkt per Drag-and-drop oder Dateiauswahl hochladen. Die Dateien werden auf dem Server gespeichert und stehen sofort zur Verfügung.</li>
|
||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Externe URLs:</strong> Für Videos über 50 MB (z. B. 4K-Showroom-Rundgänge) können Sie einen Freigabe-Link von Google Drive, OneDrive oder anderen Cloud-Diensten hinterlegen. Diese URL wird wie ein normales Medium in der Mediathek verwaltet und kann genauso in Versionen eingebunden werden.</li>
|
||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Sammlungen:</strong> Ordnen Sie Medien in Sammlungen wie <em>immobilien</em>, <em>moebel</em> oder <em>brand</em>, um bei vielen Dateien den Überblick zu behalten.</li>
|
||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Medienauswahl im Editor:</strong> Beim Bearbeiten einer Version erscheint ein „Aus Mediathek"-Button. Darüber öffnen Sie die Medienauswahl und können bestehende Medien wählen oder direkt neue hochladen.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<flux:separator />
|
||||
|
||||
<div>
|
||||
<flux:heading size="sm" class="mb-1 flex items-center gap-2">
|
||||
<flux:icon name="rectangle-group" class="size-4 text-purple-500" />
|
||||
Versionen & Versions-Typen
|
||||
</flux:heading>
|
||||
<p>
|
||||
Eine <strong class="font-medium text-zinc-800 dark:text-zinc-200">Version</strong> ist ein Content-Paket mit einem bestimmten Typ.
|
||||
Der Typ bestimmt, welche Art von Inhalten hinzugefügt werden können:
|
||||
</p>
|
||||
<ul class="mt-2 ml-5 list-disc space-y-1">
|
||||
<li>
|
||||
<strong class="font-medium text-zinc-800 dark:text-zinc-200">Video-Display</strong> –
|
||||
Für Video-Playlists mit optionalem Footer. Inhalte: <em>Videos</em> (Dateiname, Titel, Position/Ausschnitt) und <em>Footer-Zeilen</em> (Überschrift, Unterzeile, optionaler QR-Code-Link).
|
||||
</li>
|
||||
<li>
|
||||
<strong class="font-medium text-zinc-800 dark:text-zinc-200">B2in Display</strong> –
|
||||
Für Medien-Rotation (Bilder und Videos) im Marken-Design. Inhalte: <em>Media-Items</em> mit Kategorie (Immobilien / Möbel), Überschrift, Unterzeile und Anzeigedauer. Unterstützt Light-/Dark-Theme.
|
||||
</li>
|
||||
<li>
|
||||
<strong class="font-medium text-zinc-800 dark:text-zinc-200">Angebote</strong> –
|
||||
Für Produkt-Slides im Angebotsformat. Verschiedene Slide-Layouts: Intro, Produkt-Hero, Produkt-Details und Impuls-Slides mit Preisen, Badges und QR-Codes.
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mt-2">
|
||||
Innerhalb einer Version können Sie beliebig viele Inhalte anlegen, die Reihenfolge per Hoch/Runter-Sortierung festlegen und einzelne Einträge aktivieren oder deaktivieren.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<flux:separator />
|
||||
|
||||
<div>
|
||||
<flux:heading size="sm" class="mb-1 flex items-center gap-2">
|
||||
<flux:icon name="tv" class="size-4 text-blue-500" />
|
||||
Displays & Playlists
|
||||
</flux:heading>
|
||||
<p>
|
||||
Ein <strong class="font-medium text-zinc-800 dark:text-zinc-200">Display</strong> repräsentiert einen physischen Bildschirm im Showroom.
|
||||
</p>
|
||||
<ul class="mt-2 ml-5 list-disc space-y-1">
|
||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Versions-Zuweisung:</strong> Jedem Display können Sie eine oder mehrere Versionen zuordnen. Die Versionen werden in der festgelegten Reihenfolge als Playlist abgespielt.</li>
|
||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Aktiv/Inaktiv:</strong> Über den Aktiv-Status können Sie einzelne Displays vorübergehend deaktivieren, ohne die Konfiguration zu verlieren.</li>
|
||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">API-Anbindung:</strong> Jedes Display ruft seine Inhalte über eine JSON-API ab (<code class="text-xs bg-zinc-100 dark:bg-zinc-800 px-1 py-0.5 rounded">/api/display/{id}/config</code>). Änderungen werden beim nächsten Abruf automatisch übernommen.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<flux:separator />
|
||||
|
||||
<div>
|
||||
<flux:heading size="sm" class="mb-1 flex items-center gap-2">
|
||||
<flux:icon name="device-tablet" class="size-4 text-teal-500" />
|
||||
Info-Tablet
|
||||
</flux:heading>
|
||||
<p>
|
||||
Das Info-Tablet zeigt Besuchern am Showroom-Eingang den aktuellen Status und die Öffnungszeiten.
|
||||
</p>
|
||||
<ul class="mt-2 ml-5 list-disc space-y-1">
|
||||
<li>
|
||||
<strong class="font-medium text-zinc-800 dark:text-zinc-200">Store-Status:</strong>
|
||||
Vier Modi stehen zur Verfügung – <em>Automatisch</em> (berechnet den Status aus den Öffnungszeiten), <em>Geschlossen</em> (manuell), <em>Hinweis</em> (eigene Nachricht) und <em>Warnung</em> (dringende Nachricht).
|
||||
</li>
|
||||
<li>
|
||||
<strong class="font-medium text-zinc-800 dark:text-zinc-200">Öffnungszeiten:</strong>
|
||||
Für jeden Wochentag (Montag–Sonntag) können individuelle Öffnungs- und Schließzeiten gepflegt werden. Tage ohne Zeiten gelten als geschlossen.
|
||||
</li>
|
||||
<li>
|
||||
<strong class="font-medium text-zinc-800 dark:text-zinc-200">Tages-Overrides:</strong>
|
||||
Für Sonderfälle (z. B. früher schließen) können Sie die Zeiten für den heutigen Tag überschreiben. Diese Überschreibungen werden automatisch um Mitternacht zurückgesetzt.
|
||||
</li>
|
||||
<li>
|
||||
<strong class="font-medium text-zinc-800 dark:text-zinc-200">Kontaktdaten & Termine:</strong>
|
||||
Telefonnummer, E-Mail-Adresse und der nächste Termin werden auf dem Tablet angezeigt und können hier zentral gepflegt werden.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<flux:separator />
|
||||
|
||||
<div>
|
||||
<flux:heading size="sm" class="mb-1 flex items-center gap-2">
|
||||
<flux:icon name="arrow-path" class="size-4 text-zinc-500" />
|
||||
Typischer Workflow
|
||||
</flux:heading>
|
||||
<ol class="mt-2 ml-5 list-decimal space-y-1">
|
||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Version erstellen</strong> – Unter „Versionen" eine neue Version mit passendem Typ anlegen (z. B. „Frühling 2026 Video").</li>
|
||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Inhalte hinzufügen</strong> – In der Version Videos, Medien oder Slides anlegen, Reihenfolge festlegen und aktivieren.</li>
|
||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Display zuweisen</strong> – Unter „Displays" die Version einem physischen Bildschirm zuordnen.</li>
|
||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Fertig</strong> – Das Display lädt die neuen Inhalte automatisch über die API.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
204
resources/views/livewire/admin/cms/display-list.blade.php
Normal file
204
resources/views/livewire/admin/cms/display-list.blade.php
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
<div>
|
||||
<flux:header class="mb-6">
|
||||
<flux:heading size="xl">{{ __('Displays') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Verwalten Sie Ihre physischen Displays und weisen Sie ihnen Versionen zu') }}</flux:subheading>
|
||||
</flux:header>
|
||||
|
||||
@if (session()->has('success'))
|
||||
<x-success-alert>
|
||||
{{ session('success') }}
|
||||
</x-success-alert>
|
||||
@endif
|
||||
|
||||
<flux:card>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('Physische Displays') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Jedem Display können mehrere Versionen als Playlist zugewiesen werden') }}</flux:subheading>
|
||||
</div>
|
||||
<flux:button wire:click="openModal" icon="plus" variant="primary">
|
||||
{{ __('Display hinzufügen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
@if($displays->isEmpty())
|
||||
<div class="text-center py-12 text-zinc-500 dark:text-zinc-400">
|
||||
<flux:icon.tv class="w-16 h-16 mx-auto mb-4 opacity-50" />
|
||||
<p>{{ __('Noch keine Displays vorhanden. Fügen Sie Ihr erstes Display hinzu!') }}</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
@foreach($displays as $display)
|
||||
<div wire:key="display-{{ $display->id }}"
|
||||
class="flex items-center gap-4 p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600 transition">
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<flux:badge :color="$display->is_active ? 'green' : 'zinc'" size="sm">
|
||||
{{ $display->is_active ? __('Aktiv') : __('Inaktiv') }}
|
||||
</flux:badge>
|
||||
<span class="font-semibold text-sm">{{ $display->name }}</span>
|
||||
@if($display->location)
|
||||
<span class="text-xs text-zinc-500 dark:text-zinc-400">{{ $display->location }}</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if($display->versions->isNotEmpty())
|
||||
<div class="flex flex-wrap items-center gap-1.5 mt-2">
|
||||
@foreach($display->versions as $idx => $version)
|
||||
@if($idx > 0)
|
||||
<flux:icon.chevron-right class="w-3 h-3 text-zinc-400" />
|
||||
@endif
|
||||
<flux:badge color="{{ match($version->type->value) {
|
||||
'video-display' => 'purple',
|
||||
'b2in' => 'blue',
|
||||
'offers' => 'amber',
|
||||
} }}" size="sm">
|
||||
{{ $version->name }}
|
||||
</flux:badge>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<div class="text-xs text-amber-600 dark:text-amber-400 mt-1">
|
||||
{{ __('Keine Versionen zugewiesen') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Direct display links --}}
|
||||
<div class="mt-2 flex items-center gap-4">
|
||||
<a href="/_cabinet/display/index.html?id={{ $display->id }}"
|
||||
target="_blank"
|
||||
class="text-xs text-blue-600 dark:text-blue-400 hover:underline inline-flex items-center gap-1">
|
||||
<flux:icon.play class="w-3 h-3" />
|
||||
Display öffnen
|
||||
</a>
|
||||
<a href="/api/display/{{ $display->id }}/config"
|
||||
target="_blank"
|
||||
class="text-xs text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 hover:underline inline-flex items-center gap-1">
|
||||
<flux:icon.code-bracket class="w-3 h-3" />
|
||||
API
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<flux:button wire:click="toggleActive({{ $display->id }})"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
:icon="$display->is_active ? 'eye-slash' : 'eye'">
|
||||
</flux:button>
|
||||
|
||||
<flux:button wire:click="openModal({{ $display->id }})"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="pencil">
|
||||
</flux:button>
|
||||
|
||||
<flux:button wire:click="deleteDisplay({{ $display->id }})"
|
||||
wire:confirm="Möchten Sie dieses Display wirklich löschen?"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="trash"
|
||||
class="text-red-600 hover:text-red-700">
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</flux:card>
|
||||
|
||||
{{-- Display Modal --}}
|
||||
<flux:modal :open="$showModal" wire:model="showModal">
|
||||
<form wire:submit.prevent="save">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ $displayId ? __('Display bearbeiten') : __('Display hinzufügen') }}</flux:heading>
|
||||
</div>
|
||||
|
||||
<flux:input wire:model="displayName" label="Name" placeholder="z.B. Display 1 - Eingang" />
|
||||
@error('displayName') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||
|
||||
<flux:input wire:model="displayLocation" label="Standort (optional)" placeholder="z.B. Schaufenster links" />
|
||||
|
||||
{{-- Version Playlist --}}
|
||||
<div>
|
||||
<flux:heading size="sm" class="mb-2">{{ __('Versions-Playlist') }}</flux:heading>
|
||||
<flux:subheading class="mb-3">{{ __('Versionen werden in dieser Reihenfolge als Schleife abgespielt') }}</flux:subheading>
|
||||
|
||||
@if(count($selectedVersionIds) > 0)
|
||||
<div class="space-y-2 mb-3">
|
||||
@foreach($selectedVersionIds as $index => $versionId)
|
||||
@php $ver = $versions->firstWhere('id', $versionId); @endphp
|
||||
@if($ver)
|
||||
<div wire:key="playlist-{{ $index }}-{{ $versionId }}"
|
||||
class="flex items-center gap-2 p-2 bg-zinc-100 dark:bg-zinc-700 rounded border border-zinc-200 dark:border-zinc-600">
|
||||
<span class="text-xs text-zinc-400 font-mono w-5 text-center">{{ $index + 1 }}</span>
|
||||
<flux:badge color="{{ match($ver->type->value) {
|
||||
'video-display' => 'purple',
|
||||
'b2in' => 'blue',
|
||||
'offers' => 'amber',
|
||||
} }}" size="sm">
|
||||
{{ $ver->type->label() }}
|
||||
</flux:badge>
|
||||
<span class="text-sm flex-1">{{ $ver->name }}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<flux:button wire:click="moveVersion({{ $index }}, 'up')"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
icon="chevron-up"
|
||||
:disabled="$index === 0">
|
||||
</flux:button>
|
||||
<flux:button wire:click="moveVersion({{ $index }}, 'down')"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
icon="chevron-down"
|
||||
:disabled="$index === count($selectedVersionIds) - 1">
|
||||
</flux:button>
|
||||
<flux:button wire:click="removeVersion({{ $index }})"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
icon="x-mark"
|
||||
class="text-red-500">
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<div class="text-center py-4 text-zinc-400 text-sm border border-dashed border-zinc-300 dark:border-zinc-600 rounded mb-3">
|
||||
{{ __('Noch keine Versionen hinzugefügt') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-1">
|
||||
<flux:select wire:model="addVersionSelect" placeholder="Version hinzufügen...">
|
||||
@foreach($versions as $version)
|
||||
<option value="{{ $version->id }}">{{ $version->name }} ({{ $version->type->label() }})</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
</div>
|
||||
<flux:button wire:click="addVersion"
|
||||
icon="plus"
|
||||
size="sm"
|
||||
variant="ghost">
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<flux:checkbox wire:model="displayIsActive" label="Display aktiv" />
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<flux:button type="button" wire:click="closeModal" variant="ghost">
|
||||
{{ __('Abbrechen') }}
|
||||
</flux:button>
|
||||
<flux:button type="submit" variant="primary">
|
||||
{{ $displayId ? __('Aktualisieren') : __('Hinzufügen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</flux:modal>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,541 @@
|
|||
<?php
|
||||
|
||||
use App\Models\DisplayMedia;
|
||||
use App\Services\DisplayMediaService;
|
||||
use Flux\Flux;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
use Livewire\WithFileUploads;
|
||||
use function Livewire\Volt\{layout, title, state, computed, on, uses};
|
||||
|
||||
layout('components.layouts.app');
|
||||
title('Display-Mediathek');
|
||||
uses([WithFileUploads::class]);
|
||||
|
||||
state([
|
||||
'search' => '',
|
||||
'filterType' => 'all',
|
||||
'filterSource' => 'all',
|
||||
'filterCollection' => '',
|
||||
'viewMode' => 'grid',
|
||||
'editingId' => null,
|
||||
'editTitle' => '',
|
||||
'editAltText' => '',
|
||||
'editCollection' => '',
|
||||
'showDetail' => false,
|
||||
// Upload
|
||||
'uploads' => [],
|
||||
// External URL form
|
||||
'showUrlModal' => false,
|
||||
'urlInput' => '',
|
||||
'urlType' => 'video',
|
||||
'urlTitle' => '',
|
||||
'urlCollection' => '',
|
||||
'urlValidated' => null,
|
||||
]);
|
||||
|
||||
$media = computed(
|
||||
fn () => DisplayMedia::query()
|
||||
->when($this->filterType !== 'all', fn ($q) => match ($this->filterType) {
|
||||
'image' => $q->images(),
|
||||
'video' => $q->videos(),
|
||||
default => $q,
|
||||
})
|
||||
->when($this->filterSource !== 'all', fn ($q) => match ($this->filterSource) {
|
||||
'upload' => $q->uploads(),
|
||||
'external' => $q->externals(),
|
||||
default => $q,
|
||||
})
|
||||
->when($this->filterCollection, fn ($q) => $q->inCollection($this->filterCollection))
|
||||
->when($this->search, fn ($q) => $q->where('filename', 'like', "%{$this->search}%")
|
||||
->orWhere('title', 'like', "%{$this->search}%"))
|
||||
->orderByDesc('created_at')
|
||||
->paginate(48),
|
||||
);
|
||||
|
||||
$collections = computed(fn () => DisplayMedia::query()
|
||||
->whereNotNull('collection')
|
||||
->where('collection', '!=', '')
|
||||
->distinct()
|
||||
->pluck('collection')
|
||||
->sort()
|
||||
->values()
|
||||
->toArray());
|
||||
|
||||
$stats = computed(fn () => [
|
||||
'total' => DisplayMedia::count(),
|
||||
'images' => DisplayMedia::images()->count(),
|
||||
'videos' => DisplayMedia::videos()->count(),
|
||||
'uploads' => DisplayMedia::uploads()->count(),
|
||||
'externals' => DisplayMedia::externals()->count(),
|
||||
]);
|
||||
|
||||
// ========================================
|
||||
// FILE UPLOAD
|
||||
// ========================================
|
||||
|
||||
$handleUploads = function () {
|
||||
$this->validate([
|
||||
'uploads' => 'nullable|array|max:10',
|
||||
'uploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,mp4,webm,mov|max:51200',
|
||||
]);
|
||||
|
||||
$service = app(DisplayMediaService::class);
|
||||
$count = 0;
|
||||
|
||||
foreach ($this->uploads as $file) {
|
||||
$service->storeUpload($file);
|
||||
$count++;
|
||||
}
|
||||
|
||||
$this->uploads = [];
|
||||
|
||||
if ($count > 0) {
|
||||
Flux::toast(variant: 'success', heading: 'Hochgeladen', text: "{$count} Datei(en) erfolgreich hochgeladen.");
|
||||
}
|
||||
};
|
||||
|
||||
// ========================================
|
||||
// EXTERNAL URL
|
||||
// ========================================
|
||||
|
||||
$openUrlModal = function () {
|
||||
$this->urlInput = '';
|
||||
$this->urlType = 'video';
|
||||
$this->urlTitle = '';
|
||||
$this->urlCollection = '';
|
||||
$this->urlValidated = null;
|
||||
$this->showUrlModal = true;
|
||||
};
|
||||
|
||||
$validateUrl = function () {
|
||||
$this->validate(['urlInput' => 'required|url|max:2048']);
|
||||
$service = app(DisplayMediaService::class);
|
||||
$this->urlValidated = $service->validateExternalUrl($this->urlInput);
|
||||
};
|
||||
|
||||
$saveExternalUrl = function () {
|
||||
$this->validate([
|
||||
'urlInput' => 'required|url|max:2048',
|
||||
'urlType' => 'required|in:image,video',
|
||||
'urlTitle' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
$service = app(DisplayMediaService::class);
|
||||
$service->createFromUrl(
|
||||
url: $this->urlInput,
|
||||
type: $this->urlType,
|
||||
title: $this->urlTitle ?: null,
|
||||
collection: $this->urlCollection ?: null,
|
||||
);
|
||||
|
||||
$this->showUrlModal = false;
|
||||
Flux::toast(variant: 'success', heading: 'Externe URL angelegt', text: 'Das Medium wurde als externe Referenz gespeichert.');
|
||||
};
|
||||
|
||||
// ========================================
|
||||
// DETAIL / EDIT
|
||||
// ========================================
|
||||
|
||||
$startEdit = function (int $id) {
|
||||
$media = DisplayMedia::find($id);
|
||||
if (! $media) {
|
||||
return;
|
||||
}
|
||||
$this->editingId = $id;
|
||||
$this->editTitle = $media->title ?? '';
|
||||
$this->editAltText = $media->alt_text ?? '';
|
||||
$this->editCollection = $media->collection ?? '';
|
||||
$this->showDetail = true;
|
||||
};
|
||||
|
||||
$saveEdit = function () {
|
||||
$media = DisplayMedia::find($this->editingId);
|
||||
if (! $media) {
|
||||
return;
|
||||
}
|
||||
|
||||
$media->update([
|
||||
'title' => $this->editTitle ?: null,
|
||||
'alt_text' => $this->editAltText ?: null,
|
||||
'collection' => $this->editCollection ?: null,
|
||||
]);
|
||||
|
||||
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Medien-Details wurden aktualisiert.');
|
||||
};
|
||||
|
||||
$deleteMedia = function (int $id) {
|
||||
$media = DisplayMedia::find($id);
|
||||
if (! $media) {
|
||||
return;
|
||||
}
|
||||
|
||||
$filename = $media->getDisplayName();
|
||||
$service = app(DisplayMediaService::class);
|
||||
$service->delete($media);
|
||||
|
||||
$this->editingId = null;
|
||||
$this->showDetail = false;
|
||||
|
||||
Flux::toast(variant: 'success', heading: 'Gelöscht', text: $filename . ' wurde entfernt.');
|
||||
};
|
||||
|
||||
$closeDetail = function () {
|
||||
$this->showDetail = false;
|
||||
$this->editingId = null;
|
||||
};
|
||||
|
||||
?>
|
||||
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<flux:heading size="xl">Display-Mediathek</flux:heading>
|
||||
<flux:text class="mt-1">Bilder, Videos und externe URLs für Store Displays verwalten.</flux:text>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:badge color="sky">{{ $this->stats['images'] }} Bilder</flux:badge>
|
||||
<flux:badge color="purple">{{ $this->stats['videos'] }} Videos</flux:badge>
|
||||
<flux:badge color="blue">{{ $this->stats['externals'] }} Extern</flux:badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Upload + External URL --}}
|
||||
<flux:card class="mb-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-1">
|
||||
<flux:file-upload wire:model="uploads" multiple
|
||||
accept="image/jpeg,image/png,image/gif,image/webp,video/mp4,video/webm,.jpg,.jpeg,.png,.webp,.mp4,.webm,.mov">
|
||||
<flux:file-upload.dropzone
|
||||
heading="Dateien hochladen"
|
||||
text="Bilder & Videos bis 50 MB – Drag & Drop oder klicken"
|
||||
with-progress />
|
||||
</flux:file-upload>
|
||||
@if (isset($uploads) && count($uploads) > 0)
|
||||
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||
@foreach ($uploads as $index => $upload)
|
||||
<flux:file-item
|
||||
:heading="$upload->getClientOriginalName()"
|
||||
:image="(str_starts_with($upload->getMimeType() ?? '', 'image/') && $upload->isPreviewable())
|
||||
? $upload->temporaryUrl()
|
||||
: null"
|
||||
:size="$upload->getSize()" />
|
||||
@endforeach
|
||||
</div>
|
||||
<flux:button wire:click="handleUploads" variant="primary" size="sm" class="mt-3">
|
||||
{{ count($uploads) }} Datei(en) hochladen
|
||||
</flux:button>
|
||||
@endif
|
||||
<flux:error name="uploads" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 pt-2">
|
||||
<flux:button wire:click="openUrlModal" icon="link" variant="ghost">
|
||||
Externe URL anlegen
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Filters --}}
|
||||
<div class="mb-4 flex flex-wrap items-center gap-3">
|
||||
<flux:input wire:model.live.debounce.300ms="search" placeholder="Suchen..." icon="magnifying-glass" size="sm" class="w-56" />
|
||||
|
||||
<flux:select wire:model.live="filterType" size="sm" class="w-36">
|
||||
<flux:select.option value="all">Alle Typen</flux:select.option>
|
||||
<flux:select.option value="image">Bilder</flux:select.option>
|
||||
<flux:select.option value="video">Videos</flux:select.option>
|
||||
</flux:select>
|
||||
|
||||
<flux:select wire:model.live="filterSource" size="sm" class="w-36">
|
||||
<flux:select.option value="all">Alle Quellen</flux:select.option>
|
||||
<flux:select.option value="upload">Uploads</flux:select.option>
|
||||
<flux:select.option value="external">Extern</flux:select.option>
|
||||
</flux:select>
|
||||
|
||||
@if (! empty($this->collections))
|
||||
<flux:select wire:model.live="filterCollection" size="sm" class="w-40">
|
||||
<flux:select.option value="">Alle Sammlungen</flux:select.option>
|
||||
@foreach ($this->collections as $col)
|
||||
<flux:select.option value="{{ $col }}">{{ $col }}</flux:select.option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
@endif
|
||||
|
||||
<div class="ml-auto flex items-center gap-1 rounded-lg border border-zinc-200 p-0.5 dark:border-zinc-700">
|
||||
<button wire:click="$set('viewMode', 'grid')"
|
||||
class="rounded-md p-1.5 transition {{ $viewMode === 'grid' ? 'bg-zinc-200 text-zinc-900 dark:bg-zinc-600 dark:text-white' : 'text-zinc-400 hover:text-zinc-600' }}">
|
||||
<x-heroicon-s-squares-2x2 class="h-4 w-4" />
|
||||
</button>
|
||||
<button wire:click="$set('viewMode', 'list')"
|
||||
class="rounded-md p-1.5 transition {{ $viewMode === 'list' ? 'bg-zinc-200 text-zinc-900 dark:bg-zinc-600 dark:text-white' : 'text-zinc-400 hover:text-zinc-600' }}">
|
||||
<x-heroicon-s-list-bullet class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Media Grid / List + Detail --}}
|
||||
<div class="grid grid-cols-1 gap-6 {{ $showDetail ? 'lg:grid-cols-3' : '' }}">
|
||||
<div class="{{ $showDetail ? 'lg:col-span-2' : '' }}">
|
||||
@if ($viewMode === 'grid')
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 {{ $showDetail ? 'lg:grid-cols-3' : 'lg:grid-cols-6' }}">
|
||||
@forelse ($this->media as $item)
|
||||
<div wire:key="dm-g-{{ $item->id }}"
|
||||
class="group relative cursor-pointer overflow-hidden rounded-lg border transition-all
|
||||
{{ $editingId === $item->id ? 'border-blue-500 ring-2 ring-blue-200 dark:ring-blue-800' : 'border-zinc-200 hover:border-zinc-400 dark:border-zinc-700' }}"
|
||||
wire:click="startEdit({{ $item->id }})">
|
||||
<div class="aspect-square bg-zinc-100 dark:bg-zinc-800">
|
||||
@if ($item->isImage() && $item->isUpload())
|
||||
<img src="{{ $item->getThumbnailUrl() }}"
|
||||
alt="{{ $item->alt_text ?? $item->filename }}"
|
||||
class="h-full w-full object-cover" loading="lazy" />
|
||||
@elseif ($item->isVideo())
|
||||
<div class="flex h-full w-full flex-col items-center justify-center gap-2 text-purple-400">
|
||||
<x-heroicon-o-film class="h-10 w-10" />
|
||||
<span class="text-xs">Video</span>
|
||||
</div>
|
||||
@elseif ($item->isExternal() && $item->isImage())
|
||||
<div class="flex h-full w-full flex-col items-center justify-center gap-2 text-blue-400">
|
||||
<x-heroicon-o-photo class="h-10 w-10" />
|
||||
<span class="text-xs">Extern</span>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex h-full w-full flex-col items-center justify-center gap-2 text-zinc-400">
|
||||
<x-heroicon-o-link class="h-10 w-10" />
|
||||
<span class="text-xs">Link</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 p-2">
|
||||
@if ($item->isVideo())
|
||||
<x-heroicon-s-film class="h-3.5 w-3.5 shrink-0 text-purple-500" />
|
||||
@elseif ($item->isExternal())
|
||||
<x-heroicon-s-link class="h-3.5 w-3.5 shrink-0 text-blue-500" />
|
||||
@else
|
||||
<x-heroicon-s-photo class="h-3.5 w-3.5 shrink-0 text-sky-500" />
|
||||
@endif
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-xs font-medium text-zinc-700 dark:text-zinc-300">{{ $item->getDisplayName() }}</p>
|
||||
<p class="text-xs text-zinc-400">{{ $item->getHumanFileSize() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@if ($item->collection)
|
||||
<div class="absolute right-1 top-1">
|
||||
<flux:badge size="sm" color="blue" class="text-[10px]!">{{ $item->collection }}</flux:badge>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@empty
|
||||
<div class="col-span-full py-12 text-center">
|
||||
<x-heroicon-o-photo class="mx-auto mb-3 h-12 w-12 text-zinc-300" />
|
||||
<flux:text>Noch keine Medien vorhanden. Laden Sie Dateien hoch oder legen Sie externe URLs an.</flux:text>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
@else
|
||||
{{-- List View --}}
|
||||
<div class="overflow-hidden rounded-lg border border-zinc-200 dark:border-zinc-700">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="border-b border-zinc-200 bg-zinc-50 text-left text-xs text-zinc-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400">
|
||||
<tr>
|
||||
<th class="w-12 px-3 py-2"></th>
|
||||
<th class="px-3 py-2">Name</th>
|
||||
<th class="hidden px-3 py-2 sm:table-cell">Typ</th>
|
||||
<th class="hidden px-3 py-2 sm:table-cell">Quelle</th>
|
||||
<th class="hidden px-3 py-2 md:table-cell">Größe</th>
|
||||
<th class="hidden px-3 py-2 lg:table-cell">Sammlung</th>
|
||||
<th class="px-3 py-2 text-right">Datum</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||
@forelse ($this->media as $item)
|
||||
<tr wire:key="dm-l-{{ $item->id }}"
|
||||
class="cursor-pointer transition {{ $editingId === $item->id ? 'bg-blue-50 dark:bg-blue-900/20' : 'hover:bg-zinc-50 dark:hover:bg-zinc-800/50' }}"
|
||||
wire:click="startEdit({{ $item->id }})">
|
||||
<td class="px-3 py-1.5">
|
||||
<div class="flex h-8 w-8 items-center justify-center overflow-hidden rounded border border-zinc-200 bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-800">
|
||||
@if ($item->isImage() && $item->isUpload())
|
||||
<img src="{{ $item->getThumbnailUrl() }}" class="h-full w-full object-cover" loading="lazy" />
|
||||
@elseif ($item->isVideo())
|
||||
<x-heroicon-s-film class="h-4 w-4 text-purple-500" />
|
||||
@else
|
||||
<x-heroicon-s-link class="h-4 w-4 text-blue-500" />
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-1.5">
|
||||
<span class="font-medium text-zinc-700 dark:text-zinc-300">{{ $item->getDisplayName() }}</span>
|
||||
</td>
|
||||
<td class="hidden px-3 py-1.5 sm:table-cell">
|
||||
<flux:badge size="sm" :color="$item->isVideo() ? 'purple' : 'sky'">
|
||||
{{ $item->isVideo() ? 'Video' : 'Bild' }}
|
||||
</flux:badge>
|
||||
</td>
|
||||
<td class="hidden px-3 py-1.5 sm:table-cell">
|
||||
<flux:badge size="sm" :color="$item->isExternal() ? 'blue' : 'zinc'">
|
||||
{{ $item->isExternal() ? 'Extern' : 'Upload' }}
|
||||
</flux:badge>
|
||||
</td>
|
||||
<td class="hidden px-3 py-1.5 text-zinc-500 md:table-cell">{{ $item->getHumanFileSize() }}</td>
|
||||
<td class="hidden px-3 py-1.5 lg:table-cell">
|
||||
@if ($item->collection)
|
||||
<flux:badge size="sm" color="blue">{{ $item->collection }}</flux:badge>
|
||||
@else
|
||||
<span class="text-zinc-300">—</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-3 py-1.5 text-right text-zinc-400">{{ $item->created_at->format('d.m.Y') }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="7" class="py-12 text-center">
|
||||
<x-heroicon-o-photo class="mx-auto mb-3 h-12 w-12 text-zinc-300" />
|
||||
<flux:text>Noch keine Medien vorhanden.</flux:text>
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($this->media->hasPages())
|
||||
<div class="mt-4">
|
||||
{{ $this->media->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Detail Sidebar --}}
|
||||
@if ($showDetail && $editingId)
|
||||
@php $editMedia = \App\Models\DisplayMedia::find($editingId); @endphp
|
||||
@if ($editMedia)
|
||||
<div class="lg:col-span-1">
|
||||
<flux:card>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<flux:heading size="sm">Details</flux:heading>
|
||||
<flux:button size="xs" variant="ghost" icon="x-mark" wire:click="closeDetail" />
|
||||
</div>
|
||||
|
||||
{{-- Preview --}}
|
||||
<div class="mb-4 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800">
|
||||
@if ($editMedia->isImage() && $editMedia->isUpload())
|
||||
<img src="{{ $editMedia->getUrl() }}" alt="{{ $editMedia->filename }}"
|
||||
class="w-full object-contain" style="max-height: 300px;" />
|
||||
@elseif ($editMedia->isVideo() && $editMedia->isUpload())
|
||||
<video controls class="w-full" style="max-height: 300px;">
|
||||
<source src="{{ $editMedia->getUrl() }}" type="{{ $editMedia->mime_type }}">
|
||||
</video>
|
||||
@elseif ($editMedia->isExternal())
|
||||
<div class="flex flex-col items-center justify-center gap-3 py-8">
|
||||
<x-heroicon-o-link class="h-12 w-12 text-blue-400" />
|
||||
<span class="text-sm text-zinc-500">Externe Ressource</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Metadata --}}
|
||||
<div class="mb-4 space-y-1 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
<p><strong>Datei:</strong> {{ $editMedia->filename }}</p>
|
||||
<p><strong>Typ:</strong>
|
||||
<flux:badge size="sm" :color="$editMedia->isVideo() ? 'purple' : 'sky'" class="inline">
|
||||
{{ $editMedia->isVideo() ? 'Video' : 'Bild' }}
|
||||
</flux:badge>
|
||||
</p>
|
||||
<p><strong>Quelle:</strong>
|
||||
<flux:badge size="sm" :color="$editMedia->isExternal() ? 'blue' : 'zinc'" class="inline">
|
||||
{{ $editMedia->isExternal() ? 'Extern' : 'Upload' }}
|
||||
</flux:badge>
|
||||
</p>
|
||||
@if ($editMedia->isUpload())
|
||||
<p><strong>Größe:</strong> {{ $editMedia->getHumanFileSize() }}</p>
|
||||
@if ($editMedia->mime_type)
|
||||
<p><strong>MIME:</strong> {{ $editMedia->mime_type }}</p>
|
||||
@endif
|
||||
@if ($editMedia->metadata && isset($editMedia->metadata['width']))
|
||||
<p><strong>Abmessungen:</strong> {{ $editMedia->metadata['width'] }}×{{ $editMedia->metadata['height'] }} px</p>
|
||||
@endif
|
||||
@endif
|
||||
<p><strong>Angelegt:</strong> {{ $editMedia->created_at->format('d.m.Y H:i') }}</p>
|
||||
<p class="break-all"><strong>URL:</strong>
|
||||
<a href="{{ $editMedia->getUrl() }}" target="_blank" class="text-blue-500 hover:underline">
|
||||
{{ Str::limit($editMedia->getUrl(), 80) }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{-- Edit Form --}}
|
||||
<div class="space-y-3">
|
||||
<flux:input wire:model="editTitle" label="Titel" size="sm" placeholder="Anzeigename..." />
|
||||
<flux:input wire:model="editAltText" label="Alt-Text" size="sm" placeholder="Bildbeschreibung..." />
|
||||
<flux:input wire:model="editCollection" label="Sammlung" size="sm" placeholder="z.B. immobilien, moebel, brand..." />
|
||||
|
||||
<flux:button size="sm" variant="primary" wire:click="saveEdit" class="w-full">
|
||||
Speichern
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 border-t border-zinc-200 pt-4 dark:border-zinc-700">
|
||||
<flux:button size="sm" variant="danger" class="w-full" icon="trash"
|
||||
wire:click="deleteMedia({{ $editMedia->id }})"
|
||||
wire:confirm="Dieses Medium wirklich löschen?">
|
||||
Löschen
|
||||
</flux:button>
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- External URL Modal --}}
|
||||
<flux:modal wire:model="showUrlModal" class="max-w-lg">
|
||||
<form wire:submit.prevent="saveExternalUrl">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<flux:heading size="lg">Externe URL anlegen</flux:heading>
|
||||
<flux:text class="mt-1">Binden Sie große Videos oder Medien von Google Drive, OneDrive oder anderen Quellen ein.</flux:text>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<flux:input wire:model="urlInput" label="URL" placeholder="https://drive.google.com/file/d/..." />
|
||||
<flux:error name="urlInput" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-end gap-3">
|
||||
<flux:button type="button" wire:click="validateUrl" size="sm" variant="ghost" icon="arrow-path">
|
||||
URL prüfen
|
||||
</flux:button>
|
||||
@if ($urlValidated === true)
|
||||
<span class="flex items-center gap-1 text-sm text-green-600">
|
||||
<x-heroicon-s-check-circle class="h-4 w-4" /> Erreichbar
|
||||
</span>
|
||||
@elseif ($urlValidated === false)
|
||||
<span class="flex items-center gap-1 text-sm text-amber-600">
|
||||
<x-heroicon-s-exclamation-triangle class="h-4 w-4" /> Nicht erreichbar – trotzdem speichern möglich
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<flux:select wire:model="urlType" label="Medientyp">
|
||||
<option value="video">Video</option>
|
||||
<option value="image">Bild</option>
|
||||
</flux:select>
|
||||
|
||||
<flux:input wire:model="urlTitle" label="Titel (optional)" placeholder="z.B. Showroom-Rundgang 4K" />
|
||||
|
||||
<flux:input wire:model="urlCollection" label="Sammlung (optional)" placeholder="z.B. immobilien, moebel..." />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<flux:button type="button" wire:click="$set('showUrlModal', false)" variant="ghost">
|
||||
Abbrechen
|
||||
</flux:button>
|
||||
<flux:button type="submit" variant="primary">
|
||||
Anlegen
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</flux:modal>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
<div>
|
||||
<div class="flex items-end gap-3">
|
||||
<div class="flex-1">
|
||||
@if ($selectedMedia)
|
||||
<div class="flex items-center gap-3 rounded-lg border border-zinc-200 p-2 dark:border-zinc-700">
|
||||
@if ($selectedMedia->isImage() && $selectedMedia->isUpload())
|
||||
<img src="{{ $selectedMedia->getThumbnailUrl() }}"
|
||||
alt="{{ $selectedMedia->filename }}"
|
||||
class="h-16 w-16 rounded-md object-cover" />
|
||||
@elseif ($selectedMedia->isVideo())
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-md bg-purple-50 dark:bg-purple-900/20">
|
||||
<x-heroicon-o-film class="h-8 w-8 text-purple-500" />
|
||||
</div>
|
||||
@elseif ($selectedMedia->isExternal())
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-md bg-blue-50 dark:bg-blue-900/20">
|
||||
<x-heroicon-o-link class="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
@else
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-md bg-zinc-100 dark:bg-zinc-800">
|
||||
<x-heroicon-o-photo class="h-8 w-8 text-zinc-400" />
|
||||
</div>
|
||||
@endif
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||
{{ $selectedMedia->getDisplayName() }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 text-xs text-zinc-400">
|
||||
<span>{{ $selectedMedia->getHumanFileSize() }}</span>
|
||||
@if ($selectedMedia->isExternal())
|
||||
<flux:badge size="sm" color="blue">Extern</flux:badge>
|
||||
@endif
|
||||
<flux:badge size="sm" :color="$selectedMedia->isVideo() ? 'purple' : 'sky'">
|
||||
{{ $selectedMedia->isVideo() ? 'Video' : 'Bild' }}
|
||||
</flux:badge>
|
||||
</div>
|
||||
</div>
|
||||
<flux:button size="xs" variant="ghost" icon="x-mark" wire:click="clearSelection" />
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-lg border-2 border-dashed border-zinc-300 px-4 py-3 text-center text-sm text-zinc-400 dark:border-zinc-600">
|
||||
Kein Medium ausgewählt
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<flux:button size="sm" variant="ghost" icon="photo" wire:click="openPicker">
|
||||
{{ $label }}
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
<flux:modal wire:model="showModal" class="w-full max-w-4xl space-y-4 overflow-y-auto max-h-[85vh]">
|
||||
<flux:heading size="lg">{{ $label }}</flux:heading>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<flux:input wire:model.live.debounce.300ms="search" placeholder="Suchen (Dateiname oder Titel)..." icon="magnifying-glass" size="sm" />
|
||||
|
||||
<flux:file-upload wire:model="quickUploads" multiple
|
||||
accept="image/jpeg,image/png,image/gif,image/webp,video/mp4,video/webm,.jpg,.jpeg,.png,.webp,.mp4,.webm,.mov">
|
||||
<flux:file-upload.dropzone
|
||||
heading="Datei hochladen"
|
||||
text="Bilder bis 50 MB, Videos bis 50 MB – größere Videos bitte über die Mediathek als externe URL anlegen"
|
||||
with-progress />
|
||||
</flux:file-upload>
|
||||
|
||||
@if (isset($quickUploads) && count($quickUploads) > 0)
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@foreach ($quickUploads as $index => $upload)
|
||||
<flux:file-item
|
||||
:heading="$upload->getClientOriginalName()"
|
||||
:image="(str_starts_with($upload->getMimeType() ?? '', 'image/') && $upload->isPreviewable())
|
||||
? $upload->temporaryUrl()
|
||||
: null"
|
||||
:size="$upload->getSize()">
|
||||
<x-slot name="actions">
|
||||
<flux:file-item.remove wire:click="removeQuickUpload({{ $index }})" />
|
||||
</x-slot>
|
||||
</flux:file-item>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<flux:error name="quickUploads" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6">
|
||||
@forelse ($mediaItems as $item)
|
||||
<div wire:key="dpick-{{ $item->id }}"
|
||||
class="group cursor-pointer overflow-hidden rounded-lg border transition-all
|
||||
{{ $value === $item->id ? 'border-blue-500 ring-2 ring-blue-200' : 'border-zinc-200 hover:border-blue-300 dark:border-zinc-700' }}"
|
||||
wire:click="selectMedia({{ $item->id }})">
|
||||
<div class="relative aspect-square bg-zinc-100 dark:bg-zinc-800">
|
||||
@if ($item->isImage() && $item->isUpload())
|
||||
<img src="{{ $item->getThumbnailUrl() }}"
|
||||
alt="{{ $item->filename }}" class="h-full w-full object-cover" loading="lazy" />
|
||||
@elseif ($item->isVideo())
|
||||
<div class="flex h-full w-full items-center justify-center text-purple-500">
|
||||
<x-heroicon-o-film class="h-10 w-10" />
|
||||
</div>
|
||||
@elseif ($item->isExternal() && $item->isImage())
|
||||
<div class="flex h-full w-full items-center justify-center text-blue-500">
|
||||
<x-heroicon-o-photo class="h-10 w-10" />
|
||||
</div>
|
||||
@else
|
||||
<div class="flex h-full w-full items-center justify-center text-zinc-400">
|
||||
<x-heroicon-o-link class="h-10 w-10" />
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Badges --}}
|
||||
<div class="absolute right-1 top-1 flex flex-col gap-1">
|
||||
@if ($item->isExternal())
|
||||
<flux:badge size="sm" color="blue" class="text-[10px]!">URL</flux:badge>
|
||||
@endif
|
||||
@if ($item->isVideo())
|
||||
<flux:badge size="sm" color="purple" class="text-[10px]!">Video</flux:badge>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-1.5">
|
||||
<p class="truncate text-[11px] font-medium text-zinc-600 dark:text-zinc-400">{{ $item->getDisplayName() }}</p>
|
||||
<p class="truncate text-[10px] text-zinc-400">{{ $item->getHumanFileSize() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="col-span-full py-8 text-center">
|
||||
<flux:text>Keine Medien gefunden. Laden Sie Dateien hoch oder legen Sie externe URLs in der Mediathek an.</flux:text>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
@if ($mediaItems->hasPages())
|
||||
<div class="mt-2">
|
||||
{{ $mediaItems->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</flux:modal>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
<div>
|
||||
{{-- Header --}}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<flux:button :href="route('admin.cms.display-versions')" wire:navigate variant="ghost" icon="arrow-left" size="sm">
|
||||
{{ __('Zurück') }}
|
||||
</flux:button>
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:heading size="xl">{{ $version->name }}</flux:heading>
|
||||
<flux:badge color="{{ match($version->type->value) {
|
||||
'video-display' => 'purple',
|
||||
'b2in' => 'blue',
|
||||
'offers' => 'amber',
|
||||
} }}">
|
||||
{{ $version->type->label() }}
|
||||
</flux:badge>
|
||||
</div>
|
||||
<flux:subheading>{{ __('Version bearbeiten') }}</flux:subheading>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
@if($version->type->value === 'b2in')
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700">
|
||||
<flux:icon.sun class="w-4 h-4 text-amber-500" />
|
||||
<flux:switch wire:click="toggleTheme"
|
||||
:checked="($version->settings['theme'] ?? 'dark') === 'dark'" />
|
||||
<flux:icon.moon class="w-4 h-4 text-indigo-400" />
|
||||
</div>
|
||||
@endif
|
||||
<flux:button wire:click="openSettingsModal" icon="cog-6-tooth" variant="ghost">
|
||||
{{ __('Einstellungen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (session()->has('success'))
|
||||
<x-success-alert>
|
||||
{{ session('success') }}
|
||||
</x-success-alert>
|
||||
@endif
|
||||
|
||||
{{-- Name bearbeiten --}}
|
||||
<flux:card class="mb-6">
|
||||
<form wire:submit.prevent="saveName" class="flex items-end gap-4">
|
||||
<div class="flex-1">
|
||||
<flux:input wire:model="versionName" label="Versionsname" />
|
||||
</div>
|
||||
<flux:button type="submit" variant="primary" size="sm">{{ __('Speichern') }}</flux:button>
|
||||
</form>
|
||||
</flux:card>
|
||||
|
||||
{{-- Type-specific content sections --}}
|
||||
@if($version->type->value === 'video-display')
|
||||
@include('livewire.admin.cms.partials.version-editor-video', ['items' => $items])
|
||||
@elseif($version->type->value === 'b2in')
|
||||
@include('livewire.admin.cms.partials.version-editor-b2in', ['items' => $items])
|
||||
@elseif($version->type->value === 'offers')
|
||||
@include('livewire.admin.cms.partials.version-editor-offers', ['items' => $items])
|
||||
@endif
|
||||
|
||||
{{-- Item Modal --}}
|
||||
<flux:modal :open="$showItemModal" wire:model="showItemModal">
|
||||
<form wire:submit.prevent="saveItem">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ $itemId ? __('Inhalt bearbeiten') : __('Inhalt hinzufügen') }}</flux:heading>
|
||||
</div>
|
||||
|
||||
{{-- Video fields --}}
|
||||
@if($itemType === 'video')
|
||||
<livewire:admin.cms.display-media-picker
|
||||
:value="null"
|
||||
field="videoFilename"
|
||||
type="video"
|
||||
label="Video aus Mediathek"
|
||||
:key="'picker-video-' . ($itemId ?? 'new')" />
|
||||
<flux:input wire:model="videoFilename" label="Video-Pfad / URL" placeholder="Wird automatisch gesetzt oder manuell eingeben..."
|
||||
description="Über die Mediathek auswählen oder direkt Pfad/URL eingeben." />
|
||||
<flux:input wire:model="videoTitle" label="Titel (optional)" placeholder="z.B. Herbst Kollektion 2025" />
|
||||
<flux:input wire:model="videoPosition" type="number" min="0" max="100" label="Position (%)"
|
||||
description="Vertikale Position im Video (0 = oben, 100 = unten)" />
|
||||
<flux:checkbox wire:model="videoIsActive" label="Aktiv" />
|
||||
@endif
|
||||
|
||||
{{-- Footer fields --}}
|
||||
@if($itemType === 'footer')
|
||||
<flux:input wire:model="footerHeadline" label="Überschrift" placeholder="z.B. Beratung & Termin" />
|
||||
<flux:input wire:model="footerSubline" label="Unterzeile" placeholder="z.B. Jetzt Termin vereinbaren." />
|
||||
<flux:input wire:model="footerUrl" label="URL (optional)" placeholder="https://..."
|
||||
description="Leer = kein QR-Code. Mit URL = QR-Code wird generiert." />
|
||||
<flux:checkbox wire:model="footerIsActive" label="Aktiv" />
|
||||
@endif
|
||||
|
||||
{{-- Media fields (B2in) --}}
|
||||
@if($itemType === 'media')
|
||||
<flux:select wire:model="mediaType" label="Medientyp">
|
||||
<option value="image">Bild</option>
|
||||
<option value="video">Video</option>
|
||||
</flux:select>
|
||||
<flux:select wire:model="mediaCategory" label="Kategorie">
|
||||
<option value="immobilien">Immobilien</option>
|
||||
<option value="moebel">Möbel</option>
|
||||
</flux:select>
|
||||
<livewire:admin.cms.display-media-picker
|
||||
:value="null"
|
||||
field="mediaUrl"
|
||||
:type="$mediaType === 'video' ? 'video' : 'image'"
|
||||
label="Aus Mediathek"
|
||||
:key="'picker-media-' . ($itemId ?? 'new')" />
|
||||
<flux:input wire:model="mediaUrl" label="Medien-URL" placeholder="Wird automatisch gesetzt oder manuell eingeben..."
|
||||
description="Über die Mediathek auswählen oder direkt URL eingeben." />
|
||||
<flux:input wire:model="mediaHeadline" label="Überschrift" placeholder="z.B. Ihr Zuhause. Weltweit." />
|
||||
<flux:input wire:model="mediaSubline" label="Unterzeile" placeholder="z.B. Beratung und Vermittlung." />
|
||||
@if($mediaType === 'image')
|
||||
<flux:input wire:model="mediaDuration" type="number" min="1" max="120" label="Dauer (Sekunden)"
|
||||
description="Nur für Bilder – Videos spielen bis zum Ende." />
|
||||
@endif
|
||||
<flux:checkbox wire:model="mediaIsActive" label="Aktiv" />
|
||||
@endif
|
||||
|
||||
{{-- Slide fields (Offers) --}}
|
||||
@if($itemType === 'slide')
|
||||
{{-- Basis --}}
|
||||
<flux:select wire:model.live="slideType" label="Slide-Typ">
|
||||
<option value="intro">Intro</option>
|
||||
<option value="product-hero">Produkt-Hero</option>
|
||||
<option value="product-details">Produkt-Details</option>
|
||||
<option value="product-impulse">Produkt-Impuls</option>
|
||||
</flux:select>
|
||||
<flux:input wire:model="slideDuration" type="number" min="1000" label="Dauer (ms)" />
|
||||
<livewire:admin.cms.display-media-picker
|
||||
:value="null"
|
||||
field="slideImageUrl"
|
||||
type="image"
|
||||
label="Bild aus Mediathek"
|
||||
:key="'picker-slide-' . ($itemId ?? 'new')" />
|
||||
<flux:input wire:model="slideImageUrl" label="Bild-URL" placeholder="Wird automatisch gesetzt oder manuell eingeben..."
|
||||
description="Über die Mediathek auswählen oder direkt URL eingeben." />
|
||||
<flux:input wire:model="slideBadge" label="Badge-Text" placeholder="z.B. Einzelstück" />
|
||||
<flux:input wire:model="slideEyebrow" label="Eyebrow" placeholder="z.B. Hersteller: Sudbrock" />
|
||||
<flux:input wire:model="slideTitle" label="Titel" placeholder="z.B. GOYA Sideboard" />
|
||||
|
||||
{{-- Intro-spezifisch --}}
|
||||
@if($slideType === 'intro')
|
||||
<flux:input wire:model="slideDisclaimer" label="Disclaimer" placeholder="z.B. Zwischenverkauf vorbehalten" />
|
||||
<flux:checkbox wire:model="slideShowBrandText" label="Brand-Text anzeigen" />
|
||||
@if($slideShowBrandText)
|
||||
<flux:input wire:model="slideBrandTagline" label="Brand-Tagline" placeholder="z.B. Planung • Beratung • Lieferung & Montage" />
|
||||
@endif
|
||||
@endif
|
||||
|
||||
{{-- Product-Hero --}}
|
||||
@if($slideType === 'product-hero')
|
||||
<flux:input wire:model="slidePrice" label="Preis" placeholder="z.B. 489 €" />
|
||||
<flux:input wire:model="slideOriginalPrice" label="Originalpreis" placeholder="z.B. statt 4.744 €" />
|
||||
@endif
|
||||
|
||||
{{-- Product-Details --}}
|
||||
@if($slideType === 'product-details')
|
||||
<div>
|
||||
<flux:heading size="sm" class="mb-2">{{ __('Aufzählungspunkte') }}</flux:heading>
|
||||
<div class="space-y-2">
|
||||
@foreach($slideBullets as $i => $bullet)
|
||||
<div class="flex items-center gap-2" wire:key="bullet-{{ $i }}">
|
||||
<flux:input wire:model="slideBullets.{{ $i }}" placeholder="Punkt {{ $i + 1 }}" class="flex-1" />
|
||||
<flux:button wire:click="removeBullet({{ $i }})" size="xs" variant="ghost" icon="x-mark" class="text-red-500"></flux:button>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<flux:button wire:click="addBullet" size="xs" variant="ghost" icon="plus" class="mt-2">
|
||||
{{ __('Punkt hinzufügen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Product-Impulse --}}
|
||||
@if($slideType === 'product-impulse')
|
||||
<flux:input wire:model="slideSubline" label="Subline" placeholder="z.B. Heute mitnehmen" />
|
||||
<flux:input wire:model="slidePrice" label="Preis" placeholder="z.B. 199 €" />
|
||||
<flux:input wire:model="slideTagText" label="Tag-Text" placeholder="z.B. Im Store verfügbar" />
|
||||
@endif
|
||||
|
||||
{{-- QR --}}
|
||||
<div class="border-t border-zinc-200 dark:border-zinc-700 pt-4 mt-2">
|
||||
<flux:heading size="sm" class="mb-3">{{ __('QR-Code & Kontakt') }}</flux:heading>
|
||||
<div class="space-y-4">
|
||||
<flux:input wire:model="slideQrUrl" label="QR-URL" placeholder="z.B. https://cabinet-bielefeld.de" />
|
||||
<flux:input wire:model="slideQrTitle" label="QR-Titel" placeholder="z.B. Reservieren" />
|
||||
<flux:input wire:model="slideContact" label="Kontakt" placeholder="z.B. 0521 98620100 / Tel. oder WhatsApp" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<flux:checkbox wire:model="slideIsActive" label="Aktiv" />
|
||||
@endif
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<flux:button type="button" wire:click="closeItemModal" variant="ghost">
|
||||
{{ __('Abbrechen') }}
|
||||
</flux:button>
|
||||
<flux:button type="submit" variant="primary">
|
||||
{{ $itemId ? __('Aktualisieren') : __('Hinzufügen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</flux:modal>
|
||||
|
||||
{{-- Settings Modal --}}
|
||||
<flux:modal :open="$showSettingsModal" wire:model="showSettingsModal">
|
||||
<form wire:submit.prevent="saveSettings">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('Einstellungen') }}</flux:heading>
|
||||
<flux:subheading>{{ $version->type->label() }}</flux:subheading>
|
||||
</div>
|
||||
|
||||
@if($version->type->value === 'b2in')
|
||||
<flux:select wire:model="settings.theme" label="Theme">
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
</flux:select>
|
||||
<flux:input wire:model="settings.footer_name" label="Footer Name" placeholder="z.B. Marcel Scheibe" />
|
||||
<flux:input wire:model="settings.footer_url" label="Footer URL" placeholder="z.B. b2in.de" />
|
||||
<flux:select wire:model="settings.transition.type" label="Transition">
|
||||
<option value="crossfade">Crossfade</option>
|
||||
<option value="fade">Fade</option>
|
||||
<option value="slide">Slide</option>
|
||||
</flux:select>
|
||||
<flux:input wire:model="settings.transition.duration_ms" type="number" label="Transition-Dauer (ms)" />
|
||||
<flux:input wire:model="settings.default_image_duration" type="number" label="Standard-Bilddauer (Sek.)" />
|
||||
<flux:checkbox wire:model="settings.display_active" label="Display aktiv" />
|
||||
@elseif($version->type->value === 'offers')
|
||||
<flux:checkbox wire:model="settings.loop" label="Endlosschleife" />
|
||||
<flux:select wire:model="settings.transition.type" label="Transition">
|
||||
<option value="fade">Fade</option>
|
||||
<option value="slide">Slide</option>
|
||||
</flux:select>
|
||||
<flux:input wire:model="settings.transition.duration" type="number" label="Transition-Dauer (ms)" />
|
||||
@elseif($version->type->value === 'video-display')
|
||||
<p class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('Keine speziellen Einstellungen für diesen Typ.') }}</p>
|
||||
@endif
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<flux:button type="button" wire:click="$set('showSettingsModal', false)" variant="ghost">
|
||||
{{ __('Abbrechen') }}
|
||||
</flux:button>
|
||||
<flux:button type="submit" variant="primary">
|
||||
{{ __('Speichern') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</flux:modal>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
<div>
|
||||
<flux:header class="mb-6">
|
||||
<flux:heading size="xl">{{ __('Display-Versionen') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Erstellen und verwalten Sie Inhalts-Versionen für Ihre Displays') }}</flux:subheading>
|
||||
</flux:header>
|
||||
|
||||
@if (session()->has('success'))
|
||||
<x-success-alert>
|
||||
{{ session('success') }}
|
||||
</x-success-alert>
|
||||
@endif
|
||||
|
||||
<flux:card>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('Versionen') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Jede Version enthält Inhalte eines bestimmten Typs') }}</flux:subheading>
|
||||
</div>
|
||||
<flux:button wire:click="openCreateModal" icon="plus" variant="primary">
|
||||
{{ __('Version erstellen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
@if($versions->isEmpty())
|
||||
<div class="text-center py-12 text-zinc-500 dark:text-zinc-400">
|
||||
<flux:icon.rectangle-group class="w-16 h-16 mx-auto mb-4 opacity-50" />
|
||||
<p>{{ __('Noch keine Versionen vorhanden. Erstellen Sie Ihre erste Version!') }}</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
@foreach($versions as $version)
|
||||
<div wire:key="version-{{ $version->id }}"
|
||||
class="flex items-center gap-4 p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600 transition">
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<flux:badge :color="$version->is_active ? 'green' : 'zinc'" size="sm">
|
||||
{{ $version->is_active ? __('Aktiv') : __('Inaktiv') }}
|
||||
</flux:badge>
|
||||
<a href="{{ route('admin.cms.display-version-edit', $version) }}"
|
||||
wire:navigate
|
||||
class="font-semibold text-sm hover:underline">
|
||||
{{ $version->name }}
|
||||
</a>
|
||||
<flux:badge color="{{ match($version->type->value) {
|
||||
'video-display' => 'purple',
|
||||
'b2in' => 'blue',
|
||||
'offers' => 'amber',
|
||||
} }}" size="sm">
|
||||
{{ $version->type->label() }}
|
||||
</flux:badge>
|
||||
</div>
|
||||
<div class="text-xs text-zinc-600 dark:text-zinc-400 space-x-4">
|
||||
<span>{{ $version->items_count }} {{ __('Inhalte') }}</span>
|
||||
<span>{{ $version->displays_count }} {{ __('Displays') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<flux:button wire:click="toggleActive({{ $version->id }})"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
:icon="$version->is_active ? 'eye-slash' : 'eye'">
|
||||
</flux:button>
|
||||
|
||||
<flux:button :href="route('admin.cms.display-version-edit', $version)"
|
||||
wire:navigate
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="pencil">
|
||||
</flux:button>
|
||||
|
||||
<flux:button wire:click="deleteVersion({{ $version->id }})"
|
||||
wire:confirm="Möchten Sie diese Version wirklich löschen? Alle zugehörigen Inhalte werden ebenfalls gelöscht."
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="trash"
|
||||
class="text-red-600 hover:text-red-700">
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</flux:card>
|
||||
|
||||
{{-- Create Modal --}}
|
||||
<flux:modal :open="$showCreateModal" wire:model="showCreateModal">
|
||||
<form wire:submit.prevent="createVersion">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('Neue Version erstellen') }}</flux:heading>
|
||||
</div>
|
||||
|
||||
<flux:input wire:model="newName" label="Name" placeholder="z.B. Herbst 2025 Video" />
|
||||
@error('newName') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||
|
||||
<flux:select wire:model="newType" label="Typ" placeholder="Typ auswählen...">
|
||||
@foreach($types as $type)
|
||||
<option value="{{ $type->value }}">{{ $type->label() }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
@error('newType') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<flux:button type="button" wire:click="$set('showCreateModal', false)" variant="ghost">
|
||||
{{ __('Abbrechen') }}
|
||||
</flux:button>
|
||||
<flux:button type="submit" variant="primary">
|
||||
{{ __('Erstellen & Bearbeiten') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</flux:modal>
|
||||
</div>
|
||||
441
resources/views/livewire/admin/cms/media-index.blade.php
Normal file
441
resources/views/livewire/admin/cms/media-index.blade.php
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
<?php
|
||||
|
||||
use Flux\Flux;
|
||||
use FluxCms\Core\Models\CmsMedia;
|
||||
use FluxCms\Core\Services\MediaConversionService;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use function Livewire\Volt\{layout, title, state, computed, on};
|
||||
|
||||
layout('components.layouts.app');
|
||||
title('Medienbibliothek');
|
||||
|
||||
state([
|
||||
'search' => '',
|
||||
'filterType' => 'all',
|
||||
'filterCollection' => '',
|
||||
'viewMode' => 'grid',
|
||||
'editingId' => null,
|
||||
'editLocale' => 'de',
|
||||
'altText' => '',
|
||||
'mediaTitle' => '',
|
||||
'collection' => '',
|
||||
'showDetail' => false,
|
||||
'selectedProfiles' => [],
|
||||
]);
|
||||
|
||||
$media = computed(
|
||||
fn () => CmsMedia::query()
|
||||
->when(
|
||||
$this->filterType !== 'all',
|
||||
fn ($q) => match ($this->filterType) {
|
||||
'image' => $q->images(),
|
||||
'pdf' => $q->pdfs(),
|
||||
'document' => $q->documents(),
|
||||
default => $q,
|
||||
},
|
||||
)
|
||||
->when($this->filterCollection, fn ($q) => $q->inCollection($this->filterCollection))
|
||||
->when($this->search, fn ($q) => $q->where('filename', 'like', "%{$this->search}%"))
|
||||
->orderByDesc('created_at')
|
||||
->paginate(48),
|
||||
);
|
||||
|
||||
$collections = computed(fn () => CmsMedia::query()->whereNotNull('collection')->where('collection', '!=', '')->distinct()->pluck('collection')->sort()->values()->toArray());
|
||||
|
||||
$profiles = computed(fn () => config('flux-cms.media.profiles', []));
|
||||
|
||||
$stats = computed(
|
||||
fn () => [
|
||||
'total' => CmsMedia::count(),
|
||||
'images' => CmsMedia::images()->count(),
|
||||
'pdfs' => CmsMedia::pdfs()->count(),
|
||||
],
|
||||
);
|
||||
|
||||
on([
|
||||
'media-library-uploaded' => function ($mediaId) {
|
||||
$media = CmsMedia::find($mediaId);
|
||||
if ($media) {
|
||||
Flux::toast(variant: 'success', heading: 'Hochgeladen', text: $media->filename . ' wurde erfolgreich hochgeladen.');
|
||||
}
|
||||
},
|
||||
]);
|
||||
|
||||
$startEdit = function (int $id) {
|
||||
$media = CmsMedia::find($id);
|
||||
if (! $media) {
|
||||
return;
|
||||
}
|
||||
$this->editingId = $id;
|
||||
$this->altText = $media->getTranslation('alt_text', $this->editLocale) ?? '';
|
||||
$this->mediaTitle = $media->getTranslation('title', $this->editLocale) ?? '';
|
||||
$this->collection = $media->collection ?? '';
|
||||
$this->showDetail = true;
|
||||
};
|
||||
|
||||
$switchLocale = function (string $locale) {
|
||||
$this->editLocale = $locale;
|
||||
if ($this->editingId) {
|
||||
$media = CmsMedia::find($this->editingId);
|
||||
if ($media) {
|
||||
$this->altText = $media->getTranslation('alt_text', $locale) ?? '';
|
||||
$this->mediaTitle = $media->getTranslation('title', $locale) ?? '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$saveEdit = function () {
|
||||
$media = CmsMedia::find($this->editingId);
|
||||
if (! $media) {
|
||||
return;
|
||||
}
|
||||
|
||||
$media->setTranslation('alt_text', $this->editLocale, $this->altText);
|
||||
$media->setTranslation('title', $this->editLocale, $this->mediaTitle);
|
||||
$media->collection = $this->collection;
|
||||
$media->save();
|
||||
|
||||
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Medien-Details wurden aktualisiert.');
|
||||
};
|
||||
|
||||
$generateConversion = function (string $profile) {
|
||||
$media = CmsMedia::find($this->editingId);
|
||||
if (! $media || ! $media->isImage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$service = app(MediaConversionService::class);
|
||||
$result = $service->convert($media, $profile);
|
||||
|
||||
if ($result) {
|
||||
$media->refresh();
|
||||
Flux::toast(variant: 'success', heading: 'Conversion erstellt', text: "Profil \"{$profile}\" wurde generiert.");
|
||||
} else {
|
||||
Flux::toast(variant: 'danger', heading: 'Fehler', text: "Conversion \"{$profile}\" konnte nicht erstellt werden.");
|
||||
}
|
||||
};
|
||||
|
||||
$generateAllConversions = function () {
|
||||
$media = CmsMedia::find($this->editingId);
|
||||
if (! $media || ! $media->isImage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$service = app(MediaConversionService::class);
|
||||
$results = $service->generateAllConversions($media);
|
||||
|
||||
$count = count(array_filter($results));
|
||||
$media->refresh();
|
||||
Flux::toast(variant: 'success', heading: 'Alle Conversions erstellt', text: "{$count} Profile wurden generiert.");
|
||||
};
|
||||
|
||||
$deleteMedia = function (int $id) {
|
||||
$media = CmsMedia::find($id);
|
||||
if (! $media) {
|
||||
return;
|
||||
}
|
||||
|
||||
$filename = $media->filename;
|
||||
$service = app(MediaConversionService::class);
|
||||
$service->deleteAll($media);
|
||||
$media->delete();
|
||||
|
||||
$this->editingId = null;
|
||||
$this->showDetail = false;
|
||||
|
||||
Flux::toast(variant: 'success', heading: 'Gelöscht', text: "{$filename} wurde entfernt.");
|
||||
};
|
||||
|
||||
$closeDetail = function () {
|
||||
$this->showDetail = false;
|
||||
$this->editingId = null;
|
||||
};
|
||||
|
||||
?>
|
||||
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<flux:heading size="xl">Medienbibliothek</flux:heading>
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:badge color="blue">{{ $this->stats['images'] }} Bilder</flux:badge>
|
||||
<flux:badge color="amber">{{ $this->stats['pdfs'] }} PDFs</flux:badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<flux:card class="mb-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex-1">
|
||||
<livewire:admin.cms.media-library-uploader key="media-lib-uploader" />
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<div class="mb-4 flex flex-wrap items-center gap-3">
|
||||
<flux:input wire:model.live.debounce.300ms="search" placeholder="Dateiname suchen..." icon="magnifying-glass"
|
||||
size="sm" class="w-56" />
|
||||
|
||||
<flux:select wire:model.live="filterType" size="sm" class="w-36">
|
||||
<flux:select.option value="all">Alle Typen</flux:select.option>
|
||||
<flux:select.option value="image">Bilder</flux:select.option>
|
||||
<flux:select.option value="pdf">PDFs</flux:select.option>
|
||||
<flux:select.option value="document">Dokumente</flux:select.option>
|
||||
</flux:select>
|
||||
|
||||
@if (! empty($this->collections))
|
||||
<flux:select wire:model.live="filterCollection" size="sm" class="w-40">
|
||||
<flux:select.option value="">Alle Ordner</flux:select.option>
|
||||
@foreach ($this->collections as $col)
|
||||
<flux:select.option value="{{ $col }}">{{ $col }}</flux:select.option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
@endif
|
||||
|
||||
<div class="ml-auto flex items-center gap-1 rounded-lg border border-zinc-200 p-0.5 dark:border-zinc-700">
|
||||
<button wire:click="$set('viewMode', 'grid')"
|
||||
class="rounded-md p-1.5 transition {{ $viewMode === 'grid' ? 'bg-zinc-200 text-zinc-900 dark:bg-zinc-600 dark:text-white' : 'text-zinc-400 hover:text-zinc-600' }}">
|
||||
<x-heroicon-s-squares-2x2 class="h-4 w-4" />
|
||||
</button>
|
||||
<button wire:click="$set('viewMode', 'list')"
|
||||
class="rounded-md p-1.5 transition {{ $viewMode === 'list' ? 'bg-zinc-200 text-zinc-900 dark:bg-zinc-600 dark:text-white' : 'text-zinc-400 hover:text-zinc-600' }}">
|
||||
<x-heroicon-s-list-bullet class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 {{ $showDetail ? 'lg:grid-cols-3' : '' }}">
|
||||
<div class="{{ $showDetail ? 'lg:col-span-2' : '' }}">
|
||||
@if ($viewMode === 'grid')
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 {{ $showDetail ? 'lg:grid-cols-3' : 'lg:grid-cols-6' }}">
|
||||
@forelse ($this->media as $item)
|
||||
<div wire:key="media-g-{{ $item->id }}"
|
||||
class="group relative cursor-pointer overflow-hidden rounded-lg border transition-all
|
||||
{{ $editingId === $item->id ? 'border-blue-500 ring-2 ring-blue-200 dark:ring-blue-800' : 'border-zinc-200 hover:border-zinc-400 dark:border-zinc-700' }}"
|
||||
wire:click="startEdit({{ $item->id }})">
|
||||
<div class="aspect-square bg-zinc-100 dark:bg-zinc-800">
|
||||
@if ($item->isImage())
|
||||
<img src="{{ $item->hasConversion('thumb') ? $item->getConversionUrl('thumb') : $item->getUrl() }}"
|
||||
alt="{{ $item->getTranslation('alt_text', $editLocale) ?? $item->filename }}"
|
||||
class="h-full w-full object-cover" loading="lazy" />
|
||||
@elseif ($item->isPdf())
|
||||
<div class="relative h-full w-full">
|
||||
<iframe src="{{ $item->getUrl() }}#toolbar=0&navpanes=0&scrollbar=0&view=FitH"
|
||||
class="pointer-events-none h-full w-full scale-100 bg-white"
|
||||
loading="lazy"></iframe>
|
||||
<div class="absolute inset-0"></div>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex h-full w-full flex-col items-center justify-center gap-2 text-zinc-400">
|
||||
<x-heroicon-o-document class="h-10 w-10" />
|
||||
<span class="text-xs">{{ strtoupper(pathinfo($item->filename, PATHINFO_EXTENSION)) }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 p-2">
|
||||
@if ($item->isImage())
|
||||
<x-heroicon-s-photo class="h-3.5 w-3.5 shrink-0 text-blue-500" />
|
||||
@elseif ($item->isPdf())
|
||||
<x-heroicon-s-document-text class="h-3.5 w-3.5 shrink-0 text-red-500" />
|
||||
@else
|
||||
<x-heroicon-s-document class="h-3.5 w-3.5 shrink-0 text-zinc-400" />
|
||||
@endif
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-xs font-medium text-zinc-700 dark:text-zinc-300">{{ $item->filename }}</p>
|
||||
<p class="text-xs text-zinc-400">{{ $item->getHumanFileSize() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@if ($item->collection)
|
||||
<div class="absolute right-1 top-1">
|
||||
<flux:badge size="sm" color="blue" class="text-[10px]!">{{ $item->collection }}</flux:badge>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@empty
|
||||
<div class="col-span-full py-12 text-center">
|
||||
<x-heroicon-o-photo class="mx-auto mb-3 h-12 w-12 text-zinc-300" />
|
||||
<flux:text>Noch keine Medien hochgeladen.</flux:text>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
@else
|
||||
<div class="overflow-hidden rounded-lg border border-zinc-200 dark:border-zinc-700">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="border-b border-zinc-200 bg-zinc-50 text-left text-xs text-zinc-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400">
|
||||
<tr>
|
||||
<th class="w-12 px-3 py-2"></th>
|
||||
<th class="px-3 py-2">Dateiname</th>
|
||||
<th class="hidden px-3 py-2 sm:table-cell">Typ</th>
|
||||
<th class="hidden px-3 py-2 md:table-cell">Größe</th>
|
||||
<th class="hidden px-3 py-2 md:table-cell">Abmessungen</th>
|
||||
<th class="hidden px-3 py-2 lg:table-cell">Sammlung</th>
|
||||
<th class="px-3 py-2 text-right">Datum</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||
@forelse ($this->media as $item)
|
||||
<tr wire:key="media-l-{{ $item->id }}"
|
||||
class="cursor-pointer transition {{ $editingId === $item->id ? 'bg-blue-50 dark:bg-blue-900/20' : 'hover:bg-zinc-50 dark:hover:bg-zinc-800/50' }}"
|
||||
wire:click="startEdit({{ $item->id }})">
|
||||
<td class="px-3 py-1.5">
|
||||
<div class="h-8 w-8 overflow-hidden rounded border border-zinc-200 bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-800">
|
||||
@if ($item->isImage())
|
||||
<img src="{{ $item->hasConversion('thumb') ? $item->getConversionUrl('thumb') : $item->getUrl() }}"
|
||||
class="h-full w-full object-cover" loading="lazy" />
|
||||
@elseif ($item->isPdf())
|
||||
<div class="relative h-full w-full">
|
||||
<iframe src="{{ $item->getUrl() }}#toolbar=0&navpanes=0&scrollbar=0&view=FitH"
|
||||
class="pointer-events-none h-full w-full bg-white"
|
||||
loading="lazy"></iframe>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex h-full w-full items-center justify-center text-zinc-400">
|
||||
<x-heroicon-s-document class="h-4 w-4" />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-1.5">
|
||||
<span class="font-medium text-zinc-700 dark:text-zinc-300">{{ $item->filename }}</span>
|
||||
</td>
|
||||
<td class="hidden px-3 py-1.5 text-zinc-500 sm:table-cell">
|
||||
<flux:badge size="sm" :color="$item->isImage() ? 'blue' : ($item->isPdf() ? 'amber' : 'zinc')">
|
||||
{{ $item->type }}
|
||||
</flux:badge>
|
||||
</td>
|
||||
<td class="hidden px-3 py-1.5 text-zinc-500 md:table-cell">{{ $item->getHumanFileSize() }}</td>
|
||||
<td class="hidden px-3 py-1.5 text-zinc-500 md:table-cell">{{ $item->getDimensionsLabel() ?: '—' }}</td>
|
||||
<td class="hidden px-3 py-1.5 lg:table-cell">
|
||||
@if ($item->collection)
|
||||
<flux:badge size="sm" color="blue">{{ $item->collection }}</flux:badge>
|
||||
@else
|
||||
<span class="text-zinc-300">—</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-3 py-1.5 text-right text-zinc-400">{{ $item->created_at->format('d.m.Y') }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="7" class="py-12 text-center">
|
||||
<x-heroicon-o-photo class="mx-auto mb-3 h-12 w-12 text-zinc-300" />
|
||||
<flux:text>Noch keine Medien hochgeladen.</flux:text>
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($this->media->hasPages())
|
||||
<div class="mt-4">
|
||||
{{ $this->media->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($showDetail && $editingId)
|
||||
@php $editMedia = \FluxCms\Core\Models\CmsMedia::find($editingId); @endphp
|
||||
@if ($editMedia)
|
||||
<div class="lg:col-span-1">
|
||||
<flux:card>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<flux:heading size="sm">Details</flux:heading>
|
||||
<flux:button size="xs" variant="ghost" icon="x-mark" wire:click="closeDetail" />
|
||||
</div>
|
||||
|
||||
<div class="mb-4 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800">
|
||||
@if ($editMedia->isImage())
|
||||
<img src="{{ $editMedia->getUrl() }}" alt="{{ $editMedia->filename }}"
|
||||
class="w-full object-contain" style="max-height: 300px;" />
|
||||
@elseif ($editMedia->isPdf())
|
||||
<iframe src="{{ $editMedia->getUrl() }}#toolbar=0&navpanes=0"
|
||||
class="h-64 w-full bg-white" loading="lazy"></iframe>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mb-4 space-y-1 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
<p><strong>Datei:</strong> {{ $editMedia->filename }}</p>
|
||||
<p><strong>Typ:</strong> {{ $editMedia->mime_type }}</p>
|
||||
<p><strong>Größe:</strong> {{ $editMedia->getHumanFileSize() }}</p>
|
||||
@if ($editMedia->getDimensionsLabel())
|
||||
<p><strong>Abmessungen:</strong> {{ $editMedia->getDimensionsLabel() }} px</p>
|
||||
@endif
|
||||
<p><strong>Hochgeladen:</strong> {{ $editMedia->created_at->format('d.m.Y H:i') }}</p>
|
||||
<p class="break-all"><strong>URL:</strong>
|
||||
<a href="{{ $editMedia->getUrl() }}" target="_blank" class="text-blue-500 hover:underline">
|
||||
{{ $editMedia->getUrl() }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 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 class="space-y-3">
|
||||
<flux:input wire:model="mediaTitle" label="Titel" size="sm" placeholder="Anzeigename..." />
|
||||
<flux:input wire:model="altText" label="Alt-Text" size="sm" placeholder="Bildbeschreibung für SEO..." />
|
||||
<flux:input wire:model="collection" label="Ordner / Sammlung" size="sm" placeholder="z.B. hero, team, news..." />
|
||||
|
||||
<flux:button size="sm" variant="primary" wire:click="saveEdit" class="w-full">
|
||||
Speichern
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
@if ($editMedia->isImage() && $editMedia->mime_type !== 'image/svg+xml')
|
||||
<div class="mt-5 border-t border-zinc-200 pt-4 dark:border-zinc-700">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<flux:heading size="sm">Bildgrößen</flux:heading>
|
||||
<flux:button size="xs" variant="ghost" wire:click="generateAllConversions"
|
||||
wire:loading.attr="disabled">
|
||||
Alle generieren
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
@foreach ($this->profiles as $profileName => $profileConfig)
|
||||
@php
|
||||
$hasConversion = $editMedia->hasConversion($profileName);
|
||||
$conversionUrl = $hasConversion ? $editMedia->getConversionUrl($profileName) : null;
|
||||
@endphp
|
||||
<div class="flex items-center justify-between rounded-lg border border-zinc-200 px-3 py-2 dark:border-zinc-700">
|
||||
<div>
|
||||
<span class="text-sm font-medium">{{ $profileName }}</span>
|
||||
<span class="text-xs text-zinc-400">
|
||||
{{ $profileConfig['width'] }}×{{ $profileConfig['height'] }}
|
||||
{{ strtoupper($profileConfig['format'] ?? 'webp') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@if ($hasConversion)
|
||||
<flux:badge size="sm" color="green">OK</flux:badge>
|
||||
@else
|
||||
<flux:badge size="sm" color="zinc">—</flux:badge>
|
||||
@endif
|
||||
<flux:button size="xs" variant="ghost" icon="arrow-path"
|
||||
wire:click="generateConversion('{{ $profileName }}')"
|
||||
wire:loading.attr="disabled" />
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-5 border-t border-zinc-200 pt-4 dark:border-zinc-700">
|
||||
<flux:button size="sm" variant="danger" class="w-full" icon="trash"
|
||||
wire:click="deleteMedia({{ $editMedia->id }})"
|
||||
wire:confirm="'{{ $editMedia->filename }}' wirklich löschen? Alle Conversions werden ebenfalls entfernt.">
|
||||
Datei löschen
|
||||
</flux:button>
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<div>
|
||||
<flux:file-upload wire:model="uploads" multiple
|
||||
accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,.pdf,.doc,.docx,.jpg,.jpeg,.png">
|
||||
<flux:file-upload.dropzone
|
||||
heading="Dateien hochladen"
|
||||
text="Bilder (JPG, PNG, WebP, SVG) und Dokumente (PDF, DOC) — max. 10 MB pro Datei"
|
||||
with-progress />
|
||||
</flux:file-upload>
|
||||
|
||||
@if (isset($uploads) && count($uploads) > 0)
|
||||
<div class="mt-3 flex flex-wrap items-center gap-3">
|
||||
@foreach ($uploads as $index => $upload)
|
||||
<flux:file-item
|
||||
:heading="$upload->getClientOriginalName()"
|
||||
:image="(str_starts_with($upload->getMimeType() ?? '', 'image/') && $upload->isPreviewable())
|
||||
? $upload->temporaryUrl()
|
||||
: null"
|
||||
:size="$upload->getSize()">
|
||||
<x-slot name="actions">
|
||||
<flux:file-item.remove
|
||||
wire:click="removeUpload({{ $index }})"
|
||||
aria-label="Entfernen: {{ $upload->getClientOriginalName() }}" />
|
||||
</x-slot>
|
||||
</flux:file-item>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<flux:error name="uploads" />
|
||||
</div>
|
||||
124
resources/views/livewire/admin/cms/media-picker.blade.php
Normal file
124
resources/views/livewire/admin/cms/media-picker.blade.php
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
<div>
|
||||
<div class="flex items-end gap-3">
|
||||
<div class="flex-1">
|
||||
@if ($selectedMedia)
|
||||
<div class="flex items-center gap-3 rounded-lg border border-zinc-200 p-2 dark:border-zinc-700">
|
||||
@if ($selectedMedia->isImage())
|
||||
<img src="{{ $selectedMedia->hasConversion('thumb') ? $selectedMedia->getConversionUrl('thumb') : $selectedMedia->getUrl() }}"
|
||||
alt="{{ $selectedMedia->filename }}"
|
||||
class="h-16 w-16 rounded-md object-cover" />
|
||||
@elseif ($selectedMedia->isPdf())
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-md bg-red-50 dark:bg-red-900/20">
|
||||
<x-heroicon-o-document-text class="h-8 w-8 text-red-500" />
|
||||
</div>
|
||||
@endif
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||
{{ $selectedMedia->filename }}
|
||||
</p>
|
||||
<p class="text-xs text-zinc-400">
|
||||
{{ $selectedMedia->getHumanFileSize() }}
|
||||
@if ($selectedMedia->getDimensionsLabel())
|
||||
— {{ $selectedMedia->getDimensionsLabel() }}
|
||||
@endif
|
||||
</p>
|
||||
@if ($selectedMedia->isImage() && $selectedMedia->hasConversion($profile))
|
||||
@php
|
||||
$pConfig = config("flux-cms.media.profiles.{$profile}", []);
|
||||
@endphp
|
||||
<flux:badge size="sm" color="green" class="mt-1">
|
||||
{{ $profile }}: {{ $pConfig['width'] ?? '?' }}×{{ $pConfig['height'] ?? '?' }}
|
||||
</flux:badge>
|
||||
@endif
|
||||
</div>
|
||||
<flux:button size="xs" variant="ghost" icon="x-mark" wire:click="clearSelection" />
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-lg border-2 border-dashed border-zinc-300 px-4 py-3 text-center text-sm text-zinc-400 dark:border-zinc-600">
|
||||
Kein Medium ausgewählt
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<flux:button size="sm" variant="ghost" icon="photo" wire:click="openPicker">
|
||||
{{ $label }}
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
<flux:modal wire:model="showModal" class="w-full max-w-4xl space-y-4 overflow-y-auto max-h-[85vh]">
|
||||
<flux:heading size="lg">{{ $label }}</flux:heading>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<flux:input wire:model.live.debounce.300ms="search" placeholder="Suchen..." icon="magnifying-glass"
|
||||
size="sm" />
|
||||
|
||||
<flux:file-upload wire:model="quickUploads" multiple
|
||||
accept="{{ $type === 'image' ? 'image/jpeg,image/png,image/gif,image/webp,.jpg,.jpeg,.png' : ($type === 'pdf' ? '.pdf,application/pdf' : '*') }}">
|
||||
<flux:file-upload.dropzone
|
||||
heading="Neue Datei hochladen"
|
||||
text="Direkt hier hochladen und zuweisen"
|
||||
with-progress />
|
||||
</flux:file-upload>
|
||||
|
||||
@if (isset($quickUploads) && count($quickUploads) > 0)
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@foreach ($quickUploads as $index => $upload)
|
||||
<flux:file-item
|
||||
:heading="$upload->getClientOriginalName()"
|
||||
:image="(str_starts_with($upload->getMimeType() ?? '', 'image/') && $upload->isPreviewable())
|
||||
? $upload->temporaryUrl()
|
||||
: null"
|
||||
:size="$upload->getSize()">
|
||||
<x-slot name="actions">
|
||||
<flux:file-item.remove wire:click="removeQuickUpload({{ $index }})" />
|
||||
</x-slot>
|
||||
</flux:file-item>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<flux:error name="quickUploads" />
|
||||
</div>
|
||||
|
||||
@php $profileConfig = config("flux-cms.media.profiles.{$profile}", []); @endphp
|
||||
@if (!empty($profileConfig))
|
||||
<flux:text class="text-xs">
|
||||
Profil <strong>{{ $profile }}</strong>: {{ $profileConfig['width'] }}×{{ $profileConfig['height'] }} px,
|
||||
{{ strtoupper($profileConfig['format'] ?? 'webp') }},
|
||||
Qualität {{ $profileConfig['quality'] ?? 85 }}%
|
||||
</flux:text>
|
||||
@endif
|
||||
|
||||
<div class="grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6">
|
||||
@forelse ($mediaItems as $item)
|
||||
<div wire:key="pick-{{ $item->id }}"
|
||||
class="group cursor-pointer overflow-hidden rounded-lg border transition-all
|
||||
{{ $value === $item->id ? 'border-blue-500 ring-2 ring-blue-200' : 'border-zinc-200 hover:border-blue-300 dark:border-zinc-700' }}"
|
||||
wire:click="selectMedia({{ $item->id }})">
|
||||
<div class="aspect-square bg-zinc-100 dark:bg-zinc-800">
|
||||
@if ($item->isImage())
|
||||
<img src="{{ $item->hasConversion('thumb') ? $item->getConversionUrl('thumb') : $item->getUrl() }}"
|
||||
alt="{{ $item->filename }}" class="h-full w-full object-cover" loading="lazy" />
|
||||
@elseif ($item->isPdf())
|
||||
<div class="flex h-full w-full items-center justify-center text-red-500">
|
||||
<x-heroicon-o-document-text class="h-8 w-8" />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="p-1.5">
|
||||
<p class="truncate text-[11px] text-zinc-600 dark:text-zinc-400">{{ $item->filename }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="col-span-full py-8 text-center">
|
||||
<flux:text>Keine Medien gefunden.</flux:text>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
@if ($mediaItems->hasPages())
|
||||
<div class="mt-2">
|
||||
{{ $mediaItems->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</flux:modal>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
{{-- B2in: Media-Playlist --}}
|
||||
@php
|
||||
$mediaItems = $items->get('media', collect());
|
||||
@endphp
|
||||
|
||||
<flux:card>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('Media-Playlist') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Bilder und Videos werden rotierend angezeigt') }}</flux:subheading>
|
||||
</div>
|
||||
<flux:button wire:click="openItemModal(null, 'media')" icon="plus">
|
||||
{{ __('Medium hinzufügen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
@if($mediaItems->isEmpty())
|
||||
<div class="text-center py-12 text-zinc-500 dark:text-zinc-400">
|
||||
<flux:icon.photo class="w-16 h-16 mx-auto mb-4 opacity-50" />
|
||||
<p>{{ __('Noch keine Medien vorhanden.') }}</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
@foreach($mediaItems as $index => $item)
|
||||
<div wire:key="item-{{ $item->id }}"
|
||||
class="flex items-center gap-4 p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 transition">
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
@if($index > 0)
|
||||
<flux:button wire:click="moveItem({{ $item->id }}, 'up')" size="xs" variant="ghost" icon="chevron-up" class="text-zinc-400 hover:text-zinc-600"></flux:button>
|
||||
@endif
|
||||
@if($index < count($mediaItems) - 1)
|
||||
<flux:button wire:click="moveItem({{ $item->id }}, 'down')" size="xs" variant="ghost" icon="chevron-down" class="text-zinc-400 hover:text-zinc-600"></flux:button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<flux:badge :color="$item->is_active ? 'green' : 'zinc'" size="sm">
|
||||
{{ $item->is_active ? __('Aktiv') : __('Inaktiv') }}
|
||||
</flux:badge>
|
||||
<flux:badge color="{{ ($item->content['media_type'] ?? 'image') === 'video' ? 'purple' : 'sky' }}" size="sm">
|
||||
{{ ($item->content['media_type'] ?? 'image') === 'video' ? 'Video' : 'Bild' }}
|
||||
</flux:badge>
|
||||
<flux:badge color="zinc" size="sm">
|
||||
{{ ucfirst($item->content['category'] ?? '–') }}
|
||||
</flux:badge>
|
||||
<span class="font-semibold text-sm truncate">{{ $item->content['headline'] ?? '–' }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-zinc-600 dark:text-zinc-400 space-x-4">
|
||||
<span>{{ $item->content['subline'] ?? '' }}</span>
|
||||
@if(($item->content['media_type'] ?? 'image') === 'image')
|
||||
<span>{{ $item->content['duration_seconds'] ?? 10 }}s</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<flux:button wire:click="toggleItemStatus({{ $item->id }})" size="sm" variant="ghost" :icon="$item->is_active ? 'eye-slash' : 'eye'"></flux:button>
|
||||
<flux:button wire:click="openItemModal({{ $item->id }})" size="sm" variant="ghost" icon="pencil"></flux:button>
|
||||
<flux:button wire:click="deleteItem({{ $item->id }})" wire:confirm="Möchten Sie diesen Eintrag wirklich löschen?" size="sm" variant="ghost" icon="trash" class="text-red-600 hover:text-red-700"></flux:button>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</flux:card>
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
{{-- Offers: Slides --}}
|
||||
@php
|
||||
$slides = $items->get('slide', collect());
|
||||
@endphp
|
||||
|
||||
<flux:card>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('Slides') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Angebots-Slides werden in der angegebenen Reihenfolge angezeigt') }}</flux:subheading>
|
||||
</div>
|
||||
<flux:button wire:click="openItemModal(null, 'slide')" icon="plus">
|
||||
{{ __('Slide hinzufügen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
@if($slides->isEmpty())
|
||||
<div class="text-center py-12 text-zinc-500 dark:text-zinc-400">
|
||||
<flux:icon.presentation-chart-bar class="w-16 h-16 mx-auto mb-4 opacity-50" />
|
||||
<p>{{ __('Noch keine Slides vorhanden.') }}</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
@foreach($slides as $index => $item)
|
||||
<div wire:key="item-{{ $item->id }}"
|
||||
class="flex items-center gap-4 p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 transition">
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
@if($index > 0)
|
||||
<flux:button wire:click="moveItem({{ $item->id }}, 'up')" size="xs" variant="ghost" icon="chevron-up" class="text-zinc-400 hover:text-zinc-600"></flux:button>
|
||||
@endif
|
||||
@if($index < count($slides) - 1)
|
||||
<flux:button wire:click="moveItem({{ $item->id }}, 'down')" size="xs" variant="ghost" icon="chevron-down" class="text-zinc-400 hover:text-zinc-600"></flux:button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<flux:badge :color="$item->is_active ? 'green' : 'zinc'" size="sm">
|
||||
{{ $item->is_active ? __('Aktiv') : __('Inaktiv') }}
|
||||
</flux:badge>
|
||||
<flux:badge color="amber" size="sm">
|
||||
{{ match($item->content['type'] ?? '') {
|
||||
'intro' => 'Intro',
|
||||
'product-hero' => 'Produkt-Hero',
|
||||
'product-details' => 'Produkt-Details',
|
||||
'product-impulse' => 'Produkt-Impuls',
|
||||
default => $item->content['type'] ?? '–',
|
||||
} }}
|
||||
</flux:badge>
|
||||
<span class="font-semibold text-sm">{{ $item->content['title'] ?? '–' }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-zinc-600 dark:text-zinc-400 space-x-4">
|
||||
@if(!empty($item->content['price']))
|
||||
<span class="font-medium">{{ $item->content['price'] }}</span>
|
||||
@endif
|
||||
<span>{{ number_format(($item->content['duration'] ?? 8000) / 1000, 1) }}s</span>
|
||||
@if(!empty($item->content['badge_text']))
|
||||
<span>{{ $item->content['badge_text'] }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<flux:button wire:click="toggleItemStatus({{ $item->id }})" size="sm" variant="ghost" :icon="$item->is_active ? 'eye-slash' : 'eye'"></flux:button>
|
||||
<flux:button wire:click="openItemModal({{ $item->id }})" size="sm" variant="ghost" icon="pencil"></flux:button>
|
||||
<flux:button wire:click="deleteItem({{ $item->id }})" wire:confirm="Möchten Sie diesen Eintrag wirklich löschen?" size="sm" variant="ghost" icon="trash" class="text-red-600 hover:text-red-700"></flux:button>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</flux:card>
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
{{-- Video-Display: Video-Playlist + Footer-Inhalte --}}
|
||||
@php
|
||||
$videos = $items->get('video', collect());
|
||||
$footers = $items->get('footer', collect());
|
||||
@endphp
|
||||
|
||||
{{-- Video-Playlist --}}
|
||||
<flux:card class="mb-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('Video-Playlist') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Videos werden in der angegebenen Reihenfolge abgespielt') }}</flux:subheading>
|
||||
</div>
|
||||
<flux:button wire:click="openItemModal(null, 'video')" icon="plus">
|
||||
{{ __('Video hinzufügen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
@if($videos->isEmpty())
|
||||
<div class="text-center py-12 text-zinc-500 dark:text-zinc-400">
|
||||
<flux:icon.film class="w-16 h-16 mx-auto mb-4 opacity-50" />
|
||||
<p>{{ __('Noch keine Videos vorhanden.') }}</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
@foreach($videos as $index => $item)
|
||||
<div wire:key="item-{{ $item->id }}"
|
||||
class="flex items-center gap-4 p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 transition">
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
@if($index > 0)
|
||||
<flux:button wire:click="moveItem({{ $item->id }}, 'up')" size="xs" variant="ghost" icon="chevron-up" class="text-zinc-400 hover:text-zinc-600"></flux:button>
|
||||
@endif
|
||||
@if($index < count($videos) - 1)
|
||||
<flux:button wire:click="moveItem({{ $item->id }}, 'down')" size="xs" variant="ghost" icon="chevron-down" class="text-zinc-400 hover:text-zinc-600"></flux:button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<flux:badge :color="$item->is_active ? 'green' : 'zinc'" size="sm">
|
||||
{{ $item->is_active ? __('Aktiv') : __('Inaktiv') }}
|
||||
</flux:badge>
|
||||
<span class="font-semibold text-sm">{{ $item->content['title'] ?? $item->content['filename'] ?? '–' }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-zinc-600 dark:text-zinc-400 space-x-4">
|
||||
<span>{{ $item->content['filename'] ?? '–' }}</span>
|
||||
<span>Position: {{ $item->content['position'] ?? 25 }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<flux:button wire:click="toggleItemStatus({{ $item->id }})" size="sm" variant="ghost" :icon="$item->is_active ? 'eye-slash' : 'eye'"></flux:button>
|
||||
<flux:button wire:click="openItemModal({{ $item->id }})" size="sm" variant="ghost" icon="pencil"></flux:button>
|
||||
<flux:button wire:click="deleteItem({{ $item->id }})" wire:confirm="Möchten Sie diesen Eintrag wirklich löschen?" size="sm" variant="ghost" icon="trash" class="text-red-600 hover:text-red-700"></flux:button>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</flux:card>
|
||||
|
||||
{{-- Footer-Inhalte --}}
|
||||
<flux:card>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('Footer-Inhalte') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Inhalte werden im Footer rotiert') }}</flux:subheading>
|
||||
</div>
|
||||
<flux:button wire:click="openItemModal(null, 'footer')" icon="plus">
|
||||
{{ __('Inhalt hinzufügen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
@if($footers->isEmpty())
|
||||
<div class="text-center py-12 text-zinc-500 dark:text-zinc-400">
|
||||
<flux:icon.document-text class="w-16 h-16 mx-auto mb-4 opacity-50" />
|
||||
<p>{{ __('Noch keine Footer-Inhalte vorhanden.') }}</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
@foreach($footers as $index => $item)
|
||||
<div wire:key="item-{{ $item->id }}"
|
||||
class="flex items-center gap-4 p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 transition">
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
@if($index > 0)
|
||||
<flux:button wire:click="moveItem({{ $item->id }}, 'up')" size="xs" variant="ghost" icon="chevron-up" class="text-zinc-400 hover:text-zinc-600"></flux:button>
|
||||
@endif
|
||||
@if($index < count($footers) - 1)
|
||||
<flux:button wire:click="moveItem({{ $item->id }}, 'down')" size="xs" variant="ghost" icon="chevron-down" class="text-zinc-400 hover:text-zinc-600"></flux:button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<flux:badge :color="$item->is_active ? 'green' : 'zinc'" size="sm">
|
||||
{{ $item->is_active ? __('Aktiv') : __('Inaktiv') }}
|
||||
</flux:badge>
|
||||
<span class="font-semibold text-sm">{{ $item->content['headline'] ?? '–' }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-zinc-600 dark:text-zinc-400">
|
||||
{{ $item->content['subline'] ?? '' }}
|
||||
@if(!empty($item->content['url']))
|
||||
<span class="ml-2">{{ Str::limit($item->content['url'], 40) }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<flux:button wire:click="toggleItemStatus({{ $item->id }})" size="sm" variant="ghost" :icon="$item->is_active ? 'eye-slash' : 'eye'"></flux:button>
|
||||
<flux:button wire:click="openItemModal({{ $item->id }})" size="sm" variant="ghost" icon="pencil"></flux:button>
|
||||
<flux:button wire:click="deleteItem({{ $item->id }})" wire:confirm="Möchten Sie diesen Eintrag wirklich löschen?" size="sm" variant="ghost" icon="trash" class="text-red-600 hover:text-red-700"></flux:button>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</flux:card>
|
||||
720
resources/views/livewire/admin/cms/projects-index.blade.php
Normal file
720
resources/views/livewire/admin/cms/projects-index.blade.php
Normal file
|
|
@ -0,0 +1,720 @@
|
|||
<?php
|
||||
|
||||
use App\Models\CmsProject;
|
||||
use Flux\Flux;
|
||||
use Illuminate\Support\Str;
|
||||
use function Livewire\Volt\{layout, title, state, computed, on};
|
||||
|
||||
layout('components.layouts.app');
|
||||
title('Projekte verwalten');
|
||||
|
||||
state([
|
||||
'search' => '',
|
||||
'showForm' => false,
|
||||
'editingId' => null,
|
||||
'editLocale' => 'de',
|
||||
'activeTab' => 'basic',
|
||||
|
||||
'slug' => '',
|
||||
'projectTitle' => '',
|
||||
'location' => '',
|
||||
'status' => '',
|
||||
'launch_date' => '',
|
||||
'price_from_aed' => '',
|
||||
'currency' => 'AED',
|
||||
'image' => '',
|
||||
'is_published' => true,
|
||||
'order' => 0,
|
||||
|
||||
'highlights' => [''],
|
||||
'quick_facts' => [['icon' => 'home-modern', 'label' => '', 'value' => '']],
|
||||
'investTitle' => '',
|
||||
'investText' => '',
|
||||
'investViews' => [''],
|
||||
'galleryItems' => [''],
|
||||
'locTitle' => '',
|
||||
'locMapUrl' => '',
|
||||
'locPoints' => [''],
|
||||
'contactTitle' => '',
|
||||
'contactSubtitle' => '',
|
||||
'contactOptions' => [['key' => '', 'value' => '']],
|
||||
|
||||
'trustTitle' => '',
|
||||
'trustIntro' => '',
|
||||
'trustColumns' => [
|
||||
['icon' => 'heroicon-o-lock-closed', 'title' => '', 'text' => ''],
|
||||
['icon' => 'heroicon-o-building-library', 'title' => '', 'text' => ''],
|
||||
['icon' => 'heroicon-o-chart-bar', 'title' => '', 'text' => ''],
|
||||
],
|
||||
'trustCtaUrl' => '',
|
||||
'trustCtaLabel' => '',
|
||||
'furnitureTitle' => '',
|
||||
'furnitureText' => '',
|
||||
'furnitureButtonText' => '',
|
||||
'furnitureButtonLink' => '',
|
||||
]);
|
||||
|
||||
on(['media-selected' => function ($mediaId, $url, $field) {
|
||||
$media = $mediaId ? \FluxCms\Core\Models\CmsMedia::find($mediaId) : null;
|
||||
if (! $media) {
|
||||
return;
|
||||
}
|
||||
if ($field === 'project_image') {
|
||||
$this->image = $media->filename;
|
||||
}
|
||||
if (str_starts_with($field, 'gallery_')) {
|
||||
$idx = (int) str_replace('gallery_', '', $field);
|
||||
if (isset($this->galleryItems[$idx])) {
|
||||
$this->galleryItems[$idx] = $media->filename;
|
||||
}
|
||||
}
|
||||
}]);
|
||||
|
||||
$projects = computed(
|
||||
fn () => CmsProject::query()
|
||||
->when($this->search, fn ($q) => $q->where('slug', 'like', "%{$this->search}%"))
|
||||
->ordered()
|
||||
->get(),
|
||||
);
|
||||
|
||||
$resetForm = function () {
|
||||
$this->editingId = null;
|
||||
$this->activeTab = 'basic';
|
||||
$this->slug = '';
|
||||
$this->projectTitle = '';
|
||||
$this->location = '';
|
||||
$this->status = '';
|
||||
$this->launch_date = '';
|
||||
$this->price_from_aed = '';
|
||||
$this->currency = 'AED';
|
||||
$this->image = '';
|
||||
$this->is_published = true;
|
||||
$this->order = 0;
|
||||
$this->highlights = [''];
|
||||
$this->quick_facts = [['icon' => 'home-modern', 'label' => '', 'value' => '']];
|
||||
$this->investTitle = '';
|
||||
$this->investText = '';
|
||||
$this->investViews = [''];
|
||||
$this->galleryItems = [''];
|
||||
$this->locTitle = '';
|
||||
$this->locMapUrl = '';
|
||||
$this->locPoints = [''];
|
||||
$this->contactTitle = '';
|
||||
$this->contactSubtitle = '';
|
||||
$this->contactOptions = [['key' => '', 'value' => '']];
|
||||
$this->trustTitle = '';
|
||||
$this->trustIntro = '';
|
||||
$this->trustColumns = [
|
||||
['icon' => 'heroicon-o-lock-closed', 'title' => '', 'text' => ''],
|
||||
['icon' => 'heroicon-o-building-library', 'title' => '', 'text' => ''],
|
||||
['icon' => 'heroicon-o-chart-bar', 'title' => '', 'text' => ''],
|
||||
];
|
||||
$this->trustCtaUrl = '';
|
||||
$this->trustCtaLabel = '';
|
||||
$this->furnitureTitle = '';
|
||||
$this->furnitureText = '';
|
||||
$this->furnitureButtonText = '';
|
||||
$this->furnitureButtonLink = '';
|
||||
};
|
||||
|
||||
$loadProjectIntoForm = function (CmsProject $project, string $locale) {
|
||||
$this->slug = $project->slug;
|
||||
$this->image = $project->image ?? '';
|
||||
$this->status = $project->status ?? '';
|
||||
$this->launch_date = $project->launch_date?->format('Y-m-d') ?? '';
|
||||
$this->price_from_aed = $project->price_from_aed ?? '';
|
||||
$this->currency = $project->currency ?? 'AED';
|
||||
$this->is_published = $project->is_published;
|
||||
$this->order = $project->order ?? 0;
|
||||
|
||||
$this->projectTitle = $project->getTranslation('title', $locale) ?? '';
|
||||
$this->location = $project->getTranslation('location', $locale) ?? '';
|
||||
|
||||
$hl = $project->getTranslation('highlights', $locale);
|
||||
$this->highlights = is_array($hl) && count($hl) > 0 ? $hl : [''];
|
||||
|
||||
$this->quick_facts = is_array($project->quick_facts) && count($project->quick_facts) > 0
|
||||
? $project->quick_facts
|
||||
: [['icon' => 'home-modern', 'label' => '', 'value' => '']];
|
||||
|
||||
$ic = $project->getTranslation('investment_case', $locale);
|
||||
$this->investTitle = $ic['title'] ?? '';
|
||||
$this->investText = $ic['text'] ?? '';
|
||||
$this->investViews = is_array($ic['views'] ?? null) && count($ic['views']) > 0 ? $ic['views'] : [''];
|
||||
|
||||
$this->galleryItems = is_array($project->gallery) && count($project->gallery) > 0
|
||||
? $project->gallery
|
||||
: [''];
|
||||
|
||||
$li = $project->getTranslation('location_info', $locale);
|
||||
$this->locTitle = $li['title'] ?? '';
|
||||
$this->locMapUrl = $li['map_url'] ?? '';
|
||||
$this->locPoints = is_array($li['points'] ?? null) && count($li['points']) > 0 ? $li['points'] : [''];
|
||||
|
||||
$ct = $project->getTranslation('contact', $locale);
|
||||
$this->contactTitle = $ct['title'] ?? '';
|
||||
$this->contactSubtitle = $ct['subtitle'] ?? '';
|
||||
$opts = $ct['options'] ?? [];
|
||||
$this->contactOptions = count($opts) > 0
|
||||
? collect($opts)->map(fn ($v, $k) => ['key' => $k, 'value' => $v])->values()->toArray()
|
||||
: [['key' => '', 'value' => '']];
|
||||
|
||||
$it = $project->getTranslation('investor_trust', $locale) ?? [];
|
||||
$this->trustTitle = $it['title'] ?? '';
|
||||
$this->trustIntro = $it['intro'] ?? '';
|
||||
$trustCols = $it['columns'] ?? [];
|
||||
$this->trustColumns = is_array($trustCols) && count($trustCols) > 0
|
||||
? $trustCols
|
||||
: [
|
||||
['icon' => 'heroicon-o-lock-closed', 'title' => '', 'text' => ''],
|
||||
['icon' => 'heroicon-o-building-library', 'title' => '', 'text' => ''],
|
||||
['icon' => 'heroicon-o-chart-bar', 'title' => '', 'text' => ''],
|
||||
];
|
||||
$this->trustCtaUrl = $it['cta_url'] ?? '';
|
||||
$this->trustCtaLabel = $it['cta_label'] ?? '';
|
||||
|
||||
$fb = $project->getTranslation('furniture_benefit', $locale) ?? [];
|
||||
$this->furnitureTitle = $fb['title'] ?? '';
|
||||
$this->furnitureText = $fb['text'] ?? '';
|
||||
$this->furnitureButtonText = $fb['button_text'] ?? '';
|
||||
$this->furnitureButtonLink = $fb['button_link'] ?? '';
|
||||
};
|
||||
|
||||
$openCreate = function () {
|
||||
$this->resetForm();
|
||||
$this->showForm = true;
|
||||
};
|
||||
|
||||
$openEdit = function (int $id) {
|
||||
$project = CmsProject::find($id);
|
||||
if (! $project) {
|
||||
return;
|
||||
}
|
||||
$this->editingId = $id;
|
||||
$this->activeTab = 'basic';
|
||||
$this->loadProjectIntoForm($project, $this->editLocale);
|
||||
$this->showForm = true;
|
||||
};
|
||||
|
||||
$duplicateProject = function (int $id) {
|
||||
$project = CmsProject::find($id);
|
||||
if (! $project) {
|
||||
return;
|
||||
}
|
||||
$this->editingId = null;
|
||||
$this->activeTab = 'basic';
|
||||
$this->loadProjectIntoForm($project, $this->editLocale);
|
||||
$this->slug = $project->slug . '-kopie-' . Str::random(4);
|
||||
$this->showForm = true;
|
||||
Flux::toast(heading: 'Dupliziert', text: 'Projekt wurde als Kopie geladen. Bitte Slug anpassen und speichern.');
|
||||
};
|
||||
|
||||
$switchLocale = function (string $locale) {
|
||||
$this->editLocale = $locale;
|
||||
if ($this->editingId) {
|
||||
$project = CmsProject::find($this->editingId);
|
||||
if ($project) {
|
||||
$this->projectTitle = $project->getTranslation('title', $locale) ?? '';
|
||||
$this->location = $project->getTranslation('location', $locale) ?? '';
|
||||
|
||||
$hl = $project->getTranslation('highlights', $locale);
|
||||
$this->highlights = is_array($hl) && count($hl) > 0 ? $hl : [''];
|
||||
|
||||
$ic = $project->getTranslation('investment_case', $locale);
|
||||
$this->investTitle = $ic['title'] ?? '';
|
||||
$this->investText = $ic['text'] ?? '';
|
||||
$this->investViews = is_array($ic['views'] ?? null) && count($ic['views']) > 0 ? $ic['views'] : [''];
|
||||
|
||||
$li = $project->getTranslation('location_info', $locale);
|
||||
$this->locTitle = $li['title'] ?? '';
|
||||
$this->locMapUrl = $li['map_url'] ?? '';
|
||||
$this->locPoints = is_array($li['points'] ?? null) && count($li['points']) > 0 ? $li['points'] : [''];
|
||||
|
||||
$ct = $project->getTranslation('contact', $locale);
|
||||
$this->contactTitle = $ct['title'] ?? '';
|
||||
$this->contactSubtitle = $ct['subtitle'] ?? '';
|
||||
$opts = $ct['options'] ?? [];
|
||||
$this->contactOptions = count($opts) > 0
|
||||
? collect($opts)->map(fn ($v, $k) => ['key' => $k, 'value' => $v])->values()->toArray()
|
||||
: [['key' => '', 'value' => '']];
|
||||
|
||||
$it = $project->getTranslation('investor_trust', $locale) ?? [];
|
||||
$this->trustTitle = $it['title'] ?? '';
|
||||
$this->trustIntro = $it['intro'] ?? '';
|
||||
$trustCols = $it['columns'] ?? [];
|
||||
$this->trustColumns = is_array($trustCols) && count($trustCols) > 0
|
||||
? $trustCols
|
||||
: [
|
||||
['icon' => 'heroicon-o-lock-closed', 'title' => '', 'text' => ''],
|
||||
['icon' => 'heroicon-o-building-library', 'title' => '', 'text' => ''],
|
||||
['icon' => 'heroicon-o-chart-bar', 'title' => '', 'text' => ''],
|
||||
];
|
||||
$this->trustCtaUrl = $it['cta_url'] ?? '';
|
||||
$this->trustCtaLabel = $it['cta_label'] ?? '';
|
||||
|
||||
$fb = $project->getTranslation('furniture_benefit', $locale) ?? [];
|
||||
$this->furnitureTitle = $fb['title'] ?? '';
|
||||
$this->furnitureText = $fb['text'] ?? '';
|
||||
$this->furnitureButtonText = $fb['button_text'] ?? '';
|
||||
$this->furnitureButtonLink = $fb['button_link'] ?? '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$addHighlight = fn () => $this->highlights[] = '';
|
||||
$removeHighlight = function (int $i) { unset($this->highlights[$i]); $this->highlights = array_values($this->highlights); };
|
||||
|
||||
$addQuickFact = fn () => $this->quick_facts[] = ['icon' => 'home-modern', 'label' => '', 'value' => ''];
|
||||
$removeQuickFact = function (int $i) { unset($this->quick_facts[$i]); $this->quick_facts = array_values($this->quick_facts); };
|
||||
|
||||
$addInvestView = fn () => $this->investViews[] = '';
|
||||
$removeInvestView = function (int $i) { unset($this->investViews[$i]); $this->investViews = array_values($this->investViews); };
|
||||
|
||||
$addGalleryItem = fn () => $this->galleryItems[] = '';
|
||||
$removeGalleryItem = function (int $i) { unset($this->galleryItems[$i]); $this->galleryItems = array_values($this->galleryItems); };
|
||||
|
||||
$addLocPoint = fn () => $this->locPoints[] = '';
|
||||
$removeLocPoint = function (int $i) { unset($this->locPoints[$i]); $this->locPoints = array_values($this->locPoints); };
|
||||
|
||||
$addContactOption = fn () => $this->contactOptions[] = ['key' => '', 'value' => ''];
|
||||
$removeContactOption = function (int $i) { unset($this->contactOptions[$i]); $this->contactOptions = array_values($this->contactOptions); };
|
||||
|
||||
$addTrustColumn = fn () => $this->trustColumns[] = ['icon' => 'heroicon-o-lock-closed', 'title' => '', 'text' => ''];
|
||||
$removeTrustColumn = function (int $i) { unset($this->trustColumns[$i]); $this->trustColumns = array_values($this->trustColumns); };
|
||||
|
||||
$save = function () {
|
||||
$validated = validator([
|
||||
'slug' => $this->slug,
|
||||
'projectTitle' => $this->projectTitle,
|
||||
'location' => $this->location,
|
||||
'status' => $this->status,
|
||||
'launch_date' => $this->launch_date,
|
||||
'price_from_aed' => $this->price_from_aed,
|
||||
'currency' => $this->currency,
|
||||
'image' => $this->image,
|
||||
'is_published' => $this->is_published,
|
||||
'order' => $this->order,
|
||||
], [
|
||||
'slug' => 'required|string|max:255',
|
||||
'projectTitle' => 'required|string|max:500',
|
||||
'location' => 'nullable|string|max:500',
|
||||
'status' => 'nullable|string|max:100',
|
||||
'launch_date' => 'nullable|date',
|
||||
'price_from_aed' => 'nullable|integer|min:0',
|
||||
'currency' => 'nullable|string|max:10',
|
||||
'image' => 'nullable|string|max:500',
|
||||
'is_published' => 'boolean',
|
||||
'order' => 'integer|min:0',
|
||||
])->validate();
|
||||
|
||||
$project = $this->editingId
|
||||
? CmsProject::findOrFail($this->editingId)
|
||||
: CmsProject::query()->make();
|
||||
|
||||
$project->slug = $validated['slug'];
|
||||
$project->setTranslation('title', $this->editLocale, $validated['projectTitle']);
|
||||
$project->setTranslation('location', $this->editLocale, $validated['location'] ?? '');
|
||||
$project->status = $validated['status'] ?? null;
|
||||
$project->launch_date = $validated['launch_date'] ?: null;
|
||||
$project->price_from_aed = $validated['price_from_aed'] ?: null;
|
||||
$project->currency = $validated['currency'] ?? 'AED';
|
||||
$project->image = $validated['image'] ?? null;
|
||||
$project->is_published = $validated['is_published'];
|
||||
$project->order = $validated['order'];
|
||||
|
||||
$project->setTranslation('highlights', $this->editLocale, array_values(array_filter($this->highlights, fn ($h) => trim($h) !== '')));
|
||||
|
||||
$project->quick_facts = collect($this->quick_facts)
|
||||
->filter(fn ($f) => ! empty($f['label']) || ! empty($f['value']))
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
$project->setTranslation('investment_case', $this->editLocale, [
|
||||
'title' => $this->investTitle,
|
||||
'text' => $this->investText,
|
||||
'views' => array_values(array_filter($this->investViews, fn ($v) => trim($v) !== '')),
|
||||
]);
|
||||
|
||||
$project->gallery = array_values(array_filter($this->galleryItems, fn ($g) => trim($g) !== ''));
|
||||
|
||||
$project->setTranslation('location_info', $this->editLocale, [
|
||||
'title' => $this->locTitle,
|
||||
'map_url' => $this->locMapUrl,
|
||||
'points' => array_values(array_filter($this->locPoints, fn ($p) => trim($p) !== '')),
|
||||
]);
|
||||
|
||||
$opts = [];
|
||||
foreach ($this->contactOptions as $opt) {
|
||||
if ($opt['value'] !== '') {
|
||||
$opts[$opt['key']] = $opt['value'];
|
||||
}
|
||||
}
|
||||
$project->setTranslation('contact', $this->editLocale, [
|
||||
'title' => $this->contactTitle,
|
||||
'subtitle' => $this->contactSubtitle,
|
||||
'options' => $opts,
|
||||
]);
|
||||
|
||||
$trustCols = collect($this->trustColumns)
|
||||
->filter(fn ($c) => ! empty(trim($c['title'] ?? '')) || ! empty(trim($c['text'] ?? '')))
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
$project->setTranslation('investor_trust', $this->editLocale, [
|
||||
'title' => $this->trustTitle,
|
||||
'intro' => $this->trustIntro,
|
||||
'columns' => $trustCols,
|
||||
'cta_url' => $this->trustCtaUrl,
|
||||
'cta_label' => $this->trustCtaLabel,
|
||||
]);
|
||||
|
||||
$project->setTranslation('furniture_benefit', $this->editLocale, [
|
||||
'title' => $this->furnitureTitle,
|
||||
'text' => $this->furnitureText,
|
||||
'button_text' => $this->furnitureButtonText,
|
||||
'button_link' => $this->furnitureButtonLink,
|
||||
]);
|
||||
|
||||
$project->save();
|
||||
|
||||
$this->showForm = false;
|
||||
$this->editingId = null;
|
||||
|
||||
Flux::toast(variant: 'success', heading: 'Gespeichert', text: $project->slug . ' wurde erfolgreich gespeichert.');
|
||||
};
|
||||
|
||||
$deleteProject = function (int $id) {
|
||||
$project = CmsProject::find($id);
|
||||
if (! $project) {
|
||||
return;
|
||||
}
|
||||
$slug = $project->slug;
|
||||
$project->delete();
|
||||
Flux::toast(variant: 'success', heading: 'Gelöscht', text: "{$slug} wurde entfernt.");
|
||||
};
|
||||
|
||||
$cancelForm = function () {
|
||||
$this->showForm = false;
|
||||
$this->editingId = null;
|
||||
};
|
||||
|
||||
$setActiveTab = function (string $tab): void {
|
||||
$this->activeTab = $tab;
|
||||
};
|
||||
|
||||
?>
|
||||
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<flux:heading size="xl">Projekte (Immobilien)</flux:heading>
|
||||
<flux:button variant="primary" icon="plus" wire:click="openCreate">Neues Projekt</flux:button>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<flux:input wire:model.live.debounce.300ms="search" placeholder="Projekt 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 ? 'Projekt bearbeiten' : 'Neues Projekt' }}</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>
|
||||
|
||||
{{-- Explizite Tab-Steuerung: zuverlässiger als nur wire:model auf ui-tabs (Livewire-Sync) --}}
|
||||
<div class="mb-4 flex flex-wrap gap-1 rounded-lg bg-zinc-800/5 p-1 dark:bg-white/10">
|
||||
<flux:button size="sm" type="button" :variant="$activeTab === 'basic' ? 'primary' : 'ghost'" icon="information-circle" wire:click="setActiveTab('basic')">Grunddaten</flux:button>
|
||||
<flux:button size="sm" type="button" :variant="$activeTab === 'facts' ? 'primary' : 'ghost'" icon="list-bullet" wire:click="setActiveTab('facts')">Quick Facts</flux:button>
|
||||
<flux:button size="sm" type="button" :variant="$activeTab === 'invest' ? 'primary' : 'ghost'" icon="chart-bar" wire:click="setActiveTab('invest')">Investment Case</flux:button>
|
||||
<flux:button size="sm" type="button" :variant="$activeTab === 'gallery' ? 'primary' : 'ghost'" icon="photo" wire:click="setActiveTab('gallery')">Galerie</flux:button>
|
||||
<flux:button size="sm" type="button" :variant="$activeTab === 'location' ? 'primary' : 'ghost'" icon="map-pin" wire:click="setActiveTab('location')">Location</flux:button>
|
||||
<flux:button size="sm" type="button" :variant="$activeTab === 'contact' ? 'primary' : 'ghost'" icon="envelope" wire:click="setActiveTab('contact')">Kontakt</flux:button>
|
||||
<flux:button size="sm" type="button" :variant="$activeTab === 'trust_synergy' ? 'primary' : 'ghost'" icon="shield-check" wire:click="setActiveTab('trust_synergy')">Trust & Möbel</flux:button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4" wire:key="project-form-panel-{{ $activeTab }}">
|
||||
{{-- TAB: Grunddaten --}}
|
||||
@if ($activeTab === 'basic')
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<flux:input wire:model="slug" label="Slug" placeholder="azizi-creek-views-4" />
|
||||
<flux:input wire:model="projectTitle" label="Titel ({{ strtoupper($editLocale) }})" placeholder="Projektname" />
|
||||
<flux:input wire:model="location" label="Standort ({{ strtoupper($editLocale) }})" placeholder="Dubai, UAE" />
|
||||
<flux:input wire:model="status" label="Status" placeholder="z.B. NEW LAUNCH" />
|
||||
<flux:input wire:model="launch_date" label="Launch-Datum" type="date" />
|
||||
<flux:input wire:model="price_from_aed" label="Preis ab (AED)" type="number" placeholder="0" />
|
||||
<flux:input wire:model="currency" label="Währung" placeholder="AED" />
|
||||
<flux:input wire:model="order" label="Sortierung" type="number" />
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300">Hauptbild</label>
|
||||
<div class="flex items-center gap-3">
|
||||
@if ($image)
|
||||
<div class="h-16 w-24 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" />
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex-1">
|
||||
<livewire:admin.cms.media-picker :value="null" field="project_image" type="image" profile="card" label="Projektbild wählen" :key="'proj-img-' . ($editingId ?? 'new')" />
|
||||
</div>
|
||||
</div>
|
||||
<flux:input wire:model="image" size="sm" class="mt-2" placeholder="Oder Dateiname manuell..." />
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<flux:heading size="sm" class="mb-2">Highlights ({{ strtoupper($editLocale) }})</flux:heading>
|
||||
<div class="space-y-2">
|
||||
@foreach ($highlights as $i => $hl)
|
||||
<div wire:key="hl-{{ $i }}" class="flex items-center gap-2">
|
||||
<flux:input wire:model="highlights.{{ $i }}" placeholder="Highlight-Text" class="flex-1" size="sm" />
|
||||
@if (count($highlights) > 1)
|
||||
<flux:button size="xs" variant="ghost" icon="x-mark" wire:click="removeHighlight({{ $i }})" />
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<flux:button size="xs" variant="ghost" icon="plus" wire:click="addHighlight" class="mt-2">Highlight</flux:button>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<flux:switch wire:model="is_published" label="Veröffentlicht" />
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- TAB: Quick Facts --}}
|
||||
@if ($activeTab === 'facts')
|
||||
<div class="space-y-3">
|
||||
@foreach ($quick_facts as $i => $fact)
|
||||
<div wire:key="qf-{{ $i }}" class="flex items-end gap-2 rounded-lg border border-zinc-200 p-3 dark:border-zinc-700">
|
||||
<flux:select wire:model="quick_facts.{{ $i }}.icon" label="Icon" class="w-44">
|
||||
<flux:select.option value="home-modern">Haus</flux:select.option>
|
||||
<flux:select.option value="squares-2x2">Fläche</flux:select.option>
|
||||
<flux:select.option value="building-office-2">Gebäude</flux:select.option>
|
||||
<flux:select.option value="user">Person</flux:select.option>
|
||||
<flux:select.option value="currency-dollar">Preis</flux:select.option>
|
||||
<flux:select.option value="calendar">Kalender</flux:select.option>
|
||||
<flux:select.option value="map-pin">Standort</flux:select.option>
|
||||
</flux:select>
|
||||
<flux:input wire:model="quick_facts.{{ $i }}.label" label="Label" placeholder="Typen" class="flex-1" />
|
||||
<flux:input wire:model="quick_facts.{{ $i }}.value" label="Wert" placeholder="1BR & 3BR" class="flex-1" />
|
||||
@if (count($quick_facts) > 1)
|
||||
<flux:button size="sm" variant="ghost" icon="trash" wire:click="removeQuickFact({{ $i }})" />
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
<flux:button size="xs" variant="ghost" icon="plus" wire:click="addQuickFact">Quick Fact</flux:button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- TAB: Investment Case --}}
|
||||
@if ($activeTab === 'invest')
|
||||
<div class="space-y-4">
|
||||
<flux:input wire:model="investTitle" label="Überschrift ({{ strtoupper($editLocale) }})" placeholder="Starkes Investment, hohe Nachfrage." />
|
||||
<flux:textarea wire:model="investText" label="Text ({{ strtoupper($editLocale) }})" rows="4" placeholder="Beschreibung des Investment Case..." />
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300">Verfügbare Views</label>
|
||||
<div class="space-y-2">
|
||||
@foreach ($investViews as $i => $view)
|
||||
<div wire:key="iv-{{ $i }}" class="flex items-center gap-2">
|
||||
<flux:input wire:model="investViews.{{ $i }}" placeholder="z.B. Road View" class="flex-1" size="sm" />
|
||||
@if (count($investViews) > 1)
|
||||
<flux:button size="xs" variant="ghost" icon="x-mark" wire:click="removeInvestView({{ $i }})" />
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<flux:button size="xs" variant="ghost" icon="plus" wire:click="addInvestView" class="mt-2">View</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- TAB: Galerie --}}
|
||||
@if ($activeTab === 'gallery')
|
||||
<div class="space-y-3">
|
||||
@foreach ($galleryItems as $i => $img)
|
||||
<div wire:key="gal-{{ $i }}" class="flex items-center gap-3 rounded-lg border border-zinc-200 p-3 dark:border-zinc-700">
|
||||
@if ($img)
|
||||
<div class="h-14 w-20 shrink-0 overflow-hidden rounded-lg bg-zinc-100 dark:bg-zinc-700">
|
||||
<img src="{{ media_url($img) }}" class="h-full w-full object-cover" />
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex-1">
|
||||
<livewire:admin.cms.media-picker :value="null" :field="'gallery_' . $i" type="image" profile="gallery" label="Bild wählen" :key="'gal-picker-' . $i . '-' . ($editingId ?? 'new')" />
|
||||
<flux:input wire:model="galleryItems.{{ $i }}" size="sm" class="mt-1" placeholder="Oder Pfad manuell..." />
|
||||
</div>
|
||||
@if (count($galleryItems) > 1)
|
||||
<flux:button size="sm" variant="ghost" icon="trash" wire:click="removeGalleryItem({{ $i }})" />
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
<flux:button size="xs" variant="ghost" icon="plus" wire:click="addGalleryItem">Bild hinzufügen</flux:button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- TAB: Location --}}
|
||||
@if ($activeTab === 'location')
|
||||
<div class="space-y-4">
|
||||
<flux:input wire:model="locTitle" label="Überschrift ({{ strtoupper($editLocale) }})" placeholder="Strategische Location: Al Jaddaf" />
|
||||
<flux:input wire:model="locMapUrl" label="Google Maps URL" placeholder="https://maps.google.com/?q=..." />
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300">Location-Punkte ({{ strtoupper($editLocale) }})</label>
|
||||
<div class="space-y-2">
|
||||
@foreach ($locPoints as $i => $point)
|
||||
<div wire:key="lp-{{ $i }}" class="flex items-center gap-2">
|
||||
<flux:input wire:model="locPoints.{{ $i }}" placeholder="Beschreibung des Standort-Vorteils" class="flex-1" size="sm" />
|
||||
@if (count($locPoints) > 1)
|
||||
<flux:button size="xs" variant="ghost" icon="x-mark" wire:click="removeLocPoint({{ $i }})" />
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<flux:button size="xs" variant="ghost" icon="plus" wire:click="addLocPoint" class="mt-2">Punkt</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- TAB: Kontakt --}}
|
||||
@if ($activeTab === 'contact')
|
||||
<div class="space-y-4">
|
||||
<flux:input wire:model="contactTitle" label="Überschrift ({{ strtoupper($editLocale) }})" placeholder="Sichern Sie sich eine der Einheiten." />
|
||||
<flux:input wire:model="contactSubtitle" label="Untertitel ({{ strtoupper($editLocale) }})" placeholder="Ihr Ansprechpartner: Marcel Scheibe" />
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300">Auswahloptionen (Key → Anzeige)</label>
|
||||
<div class="space-y-2">
|
||||
@foreach ($contactOptions as $i => $opt)
|
||||
<div wire:key="co-{{ $i }}" class="flex items-center gap-2">
|
||||
<flux:input wire:model="contactOptions.{{ $i }}.key" placeholder="Key (leer=Default)" class="w-40" size="sm" />
|
||||
<flux:input wire:model="contactOptions.{{ $i }}.value" placeholder="Anzeige-Text" class="flex-1" size="sm" />
|
||||
@if (count($contactOptions) > 1)
|
||||
<flux:button size="xs" variant="ghost" icon="x-mark" wire:click="removeContactOption({{ $i }})" />
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<flux:button size="xs" variant="ghost" icon="plus" wire:click="addContactOption" class="mt-2">Option</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- TAB: Trust-Block & Möbel-Vorteil (Detailseite) --}}
|
||||
@if ($activeTab === 'trust_synergy')
|
||||
<div class="space-y-8">
|
||||
<div>
|
||||
<flux:heading size="sm" class="mb-3">Trust-Block: Investorenschutz ({{ strtoupper($editLocale) }})</flux:heading>
|
||||
<div class="space-y-4">
|
||||
<flux:input wire:model="trustTitle" label="Überschrift" placeholder="Maximale Sicherheit für Ihr Investment" />
|
||||
<flux:textarea wire:model="trustIntro" label="Einleitung" rows="2" placeholder="Kurzer Intro-Text unter der Überschrift" />
|
||||
<flux:input wire:model="trustCtaUrl" label="CTA-Link (Magazin o. ä.)" placeholder="/magazin/1" />
|
||||
<flux:input wire:model="trustCtaLabel" label="CTA-Button-Text" placeholder="Deep Dive: …" />
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-zinc-700 dark:text-zinc-300">Drei Spalten (Icon = Blade-Name, z. B. heroicon-o-lock-closed)</label>
|
||||
<div class="space-y-4">
|
||||
@foreach ($trustColumns as $ti => $tc)
|
||||
<div wire:key="trust-col-{{ $ti }}" class="rounded-lg border border-zinc-200 p-4 dark:border-zinc-700">
|
||||
<div class="mb-3 flex items-end gap-2">
|
||||
<flux:select wire:model="trustColumns.{{ $ti }}.icon" label="Icon" class="min-w-[14rem]">
|
||||
<flux:select.option value="heroicon-o-lock-closed">Schloss (Escrow)</flux:select.option>
|
||||
<flux:select.option value="heroicon-o-building-library">Gebäude / DLD</flux:select.option>
|
||||
<flux:select.option value="heroicon-o-chart-bar">Diagramm</flux:select.option>
|
||||
<flux:select.option value="heroicon-o-shield-check">Schild</flux:select.option>
|
||||
<flux:select.option value="heroicon-o-sparkles">Sparkles</flux:select.option>
|
||||
</flux:select>
|
||||
@if (count($trustColumns) > 1)
|
||||
<flux:button size="sm" variant="ghost" icon="trash" wire:click="removeTrustColumn({{ $ti }})" />
|
||||
@endif
|
||||
</div>
|
||||
<flux:input wire:model="trustColumns.{{ $ti }}.title" label="Spaltenüberschrift" class="mb-2" />
|
||||
<flux:textarea wire:model="trustColumns.{{ $ti }}.text" label="Text" rows="3" />
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<flux:button size="xs" variant="ghost" icon="plus" wire:click="addTrustColumn" class="mt-2">Spalte hinzufügen</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<flux:separator />
|
||||
|
||||
<div>
|
||||
<flux:heading size="sm" class="mb-3">Möbel-Vorteil / Synergie ({{ strtoupper($editLocale) }})</flux:heading>
|
||||
<flux:textarea wire:model="furnitureTitle" label="Überschrift (HTML erlaubt, z. B. <span class="text-secondary">)" rows="2" />
|
||||
<flux:textarea wire:model="furnitureText" label="Fließtext" rows="4" class="mt-3" />
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<flux:input wire:model="furnitureButtonText" label="Button-Text" placeholder="Mehr zum B2in-Netzwerk" />
|
||||
<flux:input wire:model="furnitureButtonLink" label="Button-Link" placeholder="/netzwerk" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex gap-2 border-t border-zinc-200 pt-4 dark:border-zinc-700">
|
||||
<flux:button variant="primary" wire:click="save">Speichern</flux:button>
|
||||
<flux:button variant="ghost" wire:click="cancelForm">Abbrechen</flux:button>
|
||||
</div>
|
||||
</flux:card>
|
||||
@endif
|
||||
|
||||
{{-- Projektliste --}}
|
||||
<flux:card>
|
||||
<div class="divide-y dark:divide-zinc-700">
|
||||
@forelse ($this->projects as $project)
|
||||
<div wire:key="project-{{ $project->id }}" class="flex items-center justify-between gap-4 py-3">
|
||||
<div class="flex items-center gap-4">
|
||||
@if ($project->image)
|
||||
<div class="h-12 w-20 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($project->image) }}" 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 border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<x-heroicon-o-building-office class="h-6 w-6 text-zinc-300" />
|
||||
</div>
|
||||
@endif
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-zinc-800 dark:text-zinc-200">{{ $project->title }}</span>
|
||||
@if ($project->is_published)
|
||||
<flux:badge size="sm" color="green">Live</flux:badge>
|
||||
@else
|
||||
<flux:badge size="sm" color="zinc">Entwurf</flux:badge>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-sm text-zinc-500">
|
||||
<span>{{ $project->location }}</span>
|
||||
@if ($project->status)
|
||||
<span>· {{ $project->status }}</span>
|
||||
@endif
|
||||
@if ($project->getFormattedPrice())
|
||||
<span>· {{ $project->getFormattedPrice() }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<flux:button size="sm" variant="ghost" icon="pencil" wire:click="openEdit({{ $project->id }})" />
|
||||
<flux:button size="sm" variant="ghost" icon="document-duplicate" wire:click="duplicateProject({{ $project->id }})" title="Projekt duplizieren" />
|
||||
<flux:button size="sm" variant="ghost" icon="trash"
|
||||
wire:click="deleteProject({{ $project->id }})"
|
||||
wire:confirm="'{{ $project->title }}' wirklich löschen?" />
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="py-12 text-center">
|
||||
<x-heroicon-o-building-office class="mx-auto mb-4 h-12 w-12 text-zinc-400" />
|
||||
<flux:heading>Keine Projekte</flux:heading>
|
||||
<flux:text>Erstelle das erste Projekt mit dem Button oben.</flux:text>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
|
@ -1,80 +1,14 @@
|
|||
<?php
|
||||
|
||||
use function Livewire\Volt\{state, computed};
|
||||
use Illuminate\Support\Str;
|
||||
use League\CommonMark\CommonMarkConverter;
|
||||
use League\CommonMark\Environment\Environment;
|
||||
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
|
||||
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
|
||||
use League\CommonMark\MarkdownConverter;
|
||||
use App\Services\ProjectDocumentationContent;
|
||||
use function Livewire\Volt\computed;
|
||||
use function Livewire\Volt\state;
|
||||
|
||||
state(['showToc' => false]);
|
||||
|
||||
$content = computed(function () {
|
||||
$mdPath = base_path('dev/entwicklung.md');
|
||||
|
||||
if (!file_exists($mdPath)) {
|
||||
return '<p class="text-red-600">Dokumentation nicht gefunden.</p>';
|
||||
}
|
||||
|
||||
$markdown = file_get_contents($mdPath);
|
||||
|
||||
// Configure CommonMark with GitHub Flavored Markdown
|
||||
$environment = new Environment([
|
||||
'html_input' => 'allow',
|
||||
'allow_unsafe_links' => false,
|
||||
]);
|
||||
|
||||
$environment->addExtension(new CommonMarkCoreExtension());
|
||||
$environment->addExtension(new GithubFlavoredMarkdownExtension());
|
||||
|
||||
$converter = new MarkdownConverter($environment);
|
||||
|
||||
return $converter->convert($markdown)->getContent();
|
||||
});
|
||||
|
||||
// Extract Table of Contents from markdown
|
||||
$tableOfContents = computed(function () {
|
||||
$mdPath = base_path('dev/entwicklung.md');
|
||||
|
||||
if (!file_exists($mdPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$markdown = file_get_contents($mdPath);
|
||||
$toc = [];
|
||||
|
||||
// Extract headings (## and ###)
|
||||
preg_match_all('/^(#{2,3})\s+(.+)$/m', $markdown, $matches, PREG_SET_ORDER);
|
||||
|
||||
foreach ($matches as $match) {
|
||||
$level = strlen($match[1]);
|
||||
$title = trim($match[2]);
|
||||
$slug = Str::slug($title);
|
||||
|
||||
$toc[] = [
|
||||
'level' => $level,
|
||||
'title' => $title,
|
||||
'slug' => $slug,
|
||||
];
|
||||
}
|
||||
|
||||
return $toc;
|
||||
});
|
||||
|
||||
$fileInfo = computed(function () {
|
||||
$mdPath = base_path('dev/entwicklung.md');
|
||||
|
||||
if (!file_exists($mdPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'size' => round(filesize($mdPath) / 1024, 1) . ' KB',
|
||||
'modified' => \Carbon\Carbon::parse(filemtime($mdPath))->format('d.m.Y H:i'),
|
||||
'lines' => count(file($mdPath)),
|
||||
];
|
||||
});
|
||||
$content = computed(fn () => ProjectDocumentationContent::html());
|
||||
$tableOfContents = computed(fn () => ProjectDocumentationContent::tableOfContents());
|
||||
$fileInfo = computed(fn () => ProjectDocumentationContent::fileInfo());
|
||||
|
||||
?>
|
||||
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@ new class extends Component {
|
|||
href="{{ config('domains.domain_b2in_url') . route('registration.landing', ['role' => $roleOptions[$selectedRole]['slug']], false) }}"
|
||||
target="_blank"
|
||||
>
|
||||
{{ __('B2In') }}
|
||||
{{ __('B2in') }}
|
||||
</flux:button>
|
||||
@if($selectedRole === 'customer')
|
||||
<flux:button
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue