Skip to content

Commit b29afb1

Browse files
committed
Add spotlight search
Update Pengublade components Reverted better error handling in WithCustomLivewireException.php
1 parent d16873d commit b29afb1

28 files changed

+4516
-1298
lines changed

app/Livewire/SpotlightSearch.php

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
<?php
2+
3+
namespace App\Livewire;
4+
5+
use Livewire\Attributes\On;
6+
use Livewire\Component;
7+
8+
class SpotlightSearch extends Component
9+
{
10+
public $searchTerm = '';
11+
public $isOpen = false;
12+
public $selectedIndex = 0;
13+
public $results = [];
14+
public $staticItems = [];
15+
16+
protected $listeners = [
17+
'openSpotlight' => 'open',
18+
'closeSpotlight' => 'close',
19+
];
20+
21+
public function mount()
22+
{
23+
$this->loadStaticItems();
24+
}
25+
26+
public function updatedSearchTerm()
27+
{
28+
if (empty($this->searchTerm)) {
29+
$this->loadStaticItems();
30+
$this->selectedIndex = null;
31+
} else {
32+
$this->search();
33+
$this->selectedIndex = 0;
34+
}
35+
}
36+
37+
public function loadStaticItems()
38+
{
39+
$spotlightService = app('spotlight');
40+
$this->staticItems = $spotlightService->getStaticItems();
41+
$this->results = [];
42+
}
43+
44+
public function search()
45+
{
46+
if (empty($this->searchTerm)) {
47+
$this->loadStaticItems();
48+
return;
49+
}
50+
51+
$spotlightService = app('spotlight');
52+
$this->results = $spotlightService->search($this->searchTerm);
53+
$this->staticItems = [];
54+
}
55+
56+
public function selectNextResult()
57+
{
58+
$items = $this->getDisplayItems();
59+
if (count($items) > 0) {
60+
if ($this->selectedIndex === null) {
61+
$this->selectedIndex = 0;
62+
} else {
63+
$this->selectedIndex = ($this->selectedIndex + 1) % count($items);
64+
}
65+
}
66+
}
67+
68+
public function selectPreviousResult()
69+
{
70+
$items = $this->getDisplayItems();
71+
if (count($items) > 0) {
72+
if ($this->selectedIndex === null) {
73+
$this->selectedIndex = count($items) - 1;
74+
} else {
75+
$this->selectedIndex = ($this->selectedIndex - 1 + count($items)) % count($items);
76+
}
77+
}
78+
}
79+
80+
public function selectResult($index = null)
81+
{
82+
$index = $index ?? $this->selectedIndex;
83+
$items = $this->getDisplayItems();
84+
85+
if (isset($items[$index])) {
86+
$result = $items[$index];
87+
$this->close();
88+
89+
$this->redirect($result['url']);
90+
}
91+
}
92+
93+
#[On('open-spotlight')]
94+
public function open()
95+
{
96+
$this->isOpen = true;
97+
$this->searchTerm = '';
98+
$this->loadStaticItems();
99+
$this->selectedIndex = null;
100+
101+
$this->dispatch('spotlight-opened');
102+
}
103+
104+
#[On('close-spotlight')]
105+
public function close()
106+
{
107+
$this->isOpen = false;
108+
$this->dispatch('spotlight-closed');
109+
}
110+
111+
public function getDisplayItems()
112+
{
113+
$items = empty($this->searchTerm) ? $this->staticItems : $this->results;
114+
return array_slice($items, 0, settings('internal.spotlight.results_limit', config('settings.spotlight_result_limit', 10)));
115+
}
116+
117+
public function render()
118+
{
119+
if (empty($this->searchTerm) && $this->isOpen) {
120+
$this->loadStaticItems();
121+
}
122+
123+
return view('livewire.spotlight-search');
124+
}
125+
}

app/Providers/AppServiceProvider.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace App\Providers;
44

5+
use App\Services\SpotlightService;
56
use App\Services\ViewIntegrationService;
67
use Filament\Notifications\Livewire\Notifications;
78
use Filament\Notifications\Notification;
@@ -22,6 +23,10 @@ public function register(): void
2223
$this->app->singleton(ViewIntegrationService::class, function () {
2324
return new ViewIntegrationService;
2425
});
26+
27+
$this->app->singleton('spotlight', function ($app) {
28+
return new SpotlightService();
29+
});
2530
}
2631

2732
public function boot(): void

app/Services/SpotlightService.php

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
<?php
2+
3+
namespace App\Services;
4+
5+
use InvalidArgumentException;
6+
7+
class SpotlightService
8+
{
9+
protected array $registeredModels = [];
10+
protected array $manualItems = [];
11+
protected array $staticItems = [];
12+
13+
public function registerModel(string $modelClass): void
14+
{
15+
if (!in_array($modelClass, $this->registeredModels)) {
16+
$this->registeredModels[] = $modelClass;
17+
}
18+
}
19+
20+
public function addItem(array $item): void
21+
{
22+
if (!isset($item['title']) || !isset($item['url'])) {
23+
throw new InvalidArgumentException('Spotlight items must have at least a title and URL');
24+
}
25+
26+
$item['description'] = $item['description'] ?? '';
27+
$item['icon'] = $item['icon'] ?? 'link';
28+
$item['id'] = $item['id'] ?? 'manual-' . count($this->manualItems);
29+
$item['model_type'] = $item['model_type'] ?? 'manual';
30+
$item['module'] = $item['module'] ?? null;
31+
$item['permissions'] = $item['permissions'] ?? null;
32+
33+
if (isset($item['module']) && is_string($item['module']) && $this->isTranslationKey($item['module'])) {
34+
$item['raw_module'] = $item['module'];
35+
$item['module'] = __($item['module']);
36+
}
37+
38+
$this->manualItems[] = $item;
39+
}
40+
41+
public function addStaticItem(array $item): void
42+
{
43+
if (!isset($item['title']) || !isset($item['url'])) {
44+
throw new InvalidArgumentException('Spotlight items must have at least a title and URL');
45+
}
46+
47+
$item['icon'] = $item['icon'] ?? 'link';
48+
$item['id'] = $item['id'] ?? 'static-' . count($this->staticItems);
49+
$item['permissions'] = $item['permissions'] ?? null;
50+
$item['module'] = $item['module'] ?? null;
51+
52+
if (isset($item['module']) && is_string($item['module']) && $this->isTranslationKey($item['module'])) {
53+
$item['raw_module'] = $item['module'];
54+
$item['module'] = __($item['module']);
55+
}
56+
57+
if (is_string($item['title']) && $this->isTranslationKey($item['title'])) {
58+
$item['raw_title'] = $item['title'];
59+
$item['title'] = __($item['title']);
60+
}
61+
62+
if (isset($item['description']) && is_string($item['description']) && $this->isTranslationKey($item['description'])) {
63+
$item['raw_description'] = $item['description'];
64+
$item['description'] = __($item['description']);
65+
}
66+
67+
$this->staticItems[] = $item;
68+
}
69+
70+
protected function isTranslationKey(string $text): bool
71+
{
72+
return str_contains($text, '::') ||
73+
preg_match('/^[a-z0-9_]+\.[a-z0-9_.]+$/i', $text);
74+
}
75+
76+
protected function refreshTranslations(array &$item): void
77+
{
78+
if (isset($item['raw_module'])) {
79+
$item['module'] = __($item['raw_module']);
80+
}
81+
82+
if (isset($item['raw_title'])) {
83+
$item['title'] = __($item['raw_title']);
84+
}
85+
86+
if (isset($item['raw_description'])) {
87+
$item['description'] = __($item['raw_description']);
88+
}
89+
}
90+
91+
public function getRegisteredModels(): array
92+
{
93+
return $this->registeredModels;
94+
}
95+
96+
public function getManualItems(): array
97+
{
98+
$items = $this->manualItems;
99+
100+
foreach ($items as &$item) {
101+
$this->refreshTranslations($item);
102+
}
103+
104+
return $items;
105+
}
106+
107+
public function getStaticItems(): array
108+
{
109+
$items = array_filter($this->staticItems, [$this, 'userCanViewItem']);
110+
111+
foreach ($items as &$item) {
112+
$this->refreshTranslations($item);
113+
}
114+
115+
return $items;
116+
}
117+
118+
protected function userCanViewItem(array $item): bool
119+
{
120+
if (empty($item['permissions'])) {
121+
return true;
122+
}
123+
124+
$user = auth()->user();
125+
if (!$user) {
126+
return false;
127+
}
128+
129+
if (method_exists($user, 'hasAnyPermission')) {
130+
return $user->hasAnyPermission($item['permissions']);
131+
}
132+
133+
if (is_array($item['permissions'])) {
134+
foreach ($item['permissions'] as $permission) {
135+
if ($user->can($permission)) {
136+
return true;
137+
}
138+
}
139+
} else {
140+
return $user->can($item['permissions']);
141+
}
142+
143+
return false;
144+
}
145+
146+
public function search(string $term): array
147+
{
148+
if (empty($term)) {
149+
return [];
150+
}
151+
152+
$results = [];
153+
$searchTerm = strtolower($term);
154+
155+
foreach ($this->registeredModels as $modelClass) {
156+
$model = new $modelClass;
157+
$modelResults = $modelClass::spotlightSearch($term)
158+
->get()
159+
->filter(function ($item) {
160+
return $item->userCanViewInSpotlight();
161+
})
162+
->map(function ($item) {
163+
return $item->toSpotlightResult();
164+
});
165+
166+
$results = array_merge($results, $modelResults->toArray());
167+
}
168+
169+
$manualItems = $this->getManualItems();
170+
171+
foreach ($manualItems as $item) {
172+
$matchFound = false;
173+
174+
if (str_contains(strtolower($item['title']), $searchTerm)) {
175+
$matchFound = true;
176+
}
177+
178+
if (!empty($item['description']) && str_contains(strtolower($item['description']), $searchTerm)) {
179+
$matchFound = true;
180+
}
181+
182+
if (!empty($item['module']) && str_contains(strtolower($item['module']), $searchTerm)) {
183+
$matchFound = true;
184+
}
185+
186+
if ($matchFound && $this->userCanViewItem($item)) {
187+
$results[] = $item;
188+
}
189+
}
190+
191+
usort($results, function ($a, $b) use ($searchTerm) {
192+
$aTitle = strtolower($a['title']);
193+
$bTitle = strtolower($b['title']);
194+
195+
if ($aTitle === $searchTerm && $bTitle !== $searchTerm) {
196+
return -1;
197+
}
198+
if ($bTitle === $searchTerm && $aTitle !== $searchTerm) {
199+
return 1;
200+
}
201+
202+
if (str_starts_with($aTitle, $searchTerm) && !str_starts_with($bTitle, $searchTerm)) {
203+
return -1;
204+
}
205+
if (str_starts_with($bTitle, $searchTerm) && !str_starts_with($aTitle, $searchTerm)) {
206+
return 1;
207+
}
208+
209+
$aModule = strtolower($a['module'] ?? '');
210+
$bModule = strtolower($b['module'] ?? '');
211+
if ($aModule === $searchTerm && $bModule !== $searchTerm) {
212+
return -1;
213+
}
214+
if ($bModule === $searchTerm && $aModule !== $searchTerm) {
215+
return 1;
216+
}
217+
218+
return strcmp($aTitle, $bTitle);
219+
});
220+
221+
return array_slice($results, 0);
222+
}
223+
}

0 commit comments

Comments
 (0)