@php $currentIndex = 0; @endphp
- @forelse($this->results as $type => $items)
- @if(count($items) > 0)
+ @forelse($this->results as $type => $group)
+ @if(count($group['results']) > 0)
{{-- Category header --}}
-
- {{ str($type)->title()->plural() }}
+
+
+
+ {{ $group['label'] }}
+
{{-- Results list --}}
- @foreach($items as $item)
+ @foreach($group['results'] as $item)
diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/layouts/app.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/layouts/app.blade.php
index 88ddf4d..e8cded6 100644
--- a/packages/core-admin/src/Website/Hub/View/Blade/admin/layouts/app.blade.php
+++ b/packages/core-admin/src/Website/Hub/View/Blade/admin/layouts/app.blade.php
@@ -92,6 +92,11 @@
@endpersist
+
+@persist('global-search')
+
+@endpersist
+
@include('hub::admin.components.developer-bar')
diff --git a/packages/core-admin/src/Website/Hub/View/Modal/Admin/GlobalSearch.php b/packages/core-admin/src/Website/Hub/View/Modal/Admin/GlobalSearch.php
index e7be30d..b418cab 100644
--- a/packages/core-admin/src/Website/Hub/View/Modal/Admin/GlobalSearch.php
+++ b/packages/core-admin/src/Website/Hub/View/Modal/Admin/GlobalSearch.php
@@ -4,28 +4,74 @@ declare(strict_types=1);
namespace Website\Hub\View\Modal\Admin;
-use Core\Mod\Web\Models\Domain;
-use Core\Mod\Web\Models\Page;
-use Core\Mod\Social\Models\Account;
-use Core\Mod\Social\Models\Post;
+use Core\Admin\Search\SearchProviderRegistry;
use Livewire\Attributes\Computed;
use Livewire\Attributes\On;
use Livewire\Component;
/**
- * Global search component with ⌘K keyboard shortcut.
+ * Global search component with Command+K keyboard shortcut.
*
- * Searches across biolinks, domains, social accounts, and posts.
+ * Searches across all registered SearchProvider implementations.
* Accessible from any page via keyboard shortcut or search button.
+ *
+ * Features:
+ * - Command+K / Ctrl+K to open
+ * - Arrow key navigation
+ * - Enter to select
+ * - Escape to close
+ * - Recent searches (stored in session)
+ * - Debounced search input
+ * - Grouped results by provider type
*/
class GlobalSearch extends Component
{
+ /**
+ * Whether the search modal is open.
+ */
public bool $open = false;
+ /**
+ * The current search query.
+ */
public string $query = '';
+ /**
+ * Currently selected result index for keyboard navigation.
+ */
public int $selectedIndex = 0;
+ /**
+ * Recent searches stored in session.
+ */
+ public array $recentSearches = [];
+
+ /**
+ * Maximum number of recent searches to store.
+ */
+ protected int $maxRecentSearches = 5;
+
+ /**
+ * The search provider registry.
+ */
+ protected SearchProviderRegistry $registry;
+
+ /**
+ * Boot the component with dependencies.
+ */
+ public function boot(SearchProviderRegistry $registry): void
+ {
+ $this->registry = $registry;
+ }
+
+ /**
+ * Mount the component.
+ */
+ public function mount(): void
+ {
+ $this->recentSearches = session('global_search.recent', []);
+ }
+
/**
* Open the search modal.
*/
@@ -93,11 +139,74 @@ class GlobalSearch extends Component
*/
public function navigateTo(array $result): void
{
+ // Add to recent searches
+ $this->addToRecentSearches($result);
+
$this->closeSearch();
$this->dispatch('navigate-to-url', url: $result['url']);
}
+ /**
+ * Navigate to a recent search item.
+ */
+ public function navigateToRecent(int $index): void
+ {
+ if (isset($this->recentSearches[$index])) {
+ $result = $this->recentSearches[$index];
+ $this->closeSearch();
+ $this->dispatch('navigate-to-url', url: $result['url']);
+ }
+ }
+
+ /**
+ * Clear all recent searches.
+ */
+ public function clearRecentSearches(): void
+ {
+ $this->recentSearches = [];
+ session()->forget('global_search.recent');
+ }
+
+ /**
+ * Remove a single recent search.
+ */
+ public function removeRecentSearch(int $index): void
+ {
+ if (isset($this->recentSearches[$index])) {
+ array_splice($this->recentSearches, $index, 1);
+ session(['global_search.recent' => $this->recentSearches]);
+ }
+ }
+
+ /**
+ * Add a result to recent searches.
+ */
+ protected function addToRecentSearches(array $result): void
+ {
+ // Remove if already exists (to move to top)
+ $this->recentSearches = array_values(array_filter(
+ $this->recentSearches,
+ fn ($item) => $item['id'] !== $result['id'] || $item['type'] !== $result['type']
+ ));
+
+ // Add to the beginning
+ array_unshift($this->recentSearches, [
+ 'id' => $result['id'],
+ 'title' => $result['title'],
+ 'subtitle' => $result['subtitle'] ?? null,
+ 'url' => $result['url'],
+ 'type' => $result['type'],
+ 'icon' => $result['icon'],
+ ]);
+
+ // Limit the number of recent searches
+ $this->recentSearches = array_slice($this->recentSearches, 0, $this->maxRecentSearches);
+
+ // Save to session
+ session(['global_search.recent' => $this->recentSearches]);
+ }
+
/**
* Get search results grouped by type.
*/
@@ -109,18 +218,9 @@ class GlobalSearch extends Component
}
$user = auth()->user();
- if (! $user) {
- return [];
- }
+ $workspace = $user?->defaultHostWorkspace();
- $workspace = $user->defaultHostWorkspace();
-
- return [
- 'biolinks' => $this->searchBiolinks($user->id),
- 'domains' => $this->searchDomains($user->id),
- 'accounts' => $workspace ? $this->searchAccounts($workspace->id) : [],
- 'posts' => $workspace ? $this->searchPosts($workspace->id) : [],
- ];
+ return $this->registry->search($this->query, $user, $workspace);
}
/**
@@ -129,117 +229,29 @@ class GlobalSearch extends Component
#[Computed]
public function flatResults(): array
{
- $flat = [];
-
- foreach ($this->results as $type => $items) {
- foreach ($items as $item) {
- $flat[] = $item;
- }
- }
-
- return $flat;
+ return $this->registry->flattenResults($this->results);
}
/**
- * Search bio.
+ * Check if there are any results.
*/
- protected function searchBiolinks(int $userId): array
+ #[Computed]
+ public function hasResults(): bool
{
- $escapedQuery = $this->escapeLikeWildcards($this->query);
-
- return Page::where('user_id', $userId)
- ->where(function ($query) use ($escapedQuery) {
- $query->where('url', 'like', "%{$escapedQuery}%")
- ->orWhereRaw("JSON_EXTRACT(settings, '$.title') LIKE ?", ["%{$escapedQuery}%"]);
- })
- ->limit(5)
- ->get()
- ->map(fn ($biolink) => [
- 'type' => 'biolink',
- 'icon' => 'link',
- 'title' => $biolink->settings['title'] ?? $biolink->url,
- 'subtitle' => "/{$biolink->url}",
- 'url' => route('bio.edit', $biolink),
- ])
- ->toArray();
+ return ! empty($this->flatResults);
}
/**
- * Search domains.
+ * Check if we should show recent searches.
*/
- protected function searchDomains(int $userId): array
+ #[Computed]
+ public function showRecentSearches(): bool
{
- $escapedQuery = $this->escapeLikeWildcards($this->query);
-
- return Domain::where('user_id', $userId)
- ->where('host', 'like', "%{$escapedQuery}%")
- ->limit(5)
- ->get()
- ->map(fn ($domain) => [
- 'type' => 'domain',
- 'icon' => 'globe-alt',
- 'title' => $domain->host,
- 'subtitle' => $domain->is_verified ? 'Verified' : 'Pending verification',
- 'url' => route('domains.index'),
- ])
- ->toArray();
- }
-
- /**
- * Search social accounts.
- */
- protected function searchAccounts(int $workspaceId): array
- {
- $escapedQuery = $this->escapeLikeWildcards($this->query);
-
- return Account::where('workspace_id', $workspaceId)
- ->where(function ($query) use ($escapedQuery) {
- $query->where('name', 'like', "%{$escapedQuery}%")
- ->orWhere('username', 'like', "%{$escapedQuery}%");
- })
- ->limit(5)
- ->get()
- ->map(fn ($account) => [
- 'type' => 'account',
- 'icon' => 'user-circle',
- 'title' => $account->name,
- 'subtitle' => "@{$account->username} · {$account->provider}",
- 'url' => route('social.accounts.index'),
- ])
- ->toArray();
- }
-
- /**
- * Search social posts.
- */
- protected function searchPosts(int $workspaceId): array
- {
- $escapedQuery = $this->escapeLikeWildcards($this->query);
-
- return Post::where('workspace_id', $workspaceId)
- ->whereRaw("JSON_EXTRACT(content, '$.default.body') LIKE ?", ["%{$escapedQuery}%"])
- ->limit(5)
- ->get()
- ->map(fn ($post) => [
- 'type' => 'post',
- 'icon' => 'document-text',
- 'title' => str($post->content['default']['body'] ?? '')->limit(50)->toString(),
- 'subtitle' => $post->scheduled_at?->format('d M Y H:i') ?? 'Draft',
- 'url' => route('social.posts.edit', $post),
- ])
- ->toArray();
+ return strlen($this->query) < 2 && ! empty($this->recentSearches);
}
public function render()
{
return view('hub::admin.global-search');
}
-
- /**
- * Escape LIKE wildcard characters to prevent unintended matches.
- */
- protected function escapeLikeWildcards(string $value): string
- {
- return str_replace(['%', '_'], ['\\%', '\\_'], $value);
- }
}
diff --git a/packages/core-admin/src/Website/Hub/View/Modal/Admin/ServicesAdmin.php b/packages/core-admin/src/Website/Hub/View/Modal/Admin/ServicesAdmin.php
index d36b504..244457e 100644
--- a/packages/core-admin/src/Website/Hub/View/Modal/Admin/ServicesAdmin.php
+++ b/packages/core-admin/src/Website/Hub/View/Modal/Admin/ServicesAdmin.php
@@ -31,9 +31,8 @@ use Core\Mod\Tenant\Models\Workspace;
use Core\Mod\Tenant\Services\WorkspaceService;
use Core\Mod\Trust\Models\Campaign as TrustCampaign;
use Core\Mod\Trust\Models\Notification as TrustNotification;
-use Core\Mod\Web\Models\Page as BioPage;
-use Core\Mod\Web\Models\Project as BioProject;
-use Core\Mod\Web\Services\ThemeService;
+// TODO: Bio service admin moved to Host UK app (Mod\Bio)
+// These imports are commented out until the admin panel is refactored
#[Title('Services')]
class ServicesAdmin extends Component
@@ -758,69 +757,37 @@ class ServicesAdmin extends Component
// BIO STATS (workspace-scoped)
// ========================================
+ // TODO: Bio service admin moved to Host UK app (Mod\Bio)
+ // These computed properties are stubbed until the admin panel is refactored
+
#[Computed]
public function bioStats(): array
{
- $workspaceId = $this->workspace?->id;
-
- if (! $workspaceId) {
- return ['total_pages' => 0, 'active_pages' => 0, 'total_clicks' => 0, 'total_projects' => 0, 'biolinks' => 0, 'shortlinks' => 0];
- }
-
- return [
- 'total_pages' => BioPage::where('workspace_id', $workspaceId)->count(),
- 'active_pages' => BioPage::where('workspace_id', $workspaceId)->where('is_enabled', true)->count(),
- 'total_clicks' => BioPage::where('workspace_id', $workspaceId)->sum('clicks'),
- 'total_projects' => BioProject::where('workspace_id', $workspaceId)->count(),
- 'biolinks' => BioPage::where('workspace_id', $workspaceId)->where('type', 'biolink')->count(),
- 'shortlinks' => BioPage::where('workspace_id', $workspaceId)->where('type', 'link')->count(),
- ];
+ return ['total_pages' => 0, 'active_pages' => 0, 'total_clicks' => 0, 'total_projects' => 0, 'biolinks' => 0, 'shortlinks' => 0];
}
#[Computed]
public function bioStatCards(): array
{
- return [
- ['value' => number_format($this->bioStats['total_pages']), 'label' => __('hub::hub.services.stats.bio.total_pages'), 'icon' => 'file', 'color' => 'violet'],
- ['value' => number_format($this->bioStats['active_pages']), 'label' => __('hub::hub.services.stats.bio.active_pages'), 'icon' => 'check-circle', 'color' => 'green'],
- ['value' => number_format($this->bioStats['total_clicks']), 'label' => __('hub::hub.services.stats.bio.total_clicks'), 'icon' => 'cursor-arrow-rays', 'color' => 'blue'],
- ['value' => number_format($this->bioStats['total_projects']), 'label' => __('hub::hub.services.stats.bio.projects'), 'icon' => 'folder', 'color' => 'orange'],
- ];
+ return [];
}
#[Computed]
public function bioPages(): \Illuminate\Support\Collection
{
- $workspaceId = $this->workspace?->id;
-
- if (! $workspaceId) {
- return collect();
- }
-
- return BioPage::where('workspace_id', $workspaceId)
- ->orderByDesc('clicks')
- ->get();
+ return collect();
}
#[Computed]
public function bioProjects(): \Illuminate\Support\Collection
{
- $workspaceId = $this->workspace?->id;
-
- if (! $workspaceId) {
- return collect();
- }
-
- return BioProject::where('workspace_id', $workspaceId)
- ->withCount('biolinks')
- ->orderBy('name')
- ->get();
+ return collect();
}
#[Computed]
public function bioThemes(): array
{
- return app(ThemeService::class)->getAllThemes();
+ return [];
}
// ========================================