- 83 Komponenten: Livewire\Volt\Component → Livewire\Component
- 73 Routen (admin, auth, customer): Volt::route() → Route::livewire()
- 88 Testdateien: Volt::test/actingAs → Livewire::test/actingAs
- VoltServiceProvider gelöscht; composer remove livewire/volt
- config/livewire.php auf v4-Schlüssel (component_layout, component_placeholder,
component_locations, make_command: SFC ohne ⚡). Der alte Schlüssel layout war
wirkungslos; alle gerouteten Komponenten setzen ihr Layout selbst.
- CLAUDE.md, AGENTS.md, boost.json, .cursorrules, Skill volt-development
- Roadmap: erledigt; Larastan erfasst SFCs weiterhin nicht (Blade-Dateien)
Suite 2.325 grün, einer übersprungen; Larastan und Pint ohne Meldung;
route:cache und view:cache laufen.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
290 lines
13 KiB
PHP
290 lines
13 KiB
PHP
<?php
|
|
|
|
use App\Enums\Portal;
|
|
use App\Enums\PressReleaseStatus;
|
|
use App\Models\Category;
|
|
use App\Models\PressRelease;
|
|
use App\Scopes\PortalScope;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Pagination\LengthAwarePaginator;
|
|
use Livewire\Attributes\Locked;
|
|
use Livewire\Attributes\Url;
|
|
use Livewire\Component;
|
|
use Livewire\WithPagination;
|
|
|
|
new class extends Component
|
|
{
|
|
use WithPagination;
|
|
|
|
#[Url(as: 'q', except: '')]
|
|
public string $q = '';
|
|
|
|
#[Url(except: '')]
|
|
public string $category = '';
|
|
|
|
#[Url(except: 'newest')]
|
|
public string $sort = 'newest';
|
|
|
|
#[Locked]
|
|
public ?string $portal = null;
|
|
|
|
#[Locked]
|
|
public string $language = 'de';
|
|
|
|
public function updated(string $property): void
|
|
{
|
|
if (in_array($property, ['q', 'category', 'sort'], true)) {
|
|
$this->resetPage();
|
|
}
|
|
}
|
|
|
|
public function clearFilters(): void
|
|
{
|
|
$this->reset(['q', 'category', 'sort']);
|
|
$this->resetPage();
|
|
}
|
|
|
|
public function selectCategory(string $slug): void
|
|
{
|
|
$this->category = $this->category === $slug ? '' : $slug;
|
|
$this->resetPage();
|
|
}
|
|
|
|
public function with(): array
|
|
{
|
|
// Ausgabe/Sprache auch auf Livewire-Update-Requests konsistent halten
|
|
// (dort fehlt das /de- bzw. /en-Präfix in der URL).
|
|
app()->setLocale($this->language);
|
|
\Illuminate\Support\Facades\URL::defaults(['edition' => $this->language]);
|
|
|
|
$term = trim($this->q);
|
|
$hasQuery = mb_strlen($term) >= 2 || $this->category !== '';
|
|
|
|
return [
|
|
'hasQuery' => $hasQuery,
|
|
'term' => $term,
|
|
'results' => $hasQuery ? $this->results($term) : null,
|
|
'archiveTotal' => $this->archiveTotal(),
|
|
'categories' => \App\Support\CategoryNavigation::items(12),
|
|
];
|
|
}
|
|
|
|
private function results(string $term): LengthAwarePaginator
|
|
{
|
|
$query = $this->baseQuery();
|
|
|
|
if (mb_strlen($term) >= 2) {
|
|
$query->matchingSearchTerm($term, $this->language);
|
|
}
|
|
|
|
if ($this->category !== '') {
|
|
$categoryIds = $this->categoryIds($this->category);
|
|
$query->whereIn('category_id', $categoryIds);
|
|
}
|
|
|
|
return $this->applySort($query)
|
|
->paginate(10)
|
|
->withQueryString();
|
|
}
|
|
|
|
/**
|
|
* @return array<int, int>
|
|
*/
|
|
private function categoryIds(string $slug): array
|
|
{
|
|
$category = Category::query()
|
|
->whereIn('portal', $this->portalValues())
|
|
->whereHas('translations', fn (Builder $query) => $query
|
|
->where('locale', $this->language)
|
|
->where('slug', $slug))
|
|
->first();
|
|
|
|
if (! $category) {
|
|
return [-1];
|
|
}
|
|
|
|
return Category::query()
|
|
->where(fn (Builder $query) => $query
|
|
->whereKey($category->getKey())
|
|
->orWhere('parent_id', $category->getKey()))
|
|
->pluck('id')
|
|
->all();
|
|
}
|
|
|
|
private function applySort(Builder $query): Builder
|
|
{
|
|
return match ($this->sort) {
|
|
'oldest' => $query->orderBy('published_at'),
|
|
'most-read' => $query->orderByDesc('hits')->orderByDesc('published_at'),
|
|
default => $query->orderByDesc('published_at'),
|
|
};
|
|
}
|
|
|
|
private function baseQuery(): Builder
|
|
{
|
|
return PressRelease::query()
|
|
->withoutGlobalScope(PortalScope::class)
|
|
->with([
|
|
'company',
|
|
'category.translations' => fn ($query) => $query->where('locale', $this->language),
|
|
])
|
|
->whereIn('portal', $this->portalValues())
|
|
->where('status', PressReleaseStatus::Published)
|
|
->where('language', $this->language)
|
|
->whereNotNull('published_at')
|
|
->where('published_at', '<=', now());
|
|
}
|
|
|
|
private function archiveTotal(): int
|
|
{
|
|
return $this->baseQuery()->count();
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function portalValues(): array
|
|
{
|
|
$primary = $this->portal ?? Portal::Businessportal24->value;
|
|
|
|
return [$primary, Portal::Both->value];
|
|
}
|
|
}; ?>
|
|
|
|
<div wire:loading.class="opacity-60" class="transition-opacity">
|
|
{{-- Suchfeld --}}
|
|
<form wire:submit.prevent class="mb-6">
|
|
<label class="sr-only" for="search-input">{{ __('web.header.search_title') }}</label>
|
|
<div class="flex items-center gap-3 px-4 py-3.5 border border-bg-rule-strong bg-bg-elev focus-within:border-brand transition-colors">
|
|
<svg width="20" height="20" viewBox="0 0 16 16" fill="none" class="flex-shrink-0 text-ink-3" aria-hidden="true">
|
|
<circle cx="7" cy="7" r="5" stroke="currentColor" stroke-width="1.5" />
|
|
<path d="M11 11l3 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
|
</svg>
|
|
<input id="search-input" type="search"
|
|
wire:model.live.debounce.400ms="q"
|
|
placeholder="{{ __('web.search.placeholder') }}"
|
|
autocomplete="off"
|
|
class="flex-1 bg-transparent border-0 p-0 text-[15px] text-ink placeholder:text-ink-3 focus:outline-none focus:ring-0 min-w-0">
|
|
<span wire:loading wire:target="q" class="font-mono text-[11px] text-ink-3 whitespace-nowrap">{{ __('web.search.searching') }}</span>
|
|
@if ($q !== '' || $category !== '')
|
|
<button type="button" wire:click="clearFilters"
|
|
class="flex-shrink-0 inline-flex items-center justify-center w-7 h-7 text-ink-3 hover:text-ink transition-colors"
|
|
aria-label="{{ __('web.search.reset') }}">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
<path d="M6 6l12 12M6 18L18 6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
|
|
</svg>
|
|
</button>
|
|
@endif
|
|
</div>
|
|
</form>
|
|
|
|
{{-- Rubriken-Filter --}}
|
|
@if ($categories->isNotEmpty())
|
|
<div class="flex flex-wrap gap-2 mb-5">
|
|
<button type="button" wire:click="$set('category', '')" @class([
|
|
'px-3.5 py-2 text-[12.5px] font-medium border transition-colors',
|
|
'border-brand bg-brand text-white' => $category === '',
|
|
'border-bg-rule-strong text-ink hover:bg-bg-elev' => $category !== '',
|
|
])>
|
|
{{ __('web.search.all_categories') }}
|
|
</button>
|
|
@foreach ($categories as $cat)
|
|
<button type="button" wire:click="selectCategory('{{ $cat['slug'] }}')" @class([
|
|
'px-3.5 py-2 text-[12.5px] font-medium border transition-colors',
|
|
'border-brand bg-brand text-white' => $category === $cat['slug'],
|
|
'border-bg-rule-strong text-ink hover:bg-bg-elev' => $category !== $cat['slug'],
|
|
])>
|
|
{{ $cat['label'] }}
|
|
</button>
|
|
@endforeach
|
|
</div>
|
|
@endif
|
|
|
|
@if ($hasQuery && $results)
|
|
{{-- Ergebnis-Kopf --}}
|
|
<div class="flex items-baseline justify-between gap-3 flex-wrap mb-1">
|
|
<h2 class="font-serif text-[22px] font-semibold m-0 tracking-[-0.3px] text-ink">
|
|
{{ number_format($results->total(), 0, ',', '.') }}
|
|
{{ trans_choice('web.search.results', $results->total()) }}
|
|
@if ($term !== '')
|
|
<span class="text-ink-3 font-normal">{{ __('web.search.for_term', ['term' => $term]) }}</span>
|
|
@endif
|
|
</h2>
|
|
<label class="flex items-center gap-2 text-[12.5px] text-ink-3">
|
|
<span class="whitespace-nowrap">{{ __('web.search.sort') }}</span>
|
|
<select wire:model.live="sort"
|
|
class="border border-bg-rule-strong bg-bg-elev text-ink text-[12.5px] py-1.5 pl-2.5 pr-7 focus:outline-none focus:border-brand">
|
|
<option value="newest">{{ __('web.search.sort_newest') }}</option>
|
|
<option value="oldest">{{ __('web.search.sort_oldest') }}</option>
|
|
<option value="most-read">{{ __('web.most_read.title') }}</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<hr class="rule-strong mb-1">
|
|
|
|
@forelse ($results as $release)
|
|
<x-web.feed-item :release="$release" />
|
|
@empty
|
|
<div class="border border-bg-rule bg-bg-elev px-6 py-12 text-center mt-4">
|
|
<h3 class="font-serif text-[20px] font-semibold text-ink m-0 mb-2">{{ __('web.search.no_results') }}</h3>
|
|
<p class="text-[13px] text-ink-3 m-0 mb-5 max-w-md mx-auto">
|
|
{{ __('web.search.no_results_text') }}
|
|
</p>
|
|
<button type="button" wire:click="clearFilters"
|
|
class="inline-flex items-center gap-2 px-4 py-2.5 text-[13px] font-semibold border border-bg-rule-strong bg-white text-ink hover:bg-bg-elev transition-colors">
|
|
{{ __('web.search.reset') }}
|
|
</button>
|
|
</div>
|
|
@endforelse
|
|
|
|
@if ($results->hasPages())
|
|
<nav class="mt-7 flex items-center justify-center gap-1.5" aria-label="{{ __('web.search.pagination') }}">
|
|
<button type="button" wire:click="previousPage" @disabled($results->onFirstPage())
|
|
class="inline-flex items-center justify-center w-9 h-9 border border-bg-rule-strong text-ink disabled:opacity-40 disabled:cursor-not-allowed hover:bg-bg-elev transition-colors"
|
|
aria-label="{{ __('web.search.previous_page') }}">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M15 19l-7-7 7-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
</button>
|
|
@foreach ($results->getUrlRange(max(1, $results->currentPage() - 2), min($results->lastPage(), $results->currentPage() + 2)) as $page => $url)
|
|
<button type="button" wire:click="gotoPage({{ $page }})" @class([
|
|
'inline-flex items-center justify-center min-w-9 h-9 px-2 border text-[13px] font-mono transition-colors',
|
|
'border-brand bg-brand text-white' => $page === $results->currentPage(),
|
|
'border-bg-rule-strong text-ink hover:bg-bg-elev' => $page !== $results->currentPage(),
|
|
])>{{ $page }}</button>
|
|
@endforeach
|
|
<button type="button" wire:click="nextPage" @disabled(! $results->hasMorePages())
|
|
class="inline-flex items-center justify-center w-9 h-9 border border-bg-rule-strong text-ink disabled:opacity-40 disabled:cursor-not-allowed hover:bg-bg-elev transition-colors"
|
|
aria-label="{{ __('web.search.next_page') }}">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M9 5l7 7-7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
</button>
|
|
</nav>
|
|
@endif
|
|
@else
|
|
{{-- Discovery / Leerzustand --}}
|
|
<div class="border border-bg-rule bg-bg-elev px-6 py-12 text-center">
|
|
<div class="w-14 h-14 mx-auto mb-5 inline-flex items-center justify-center border border-bg-rule-strong bg-bg text-ink-3">
|
|
<svg width="24" height="24" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
<circle cx="7" cy="7" r="5" stroke="currentColor" stroke-width="1.5" />
|
|
<path d="M11 11l3 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
|
</svg>
|
|
</div>
|
|
<h2 class="font-serif text-[22px] font-semibold text-ink m-0 mb-2">{{ __('web.search.discover_title') }}</h2>
|
|
<p class="text-[13.5px] text-ink-3 m-0 max-w-lg mx-auto">
|
|
{{ __('web.search.discover_text', ['count' => number_format($archiveTotal, 0, ',', '.')]) }}
|
|
</p>
|
|
|
|
@if ($categories->isNotEmpty())
|
|
<div class="mt-7">
|
|
<div class="eyebrow muted text-[10px] mb-3">{{ __('web.search.popular_categories') }}</div>
|
|
<div class="flex flex-wrap gap-2 justify-center">
|
|
@foreach ($categories->take(8) as $cat)
|
|
<button type="button" wire:click="selectCategory('{{ $cat['slug'] }}')"
|
|
class="px-3.5 py-2 text-[12.5px] font-medium border border-bg-rule-strong text-ink hover:bg-white transition-colors">
|
|
{{ $cat['label'] }}
|
|
</button>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
@endif
|
|
</div>
|