presseportale/resources/views/livewire/web/search.blade.php
Kevin Adametz 31da9ea8b6
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run
livewire: Volt entfernt — Komponenten als Livewire-4-Single-File-Components, Route::livewire(), Livewire::test(); livewire/volt deinstalliert
- 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>
2026-10-06 13:59:36 +00:00

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>