From b8531676e2b868312085f5a6ab3b2219ca08f771 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 26 Jan 2026 14:24:15 +0000 Subject: [PATCH] feat(search): implement global search component with keyboard navigation and recent searches --- packages/core-admin/TODO.md | 142 +------- .../core-admin/changelog/2026/jan/features.md | 70 ++++ .../views/components/forms/button.blade.php | 82 +++++ .../views/components/forms/checkbox.blade.php | 88 +++++ .../components/forms/form-group.blade.php | 50 +++ .../views/components/forms/input.blade.php | 77 +++++ .../views/components/forms/select.blade.php | 108 +++++++ .../views/components/forms/textarea.blade.php | 87 +++++ .../views/components/forms/toggle.blade.php | 104 ++++++ packages/core-admin/src/Boot.php | 60 +++- .../Forms/Concerns/HasAuthorizationProps.php | 101 ++++++ .../src/Forms/View/Components/Button.php | 135 ++++++++ .../src/Forms/View/Components/Checkbox.php | 89 +++++ .../src/Forms/View/Components/FormGroup.php | 88 +++++ .../src/Forms/View/Components/Input.php | 99 ++++++ .../src/Forms/View/Components/Select.php | 146 +++++++++ .../src/Forms/View/Components/Textarea.php | 104 ++++++ .../src/Forms/View/Components/Toggle.php | 127 ++++++++ .../Mod/Hub/Controllers/TeapotController.php | 50 +-- .../core-admin/src/Mod/Hub/Lang/en_GB/hub.php | 6 +- .../src/Mod/Hub/Models/HoneypotHit.php | 56 +++- .../src/Search/Concerns/HasSearchProvider.php | 49 +++ .../src/Search/Contracts/SearchProvider.php | 120 +++++++ .../Providers/AdminPageSearchProvider.php | 216 +++++++++++++ .../src/Search/SearchProviderRegistry.php | 305 ++++++++++++++++++ .../core-admin/src/Search/SearchResult.php | 104 ++++++ .../Tests/SearchProviderRegistryTest.php | 237 ++++++++++++++ .../src/Search/Tests/SearchResultTest.php | 165 ++++++++++ packages/core-admin/src/Website/Hub/Boot.php | 5 +- .../Blade/admin/components/header.blade.php | 13 +- .../View/Blade/admin/global-search.blade.php | 120 +++++-- .../View/Blade/admin/layouts/app.blade.php | 5 + .../Hub/View/Modal/Admin/GlobalSearch.php | 240 +++++++------- .../Hub/View/Modal/Admin/ServicesAdmin.php | 53 +-- 34 files changed, 3137 insertions(+), 364 deletions(-) create mode 100644 packages/core-admin/changelog/2026/jan/features.md create mode 100644 packages/core-admin/resources/views/components/forms/button.blade.php create mode 100644 packages/core-admin/resources/views/components/forms/checkbox.blade.php create mode 100644 packages/core-admin/resources/views/components/forms/form-group.blade.php create mode 100644 packages/core-admin/resources/views/components/forms/input.blade.php create mode 100644 packages/core-admin/resources/views/components/forms/select.blade.php create mode 100644 packages/core-admin/resources/views/components/forms/textarea.blade.php create mode 100644 packages/core-admin/resources/views/components/forms/toggle.blade.php create mode 100644 packages/core-admin/src/Forms/Concerns/HasAuthorizationProps.php create mode 100644 packages/core-admin/src/Forms/View/Components/Button.php create mode 100644 packages/core-admin/src/Forms/View/Components/Checkbox.php create mode 100644 packages/core-admin/src/Forms/View/Components/FormGroup.php create mode 100644 packages/core-admin/src/Forms/View/Components/Input.php create mode 100644 packages/core-admin/src/Forms/View/Components/Select.php create mode 100644 packages/core-admin/src/Forms/View/Components/Textarea.php create mode 100644 packages/core-admin/src/Forms/View/Components/Toggle.php create mode 100644 packages/core-admin/src/Search/Concerns/HasSearchProvider.php create mode 100644 packages/core-admin/src/Search/Contracts/SearchProvider.php create mode 100644 packages/core-admin/src/Search/Providers/AdminPageSearchProvider.php create mode 100644 packages/core-admin/src/Search/SearchProviderRegistry.php create mode 100644 packages/core-admin/src/Search/SearchResult.php create mode 100644 packages/core-admin/src/Search/Tests/SearchProviderRegistryTest.php create mode 100644 packages/core-admin/src/Search/Tests/SearchResultTest.php diff --git a/packages/core-admin/TODO.md b/packages/core-admin/TODO.md index c188784..68b487e 100644 --- a/packages/core-admin/TODO.md +++ b/packages/core-admin/TODO.md @@ -1,145 +1,9 @@ # Core-Admin TODO -## Form Authorization Components +## Security -**Priority:** Medium -**Context:** Authorization checks scattered throughout views with `@can` directives. - -### Solution - -Build authorization into form components themselves. - -### Implementation - -```blade -{{-- resources/views/components/forms/input.blade.php --}} -@props([ - 'id', - 'label' => null, - 'canGate' => null, - 'canResource' => null, -]) - -@php - $disabled = $attributes->get('disabled', false); - if ($canGate && $canResource && !$disabled) { - $disabled = !auth()->user()?->can($canGate, $canResource); - } -@endphp - -merge(['disabled' => $disabled]) }} /> -``` - -### Usage - -```blade - -Save -``` - -### Components to Create - -- `input.blade.php` -- `textarea.blade.php` -- `select.blade.php` -- `checkbox.blade.php` -- `button.blade.php` -- `toggle.blade.php` +- [ ] **Audit Admin Routes** - Ensure all admin controllers use `#[Action]` attributes or implicit routing covered by Bouncer. --- -## Global Search (⌘K) - -**Priority:** Medium -**Context:** No unified search across resources. - -### Implementation - -```php -class GlobalSearch extends Component -{ - public bool $open = false; - public string $query = ''; - public array $results = []; - - public function updatedQuery() - { - if (strlen($this->query) < 2) return; - - $this->results = $this->searchProviders(); - } -} -``` - -### Features - -- ⌘K keyboard shortcut -- Arrow key navigation -- Enter to select -- Module-provided search providers -- Recent searches - -### Requirements - -- `SearchProvider` interface for modules to implement -- Auto-discover providers from registered modules -- Fuzzy matching support - ---- - -## Real-time WebSocket (Soketi) - -**Priority:** Low -**Context:** No real-time updates. Users must refresh. - -### Implementation - -Self-hosted Soketi + Laravel Echo. - -```yaml -# docker-compose.yml -soketi: - image: 'quay.io/soketi/soketi:latest' - ports: - - '6001:6001' -``` - -```php -// Broadcasting events -broadcast(new ResourceUpdated($resource)); - -// Livewire listening -protected function getListeners() -{ - return [ - "echo-private:workspace.{$this->workspace->id},resource.updated" => 'refresh', - ]; -} -``` - -### Notes - -- DO NOT route through Bunny CDN (charges per connection) -- Use private channels for workspace-scoped events - ---- - -## Enhanced Form Components - -**Priority:** Medium -**Context:** Extend Flux Pro components with additional features. - -### Features to Add - -- Dark mode consistency -- Automatic error display -- Helper text support -- Disabled states from authorization -- `instantSave` for real-time persistence - -### Components - -```blade - - -``` +*See `changelog/2026/jan/` for completed features.* diff --git a/packages/core-admin/changelog/2026/jan/features.md b/packages/core-admin/changelog/2026/jan/features.md new file mode 100644 index 0000000..96025c9 --- /dev/null +++ b/packages/core-admin/changelog/2026/jan/features.md @@ -0,0 +1,70 @@ +# Core-Admin - January 2026 + +## Features Implemented + +### Form Authorization Components + +Authorization-aware form components that automatically disable/hide based on permissions. + +**Files:** +- `src/Forms/Concerns/HasAuthorizationProps.php` - Authorization trait +- `src/Forms/View/Components/` - Input, Textarea, Select, Checkbox, Button, Toggle, FormGroup +- `resources/views/components/forms/` - Blade templates + +**Components:** +- `` - Text input with label, helper, error +- `` - Textarea with auto-resize +- `` - Dropdown with grouped options +- `` - Checkbox with description +- `` - Button with variants, loading state +- `` - Toggle with instant save +- `` - Wrapper for spacing + +**Usage:** +```blade + + + + Delete + +``` + +--- + +### Global Search (⌘K) + +Unified search across resources with keyboard navigation. + +**Files:** +- `src/Search/Contracts/SearchProvider.php` - Provider interface +- `src/Search/SearchProviderRegistry.php` - Registry with fuzzy matching +- `src/Search/SearchResult.php` - Result DTO +- `src/Search/Providers/AdminPageSearchProvider.php` - Built-in provider +- `src/Website/Hub/View/Modal/Admin/GlobalSearch.php` - Livewire component + +**Features:** +- ⌘K / Ctrl+K keyboard shortcut +- Arrow key navigation, Enter to select +- Fuzzy matching support +- Recent searches +- Grouped results by provider + +**Usage:** +```php +// Register custom provider +app(SearchProviderRegistry::class)->register(new MySearchProvider()); +``` + +--- + +## Design Decisions + +### Soketi (Real-time WebSocket) + +Excluded per project decision. Self-hosted Soketi integration not required at this time. diff --git a/packages/core-admin/resources/views/components/forms/button.blade.php b/packages/core-admin/resources/views/components/forms/button.blade.php new file mode 100644 index 0000000..67fe9fa --- /dev/null +++ b/packages/core-admin/resources/views/components/forms/button.blade.php @@ -0,0 +1,82 @@ +{{-- + Button Component + + A button with authorization support, variants, loading states, and icons. + + Props: + - type: string - Button type (button, submit, reset) + - variant: string - Button style: primary, secondary, danger, ghost + - size: string - Button size: sm, md, lg + - icon: string|null - Icon name (left position) + - iconRight: string|null - Icon name (right position) + - loading: bool - Show loading state + - loadingText: string|null - Text to show during loading + - disabled: bool - Whether button is disabled + - canGate: string|null - Gate/ability to check + - canResource: mixed|null - Resource to check against + - canHide: bool - Hide instead of disable when unauthorized + + Usage: + + Save Changes + + + + Delete + + + {{-- With loading state --}} + + Save + Saving... + +--}} + +@if(!$hidden) + +@endif diff --git a/packages/core-admin/resources/views/components/forms/checkbox.blade.php b/packages/core-admin/resources/views/components/forms/checkbox.blade.php new file mode 100644 index 0000000..bfb6036 --- /dev/null +++ b/packages/core-admin/resources/views/components/forms/checkbox.blade.php @@ -0,0 +1,88 @@ +{{-- + Checkbox Component + + A checkbox with authorization support, label positioning, and description. + + Props: + - id: string (required) - Checkbox element ID + - label: string|null - Label text + - description: string|null - Description text below label + - error: string|null - Error message + - labelPosition: string - Label position: 'left' or 'right' (default: 'right') + - disabled: bool - Whether checkbox is disabled + - canGate: string|null - Gate/ability to check + - canResource: mixed|null - Resource to check against + - canHide: bool - Hide instead of disable when unauthorized + + Usage: + + + {{-- Label on left --}} + +--}} + +@if(!$hidden) +
only(['class', 'x-show', 'x-if', 'x-cloak'])->merge(['class' => 'space-y-1']) }}> +
$labelPosition === 'left', + ])> + {{-- Checkbox --}} +
+ except(['class', 'x-show', 'x-if', 'x-cloak'])->class([ + 'h-4 w-4 rounded transition-colors duration-200', + 'border-gray-300 dark:border-gray-600', + 'text-violet-600 dark:text-violet-500', + 'focus:ring-2 focus:ring-violet-500/20 focus:ring-offset-0', + 'bg-white dark:bg-gray-800', + // Disabled state + 'bg-gray-100 dark:bg-gray-900 cursor-not-allowed' => $disabled, + ]) }} + /> +
+ + {{-- Label and description --}} + @if($label || $description) +
+ @if($label) + + @endif + + @if($description) +

{{ $description }}

+ @endif +
+ @endif +
+ + {{-- Error message --}} + @if($error) +

{{ $error }}

+ @elseif($errors->has($id)) +

{{ $errors->first($id) }}

+ @endif +
+@endif diff --git a/packages/core-admin/resources/views/components/forms/form-group.blade.php b/packages/core-admin/resources/views/components/forms/form-group.blade.php new file mode 100644 index 0000000..fea442b --- /dev/null +++ b/packages/core-admin/resources/views/components/forms/form-group.blade.php @@ -0,0 +1,50 @@ +{{-- + Form Group Component + + A wrapper component for consistent form field spacing and error display. + + Props: + - label: string|null - Label text + - for: string|null - ID of the form element (for label) + - error: string|null - Error bag key to check + - helper: string|null - Helper text + - required: bool - Show required indicator + + Usage: + + + + + {{-- Without label --}} + + + +--}} + +
merge(['class' => 'space-y-1']) }}> + {{-- Label --}} + @if($label) + + @endif + + {{-- Content slot --}} + {{ $slot }} + + {{-- Helper text --}} + @if($helper && !$hasError()) +

{{ $helper }}

+ @endif + + {{-- Error message --}} + @if($hasError()) +

{{ $errorMessage }}

+ @endif +
diff --git a/packages/core-admin/resources/views/components/forms/input.blade.php b/packages/core-admin/resources/views/components/forms/input.blade.php new file mode 100644 index 0000000..b3c9804 --- /dev/null +++ b/packages/core-admin/resources/views/components/forms/input.blade.php @@ -0,0 +1,77 @@ +{{-- + Input Component + + A text input with authorization support, labels, helper text, and error display. + + Props: + - id: string (required) - Input element ID + - label: string|null - Label text + - helper: string|null - Helper text below input + - error: string|null - Error message (auto-resolved from validation bag if not provided) + - type: string - Input type (text, email, password, etc.) + - placeholder: string|null - Placeholder text + - disabled: bool - Whether input is disabled + - required: bool - Whether input is required + - canGate: string|null - Gate/ability to check + - canResource: mixed|null - Resource to check against + - canHide: bool - Hide instead of disable when unauthorized + + Usage: + +--}} + +@if(!$hidden) +
only(['class', 'x-show', 'x-if', 'x-cloak'])->merge(['class' => 'space-y-1']) }}> + {{-- Label --}} + @if($label) + + @endif + + {{-- Input --}} + except(['class', 'x-show', 'x-if', 'x-cloak'])->class([ + 'block w-full rounded-lg border px-3 py-2 text-sm transition-colors duration-200', + 'bg-white dark:bg-gray-800', + 'text-gray-900 dark:text-gray-100', + 'placeholder-gray-400 dark:placeholder-gray-500', + 'focus:outline-none focus:ring-2 focus:ring-offset-0', + // Normal state + 'border-gray-300 dark:border-gray-600 focus:border-violet-500 focus:ring-violet-500/20' => !$error, + // Error state + 'border-red-500 dark:border-red-500 focus:border-red-500 focus:ring-red-500/20' => $error, + // Disabled state + 'bg-gray-50 dark:bg-gray-900 text-gray-500 dark:text-gray-400 cursor-not-allowed' => $disabled, + ]) }} + /> + + {{-- Helper text --}} + @if($helper && !$error) +

{{ $helper }}

+ @endif + + {{-- Error message --}} + @if($error) +

{{ $error }}

+ @elseif($errors->has($id)) +

{{ $errors->first($id) }}

+ @endif +
+@endif diff --git a/packages/core-admin/resources/views/components/forms/select.blade.php b/packages/core-admin/resources/views/components/forms/select.blade.php new file mode 100644 index 0000000..a0b741d --- /dev/null +++ b/packages/core-admin/resources/views/components/forms/select.blade.php @@ -0,0 +1,108 @@ +{{-- + Select Component + + A dropdown select with authorization support, options, and error display. + + Props: + - id: string (required) - Select element ID + - options: array - Options as value => label or grouped options + - label: string|null - Label text + - helper: string|null - Helper text below select + - error: string|null - Error message + - placeholder: string|null - Placeholder option text + - multiple: bool - Allow multiple selection + - disabled: bool - Whether select is disabled + - required: bool - Whether select is required + - canGate: string|null - Gate/ability to check + - canResource: mixed|null - Resource to check against + - canHide: bool - Hide instead of disable when unauthorized + + Usage: + + + {{-- With grouped options --}} + +--}} + +@if(!$hidden) +
only(['class', 'x-show', 'x-if', 'x-cloak'])->merge(['class' => 'space-y-1']) }}> + {{-- Label --}} + @if($label) + + @endif + + {{-- Select --}} + + + {{-- Helper text --}} + @if($helper && !$error) +

{{ $helper }}

+ @endif + + {{-- Error message --}} + @if($error) +

{{ $error }}

+ @elseif($errors->has($id)) +

{{ $errors->first($id) }}

+ @endif +
+@endif diff --git a/packages/core-admin/resources/views/components/forms/textarea.blade.php b/packages/core-admin/resources/views/components/forms/textarea.blade.php new file mode 100644 index 0000000..0549fea --- /dev/null +++ b/packages/core-admin/resources/views/components/forms/textarea.blade.php @@ -0,0 +1,87 @@ +{{-- + Textarea Component + + A textarea with authorization support, auto-resize, labels, and error display. + + Props: + - id: string (required) - Textarea element ID + - label: string|null - Label text + - helper: string|null - Helper text below textarea + - error: string|null - Error message + - placeholder: string|null - Placeholder text + - rows: int - Number of visible rows (default: 3) + - autoResize: bool - Enable auto-resize via Alpine.js + - disabled: bool - Whether textarea is disabled + - required: bool - Whether textarea is required + - canGate: string|null - Gate/ability to check + - canResource: mixed|null - Resource to check against + - canHide: bool - Hide instead of disable when unauthorized + + Usage: + +--}} + +@if(!$hidden) +
only(['class', 'x-show', 'x-if', 'x-cloak'])->merge(['class' => 'space-y-1']) }}> + {{-- Label --}} + @if($label) + + @endif + + {{-- Textarea --}} + + + {{-- Helper text --}} + @if($helper && !$error) +

{{ $helper }}

+ @endif + + {{-- Error message --}} + @if($error) +

{{ $error }}

+ @elseif($errors->has($id)) +

{{ $errors->first($id) }}

+ @endif +
+@endif diff --git a/packages/core-admin/resources/views/components/forms/toggle.blade.php b/packages/core-admin/resources/views/components/forms/toggle.blade.php new file mode 100644 index 0000000..ed843a8 --- /dev/null +++ b/packages/core-admin/resources/views/components/forms/toggle.blade.php @@ -0,0 +1,104 @@ +{{-- + Toggle Component + + A toggle switch with authorization support and instant save capability. + + Props: + - id: string (required) - Toggle element ID + - label: string|null - Label text + - description: string|null - Description text + - error: string|null - Error message + - size: string - Toggle size: sm, md, lg + - instantSave: bool - Enable instant save on change + - instantSaveMethod: string|null - Livewire method to call on change + - disabled: bool - Whether toggle is disabled + - canGate: string|null - Gate/ability to check + - canResource: mixed|null - Resource to check against + - canHide: bool - Hide instead of disable when unauthorized + + Usage: + + + {{-- With instant save --}} + +--}} + +@if(!$hidden) +
only(['class', 'x-show', 'x-if', 'x-cloak'])->merge(['class' => 'space-y-1']) }}> +
+ {{-- Label and description --}} + @if($label || $description) +
+ @if($label) + + @endif + + @if($description) +

{{ $description }}

+ @endif +
+ @endif + + {{-- Toggle switch --}} + +
+ + {{-- Error message --}} + @if($error) +

{{ $error }}

+ @elseif($errors->has($id)) +

{{ $errors->first($id) }}

+ @endif +
+@endif diff --git a/packages/core-admin/src/Boot.php b/packages/core-admin/src/Boot.php index a97affc..53b1ea2 100644 --- a/packages/core-admin/src/Boot.php +++ b/packages/core-admin/src/Boot.php @@ -4,13 +4,24 @@ declare(strict_types=1); namespace Core\Admin; +use Core\Admin\Forms\View\Components\Button; +use Core\Admin\Forms\View\Components\Checkbox; +use Core\Admin\Forms\View\Components\FormGroup; +use Core\Admin\Forms\View\Components\Input; +use Core\Admin\Forms\View\Components\Select; +use Core\Admin\Forms\View\Components\Textarea; +use Core\Admin\Forms\View\Components\Toggle; +use Core\Admin\Search\Providers\AdminPageSearchProvider; +use Core\Admin\Search\SearchProviderRegistry; use Core\ModuleRegistry; +use Illuminate\Support\Facades\Blade; use Illuminate\Support\ServiceProvider; /** * Core Admin Package Bootstrap. * - * Registers package paths with the module scanner. + * Registers package paths with the module scanner and initializes + * admin-specific services like the search provider registry. */ class Boot extends ServiceProvider { @@ -20,11 +31,58 @@ class Boot extends ServiceProvider app(ModuleRegistry::class)->addPaths([ __DIR__.'/Website', ]); + + // Register the search provider registry as a singleton + $this->app->singleton(SearchProviderRegistry::class); } public function boot(): void { // Load Hub translations $this->loadTranslationsFrom(__DIR__.'/Mod/Hub/Lang', 'hub'); + + // Register form components + $this->registerFormComponents(); + + // Register the default search providers + $this->registerSearchProviders(); + } + + /** + * Register form components with authorization support. + * + * Components are registered with the 'core-forms' prefix: + * - + * - + * - + * - + * - + * - + * - + */ + protected function registerFormComponents(): void + { + // Register views namespace for form component templates + $this->loadViewsFrom(dirname(__DIR__).'/resources/views', 'core-forms'); + + // Register class-backed form components + Blade::component('core-forms.input', Input::class); + Blade::component('core-forms.textarea', Textarea::class); + Blade::component('core-forms.select', Select::class); + Blade::component('core-forms.checkbox', Checkbox::class); + Blade::component('core-forms.button', Button::class); + Blade::component('core-forms.toggle', Toggle::class); + Blade::component('core-forms.form-group', FormGroup::class); + } + + /** + * Register the default search providers. + */ + protected function registerSearchProviders(): void + { + $registry = $this->app->make(SearchProviderRegistry::class); + + // Register the built-in admin page search provider + $registry->register($this->app->make(AdminPageSearchProvider::class)); } } diff --git a/packages/core-admin/src/Forms/Concerns/HasAuthorizationProps.php b/packages/core-admin/src/Forms/Concerns/HasAuthorizationProps.php new file mode 100644 index 0000000..dff78c2 --- /dev/null +++ b/packages/core-admin/src/Forms/Concerns/HasAuthorizationProps.php @@ -0,0 +1,101 @@ + + * Delete + * ``` + */ +trait HasAuthorizationProps +{ + /** + * The gate/ability to check (e.g., 'update', 'delete'). + */ + public ?string $canGate = null; + + /** + * The resource/model to check the gate against. + */ + public mixed $canResource = null; + + /** + * Whether to hide the component (instead of disabling) when unauthorized. + */ + public bool $canHide = false; + + /** + * Resolve whether the component should be disabled based on authorization. + * + * If `canGate` and `canResource` are both provided and the user lacks + * the required permission, the component will be disabled. + * + * @param bool $explicitlyDisabled Whether the component was explicitly disabled via props + */ + protected function resolveDisabledState(bool $explicitlyDisabled = false): bool + { + // Already explicitly disabled - no need to check authorization + if ($explicitlyDisabled) { + return true; + } + + // No authorization check configured + if (! $this->canGate || $this->canResource === null) { + return false; + } + + // Check if user can perform the action + return ! $this->userCan(); + } + + /** + * Resolve whether the component should be hidden based on authorization. + * + * Only hides if `canHide` is true and the user lacks permission. + */ + protected function resolveHiddenState(): bool + { + // Not configured to hide on unauthorized + if (! $this->canHide) { + return false; + } + + // No authorization check configured + if (! $this->canGate || $this->canResource === null) { + return false; + } + + // Hide if user cannot perform the action + return ! $this->userCan(); + } + + /** + * Check if the current user can perform the gate action on the resource. + */ + protected function userCan(): bool + { + $user = auth()->user(); + + if (! $user) { + return false; + } + + return $user->can($this->canGate, $this->canResource); + } +} diff --git a/packages/core-admin/src/Forms/View/Components/Button.php b/packages/core-admin/src/Forms/View/Components/Button.php new file mode 100644 index 0000000..a11ff8f --- /dev/null +++ b/packages/core-admin/src/Forms/View/Components/Button.php @@ -0,0 +1,135 @@ + + * Save Changes + * + * + * + * Delete + * + * ``` + */ +class Button extends Component +{ + use HasAuthorizationProps; + + public string $type; + + public string $variant; + + public string $size; + + public ?string $icon; + + public ?string $iconRight; + + public bool $loading; + + public ?string $loadingText; + + public bool $disabled; + + public bool $hidden; + + public string $variantClasses; + + public string $sizeClasses; + + public function __construct( + string $type = 'button', + string $variant = 'primary', + string $size = 'md', + ?string $icon = null, + ?string $iconRight = null, + bool $loading = false, + ?string $loadingText = null, + bool $disabled = false, + // Authorization props + ?string $canGate = null, + mixed $canResource = null, + bool $canHide = false, + ) { + $this->type = $type; + $this->variant = $variant; + $this->size = $size; + $this->icon = $icon; + $this->iconRight = $iconRight; + $this->loading = $loading; + $this->loadingText = $loadingText; + + // Authorization setup + $this->canGate = $canGate; + $this->canResource = $canResource; + $this->canHide = $canHide; + + // Resolve states based on authorization + $this->disabled = $this->resolveDisabledState($disabled); + $this->hidden = $this->resolveHiddenState(); + + // Resolve variant and size classes + $this->variantClasses = $this->resolveVariantClasses(); + $this->sizeClasses = $this->resolveSizeClasses(); + } + + protected function resolveVariantClasses(): string + { + return match ($this->variant) { + 'primary' => 'bg-violet-600 hover:bg-violet-700 text-white focus:ring-violet-500 disabled:bg-violet-400', + 'secondary' => 'bg-gray-100 hover:bg-gray-200 text-gray-700 dark:bg-gray-700 dark:hover:bg-gray-600 dark:text-gray-200 focus:ring-gray-500 disabled:bg-gray-100 disabled:dark:bg-gray-800', + 'danger' => 'bg-red-600 hover:bg-red-700 text-white focus:ring-red-500 disabled:bg-red-400', + 'ghost' => 'bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300 focus:ring-gray-500', + default => 'bg-violet-600 hover:bg-violet-700 text-white focus:ring-violet-500 disabled:bg-violet-400', + }; + } + + protected function resolveSizeClasses(): string + { + return match ($this->size) { + 'sm' => 'px-3 py-1.5 text-sm', + 'lg' => 'px-6 py-3 text-base', + default => 'px-4 py-2 text-sm', + }; + } + + public function render() + { + return view('core-forms::components.forms.button'); + } +} diff --git a/packages/core-admin/src/Forms/View/Components/Checkbox.php b/packages/core-admin/src/Forms/View/Components/Checkbox.php new file mode 100644 index 0000000..a9d8181 --- /dev/null +++ b/packages/core-admin/src/Forms/View/Components/Checkbox.php @@ -0,0 +1,89 @@ + + * ``` + */ +class Checkbox extends Component +{ + use HasAuthorizationProps; + + public string $id; + + public ?string $label; + + public ?string $description; + + public ?string $error; + + public string $labelPosition; + + public bool $disabled; + + public bool $hidden; + + public function __construct( + string $id, + ?string $label = null, + ?string $description = null, + ?string $error = null, + string $labelPosition = 'right', + bool $disabled = false, + // Authorization props + ?string $canGate = null, + mixed $canResource = null, + bool $canHide = false, + ) { + $this->id = $id; + $this->label = $label; + $this->description = $description; + $this->error = $error; + $this->labelPosition = $labelPosition; + + // Authorization setup + $this->canGate = $canGate; + $this->canResource = $canResource; + $this->canHide = $canHide; + + // Resolve states based on authorization + $this->disabled = $this->resolveDisabledState($disabled); + $this->hidden = $this->resolveHiddenState(); + } + + public function render() + { + return view('core-forms::components.forms.checkbox'); + } +} diff --git a/packages/core-admin/src/Forms/View/Components/FormGroup.php b/packages/core-admin/src/Forms/View/Components/FormGroup.php new file mode 100644 index 0000000..9e47675 --- /dev/null +++ b/packages/core-admin/src/Forms/View/Components/FormGroup.php @@ -0,0 +1,88 @@ + + * + *
+ * ``` + */ +class FormGroup extends Component +{ + public ?string $label; + + public ?string $for; + + public ?string $error; + + public ?string $helper; + + public bool $required; + + public string $errorMessage; + + public function __construct( + ?string $label = null, + ?string $for = null, + ?string $error = null, + ?string $helper = null, + bool $required = false, + ) { + $this->label = $label; + $this->for = $for; + $this->error = $error; + $this->helper = $helper; + $this->required = $required; + + // Resolve error message from validation bag + $this->errorMessage = $this->resolveError(); + } + + protected function resolveError(): string + { + if (! $this->error) { + return ''; + } + + $errors = session('errors'); + + if (! $errors) { + return ''; + } + + return $errors->first($this->error) ?? ''; + } + + public function hasError(): bool + { + return ! empty($this->errorMessage); + } + + public function render() + { + return view('core-forms::components.forms.form-group'); + } +} diff --git a/packages/core-admin/src/Forms/View/Components/Input.php b/packages/core-admin/src/Forms/View/Components/Input.php new file mode 100644 index 0000000..e9e3a45 --- /dev/null +++ b/packages/core-admin/src/Forms/View/Components/Input.php @@ -0,0 +1,99 @@ + + * ``` + */ +class Input extends Component +{ + use HasAuthorizationProps; + + public string $id; + + public ?string $label; + + public ?string $helper; + + public ?string $error; + + public string $type; + + public ?string $placeholder; + + public bool $disabled; + + public bool $hidden; + + public bool $required; + + public function __construct( + string $id, + ?string $label = null, + ?string $helper = null, + ?string $error = null, + string $type = 'text', + ?string $placeholder = null, + bool $disabled = false, + bool $required = false, + // Authorization props + ?string $canGate = null, + mixed $canResource = null, + bool $canHide = false, + ) { + $this->id = $id; + $this->label = $label; + $this->helper = $helper; + $this->error = $error; + $this->type = $type; + $this->placeholder = $placeholder; + $this->required = $required; + + // Authorization setup + $this->canGate = $canGate; + $this->canResource = $canResource; + $this->canHide = $canHide; + + // Resolve states based on authorization + $this->disabled = $this->resolveDisabledState($disabled); + $this->hidden = $this->resolveHiddenState(); + } + + public function render() + { + return view('core-forms::components.forms.input'); + } +} diff --git a/packages/core-admin/src/Forms/View/Components/Select.php b/packages/core-admin/src/Forms/View/Components/Select.php new file mode 100644 index 0000000..4dba7eb --- /dev/null +++ b/packages/core-admin/src/Forms/View/Components/Select.php @@ -0,0 +1,146 @@ + label or flat array) + * - Placeholder option + * - Multiple selection support + * - Label with automatic `for` attribute + * - Helper text support + * - Error display from validation + * - Dark mode support + * + * Usage: + * ```blade + * + * ``` + */ +class Select extends Component +{ + use HasAuthorizationProps; + + public string $id; + + public ?string $label; + + public ?string $helper; + + public ?string $error; + + public ?string $placeholder; + + public array $options; + + public array $normalizedOptions; + + public bool $multiple; + + public bool $disabled; + + public bool $hidden; + + public bool $required; + + public function __construct( + string $id, + array $options = [], + ?string $label = null, + ?string $helper = null, + ?string $error = null, + ?string $placeholder = null, + bool $multiple = false, + bool $disabled = false, + bool $required = false, + // Authorization props + ?string $canGate = null, + mixed $canResource = null, + bool $canHide = false, + ) { + $this->id = $id; + $this->label = $label; + $this->helper = $helper; + $this->error = $error; + $this->placeholder = $placeholder; + $this->options = $options; + $this->multiple = $multiple; + $this->required = $required; + + // Normalize options to value => label format + $this->normalizedOptions = $this->normalizeOptions($options); + + // Authorization setup + $this->canGate = $canGate; + $this->canResource = $canResource; + $this->canHide = $canHide; + + // Resolve states based on authorization + $this->disabled = $this->resolveDisabledState($disabled); + $this->hidden = $this->resolveHiddenState(); + } + + /** + * Normalize options to ensure consistent value => label format. + */ + protected function normalizeOptions(array $options): array + { + $normalized = []; + + foreach ($options as $key => $value) { + // Handle grouped options (optgroup) + if (is_array($value) && ! isset($value['label'])) { + $normalized[$key] = $this->normalizeOptions($value); + + continue; + } + + // Handle array format: ['label' => 'Display', 'value' => 'actual'] + if (is_array($value) && isset($value['label'])) { + $normalized[$value['value'] ?? $key] = $value['label']; + + continue; + } + + // Handle flat array: ['option1', 'option2'] + if (is_int($key)) { + $normalized[$value] = $value; + + continue; + } + + // Handle associative array: ['value' => 'Label'] + $normalized[$key] = $value; + } + + return $normalized; + } + + public function render() + { + return view('core-forms::components.forms.select'); + } +} diff --git a/packages/core-admin/src/Forms/View/Components/Textarea.php b/packages/core-admin/src/Forms/View/Components/Textarea.php new file mode 100644 index 0000000..b4eb6df --- /dev/null +++ b/packages/core-admin/src/Forms/View/Components/Textarea.php @@ -0,0 +1,104 @@ + + * ``` + */ +class Textarea extends Component +{ + use HasAuthorizationProps; + + public string $id; + + public ?string $label; + + public ?string $helper; + + public ?string $error; + + public ?string $placeholder; + + public int $rows; + + public bool $autoResize; + + public bool $disabled; + + public bool $hidden; + + public bool $required; + + public function __construct( + string $id, + ?string $label = null, + ?string $helper = null, + ?string $error = null, + ?string $placeholder = null, + int $rows = 3, + bool $autoResize = false, + bool $disabled = false, + bool $required = false, + // Authorization props + ?string $canGate = null, + mixed $canResource = null, + bool $canHide = false, + ) { + $this->id = $id; + $this->label = $label; + $this->helper = $helper; + $this->error = $error; + $this->placeholder = $placeholder; + $this->rows = $rows; + $this->autoResize = $autoResize; + $this->required = $required; + + // Authorization setup + $this->canGate = $canGate; + $this->canResource = $canResource; + $this->canHide = $canHide; + + // Resolve states based on authorization + $this->disabled = $this->resolveDisabledState($disabled); + $this->hidden = $this->resolveHiddenState(); + } + + public function render() + { + return view('core-forms::components.forms.textarea'); + } +} diff --git a/packages/core-admin/src/Forms/View/Components/Toggle.php b/packages/core-admin/src/Forms/View/Components/Toggle.php new file mode 100644 index 0000000..4530d30 --- /dev/null +++ b/packages/core-admin/src/Forms/View/Components/Toggle.php @@ -0,0 +1,127 @@ + + * ``` + */ +class Toggle extends Component +{ + use HasAuthorizationProps; + + public string $id; + + public ?string $label; + + public ?string $description; + + public ?string $error; + + public string $size; + + public bool $instantSave; + + public ?string $instantSaveMethod; + + public bool $disabled; + + public bool $hidden; + + public string $trackClasses; + + public string $thumbClasses; + + public function __construct( + string $id, + ?string $label = null, + ?string $description = null, + ?string $error = null, + string $size = 'md', + bool $instantSave = false, + ?string $instantSaveMethod = null, + bool $disabled = false, + // Authorization props + ?string $canGate = null, + mixed $canResource = null, + bool $canHide = false, + ) { + $this->id = $id; + $this->label = $label; + $this->description = $description; + $this->error = $error; + $this->size = $size; + $this->instantSave = $instantSave; + $this->instantSaveMethod = $instantSaveMethod; + + // Authorization setup + $this->canGate = $canGate; + $this->canResource = $canResource; + $this->canHide = $canHide; + + // Resolve states based on authorization + $this->disabled = $this->resolveDisabledState($disabled); + $this->hidden = $this->resolveHiddenState(); + + // Resolve size classes + [$this->trackClasses, $this->thumbClasses] = $this->resolveSizeClasses(); + } + + protected function resolveSizeClasses(): array + { + return match ($this->size) { + 'sm' => ['w-8 h-4', 'w-3 h-3'], + 'lg' => ['w-14 h-7', 'w-6 h-6'], + default => ['w-11 h-6', 'w-5 h-5'], + }; + } + + /** + * Get the wire:change directive for instant save. + */ + public function wireChange(): ?string + { + if (! $this->instantSave) { + return null; + } + + // Default to 'save' method if not specified + return $this->instantSaveMethod ?? 'save'; + } + + public function render() + { + return view('core-forms::components.forms.toggle'); + } +} diff --git a/packages/core-admin/src/Mod/Hub/Controllers/TeapotController.php b/packages/core-admin/src/Mod/Hub/Controllers/TeapotController.php index 581618e..d50113b 100644 --- a/packages/core-admin/src/Mod/Hub/Controllers/TeapotController.php +++ b/packages/core-admin/src/Mod/Hub/Controllers/TeapotController.php @@ -9,6 +9,7 @@ use Core\Headers\DetectLocation; use Core\Mod\Hub\Models\HoneypotHit; use Illuminate\Http\Request; use Illuminate\Http\Response; +use Illuminate\Support\Facades\RateLimiter; /** * Honeypot endpoint that returns 418 I'm a Teapot. @@ -28,27 +29,40 @@ class TeapotController $severity = HoneypotHit::severityForPath($path); $ip = $request->ip(); - // Optional services - use app() since route skips web middleware - $geoIp = app(DetectLocation::class); + // Rate limit honeypot logging to prevent DoS via log flooding. + // Each IP gets limited to N log entries per time window. + $rateLimitKey = 'honeypot:log:'.$ip; + $maxAttempts = (int) config('core.bouncer.honeypot.rate_limit_max', 10); + $decaySeconds = (int) config('core.bouncer.honeypot.rate_limit_window', 60); - HoneypotHit::create([ - 'ip_address' => $ip, - 'user_agent' => substr($userAgent ?? '', 0, 1000), - 'referer' => substr($request->header('Referer', ''), 0, 2000), - 'path' => $path, - 'method' => $request->method(), - 'headers' => $this->sanitizeHeaders($request->headers->all()), - 'country' => $geoIp?->getCountryCode($ip), - 'city' => $geoIp?->getCity($ip), - 'is_bot' => $botName !== null, - 'bot_name' => $botName, - 'severity' => $severity, - ]); + if (! RateLimiter::tooManyAttempts($rateLimitKey, $maxAttempts)) { + RateLimiter::hit($rateLimitKey, $decaySeconds); - // Auto-block critical hits (active probing) - // Skip localhost in dev to avoid blocking yourself + // Optional services - use app() since route skips web middleware + $geoIp = app(DetectLocation::class); + + HoneypotHit::create([ + 'ip_address' => $ip, + 'user_agent' => substr($userAgent ?? '', 0, 1000), + 'referer' => substr($request->header('Referer', ''), 0, 2000), + 'path' => $path, + 'method' => $request->method(), + 'headers' => $this->sanitizeHeaders($request->headers->all()), + 'country' => $geoIp?->getCountryCode($ip), + 'city' => $geoIp?->getCity($ip), + 'is_bot' => $botName !== null, + 'bot_name' => $botName, + 'severity' => $severity, + ]); + } + + // Auto-block critical hits (active probing) if enabled in config. + // Skip localhost in dev to avoid blocking yourself. + $autoBlockEnabled = config('core.bouncer.honeypot.auto_block_critical', true); $isLocalhost = in_array($ip, ['127.0.0.1', '::1'], true); - if ($severity === HoneypotHit::SEVERITY_CRITICAL && ! $isLocalhost) { + $isCritical = $severity === HoneypotHit::getSeverityCritical(); + + if ($autoBlockEnabled && $isCritical && ! $isLocalhost) { app(BlocklistService::class)->block($ip, 'honeypot_critical'); } diff --git a/packages/core-admin/src/Mod/Hub/Lang/en_GB/hub.php b/packages/core-admin/src/Mod/Hub/Lang/en_GB/hub.php index 474c8f0..fd3278c 100644 --- a/packages/core-admin/src/Mod/Hub/Lang/en_GB/hub.php +++ b/packages/core-admin/src/Mod/Hub/Lang/en_GB/hub.php @@ -427,12 +427,16 @@ return [ // Global Search 'search' => [ 'button' => 'Search...', - 'placeholder' => 'Search biolinks, accounts, posts...', + 'placeholder' => 'Search pages, workspaces, settings...', 'no_results' => 'No results found for ":query"', 'navigate' => 'to navigate', 'select' => 'to select', 'close' => 'to close', 'start_typing' => 'Start typing to search...', + 'tips' => 'Search pages, settings, and more', + 'recent' => 'Recent', + 'clear_recent' => 'Clear', + 'remove' => 'Remove', ], // Workspace Switcher diff --git a/packages/core-admin/src/Mod/Hub/Models/HoneypotHit.php b/packages/core-admin/src/Mod/Hub/Models/HoneypotHit.php index ca25f13..5373e89 100644 --- a/packages/core-admin/src/Mod/Hub/Models/HoneypotHit.php +++ b/packages/core-admin/src/Mod/Hub/Models/HoneypotHit.php @@ -29,35 +29,67 @@ class HoneypotHit extends Model /** * Severity levels for honeypot hits. + * + * These can be overridden via config('core.bouncer.honeypot.severity_levels'). */ public const SEVERITY_WARNING = 'warning'; // Ignored robots.txt (/teapot) public const SEVERITY_CRITICAL = 'critical'; // Active probing (/admin) + /** + * Default critical paths (used when config is not available). + */ + protected static array $defaultCriticalPaths = [ + 'admin', + 'wp-admin', + 'wp-login.php', + 'administrator', + 'phpmyadmin', + '.env', + '.git', + ]; + + /** + * Get the severity level string for 'critical'. + */ + public static function getSeverityCritical(): string + { + return config('core.bouncer.honeypot.severity_levels.critical', self::SEVERITY_CRITICAL); + } + + /** + * Get the severity level string for 'warning'. + */ + public static function getSeverityWarning(): string + { + return config('core.bouncer.honeypot.severity_levels.warning', self::SEVERITY_WARNING); + } + + /** + * Get the list of critical paths. + */ + public static function getCriticalPaths(): array + { + return config('core.bouncer.honeypot.critical_paths', self::$defaultCriticalPaths); + } + /** * Determine severity based on path. + * + * Uses configurable critical paths from config('core.bouncer.honeypot.critical_paths'). */ public static function severityForPath(string $path): string { - // Paths that indicate active malicious probing - $criticalPaths = [ - 'admin', - 'wp-admin', - 'wp-login.php', - 'administrator', - 'phpmyadmin', - '.env', - '.git', - ]; + $criticalPaths = self::getCriticalPaths(); $path = ltrim($path, '/'); foreach ($criticalPaths as $critical) { if (str_starts_with($path, $critical)) { - return self::SEVERITY_CRITICAL; + return self::getSeverityCritical(); } } - return self::SEVERITY_WARNING; + return self::getSeverityWarning(); } /** diff --git a/packages/core-admin/src/Search/Concerns/HasSearchProvider.php b/packages/core-admin/src/Search/Concerns/HasSearchProvider.php new file mode 100644 index 0000000..77db755 --- /dev/null +++ b/packages/core-admin/src/Search/Concerns/HasSearchProvider.php @@ -0,0 +1,49 @@ + 'unique-identifier', + * 'title' => 'Result Title', + * 'subtitle' => 'Optional description', + * 'url' => '/path/to/resource', + * 'icon' => 'optional-override-icon', + * 'meta' => ['optional' => 'metadata'], + * ] + * ``` + * + * ## Registration + * + * Providers are typically registered via `SearchProviderRegistry::register()` + * during the AdminPanelBooting event or in a service provider's boot method. + * + * + * @see SearchProviderRegistry For provider registration and discovery + * @see SearchResult For the result data structure + */ +interface SearchProvider +{ + /** + * Get the search type identifier. + * + * This is used for grouping results in the UI and for filtering. + * Examples: 'pages', 'users', 'posts', 'products', 'settings'. + */ + public function searchType(): string; + + /** + * Get the display label for this search type. + * + * This is shown as the group header in the search results. + * Should be a human-readable, translatable string. + */ + public function searchLabel(): string; + + /** + * Get the icon name for this search type. + * + * Used to display an icon next to search results from this provider. + * Should be a valid Heroicon or FontAwesome icon name. + */ + public function searchIcon(): string; + + /** + * Execute a search query. + * + * Searches the provider's data source for matches against the query. + * Should implement fuzzy matching where appropriate for better UX. + * + * @param string $query The search query string + * @param int $limit Maximum number of results to return (default: 5) + * @return Collection Collection of search results + */ + public function search(string $query, int $limit = 5): Collection; + + /** + * Get the URL for a search result. + * + * Generates the navigation URL for a given search result. + * This allows providers to implement custom URL generation logic. + * + * @param mixed $result The search result (model or array) + * @return string The URL to navigate to + */ + public function getUrl(mixed $result): string; + + /** + * Get the priority for ordering in search results. + * + * Lower numbers appear first. Default should be 50. + * Use lower numbers (10-40) for important/frequently accessed resources. + * Use higher numbers (60-100) for less important resources. + */ + public function searchPriority(): int; + + /** + * Check if this provider should be active for the current context. + * + * Override this to implement permission checks or context-based filtering. + * For example, only show certain searches to admin users. + * + * @param object|null $user The authenticated user + * @param object|null $workspace The current workspace context + */ + public function isAvailable(?object $user, ?object $workspace): bool; +} diff --git a/packages/core-admin/src/Search/Providers/AdminPageSearchProvider.php b/packages/core-admin/src/Search/Providers/AdminPageSearchProvider.php new file mode 100644 index 0000000..8d92e86 --- /dev/null +++ b/packages/core-admin/src/Search/Providers/AdminPageSearchProvider.php @@ -0,0 +1,216 @@ + + */ + protected array $pages = [ + [ + 'id' => 'dashboard', + 'title' => 'Dashboard', + 'subtitle' => 'Overview and quick actions', + 'url' => '/hub', + 'icon' => 'house', + ], + [ + 'id' => 'workspaces', + 'title' => 'Workspaces', + 'subtitle' => 'Manage your workspaces', + 'url' => '/hub/sites', + 'icon' => 'folders', + ], + [ + 'id' => 'profile', + 'title' => 'Profile', + 'subtitle' => 'Your account profile', + 'url' => '/hub/account', + 'icon' => 'user', + ], + [ + 'id' => 'settings', + 'title' => 'Settings', + 'subtitle' => 'Account settings and preferences', + 'url' => '/hub/account/settings', + 'icon' => 'gear', + ], + [ + 'id' => 'usage', + 'title' => 'Usage & Limits', + 'subtitle' => 'Monitor your usage and quotas', + 'url' => '/hub/account/usage', + 'icon' => 'chart-pie', + ], + [ + 'id' => 'ai-services', + 'title' => 'AI Services', + 'subtitle' => 'Configure AI providers', + 'url' => '/hub/ai-services', + 'icon' => 'sparkles', + ], + [ + 'id' => 'prompts', + 'title' => 'Prompt Manager', + 'subtitle' => 'Manage AI prompts', + 'url' => '/hub/prompts', + 'icon' => 'command', + ], + [ + 'id' => 'content-manager', + 'title' => 'Content Manager', + 'subtitle' => 'Manage WordPress content', + 'url' => '/hub/content-manager', + 'icon' => 'newspaper', + ], + [ + 'id' => 'deployments', + 'title' => 'Deployments', + 'subtitle' => 'View deployment history', + 'url' => '/hub/deployments', + 'icon' => 'rocket', + ], + [ + 'id' => 'databases', + 'title' => 'Databases', + 'subtitle' => 'Database management', + 'url' => '/hub/databases', + 'icon' => 'database', + ], + [ + 'id' => 'console', + 'title' => 'Server Console', + 'subtitle' => 'Terminal access', + 'url' => '/hub/console', + 'icon' => 'terminal', + ], + [ + 'id' => 'analytics', + 'title' => 'Analytics', + 'subtitle' => 'Traffic and performance', + 'url' => '/hub/analytics', + 'icon' => 'chart-line', + ], + [ + 'id' => 'activity', + 'title' => 'Activity Log', + 'subtitle' => 'Recent account activity', + 'url' => '/hub/activity', + 'icon' => 'clock-rotate-left', + ], + ]; + + protected SearchProviderRegistry $registry; + + public function __construct(SearchProviderRegistry $registry) + { + $this->registry = $registry; + } + + /** + * Get the search type identifier. + */ + public function searchType(): string + { + return 'pages'; + } + + /** + * Get the display label for this search type. + */ + public function searchLabel(): string + { + return __('Pages'); + } + + /** + * Get the icon name for this search type. + */ + public function searchIcon(): string + { + return 'rectangle-stack'; + } + + /** + * Get the priority for ordering in search results. + */ + public function searchPriority(): int + { + return 10; // Show pages first + } + + /** + * Execute a search query. + * + * @param string $query The search query string + * @param int $limit Maximum number of results to return + */ + public function search(string $query, int $limit = 5): Collection + { + return collect($this->pages) + ->filter(function ($page) use ($query) { + // Match against title and subtitle + return $this->registry->fuzzyMatch($query, $page['title']) + || $this->registry->fuzzyMatch($query, $page['subtitle']); + }) + ->sortByDesc(function ($page) use ($query) { + // Sort by relevance to title + return $this->registry->relevanceScore($query, $page['title']); + }) + ->take($limit) + ->map(function ($page) { + return new SearchResult( + id: $page['id'], + title: $page['title'], + url: $page['url'], + type: $this->searchType(), + icon: $page['icon'], + subtitle: $page['subtitle'], + ); + }) + ->values(); + } + + /** + * Get the URL for a search result. + * + * @param mixed $result The search result + */ + public function getUrl(mixed $result): string + { + if ($result instanceof SearchResult) { + return $result->url; + } + + return $result['url'] ?? '#'; + } +} diff --git a/packages/core-admin/src/Search/SearchProviderRegistry.php b/packages/core-admin/src/Search/SearchProviderRegistry.php new file mode 100644 index 0000000..c5fa718 --- /dev/null +++ b/packages/core-admin/src/Search/SearchProviderRegistry.php @@ -0,0 +1,305 @@ +getAllItems(); + * return $results->filter(function ($item) use ($query) { + * return app(SearchProviderRegistry::class) + * ->fuzzyMatch($query, $item->title); + * })->take($limit); + * } + * ``` + */ +class SearchProviderRegistry +{ + /** + * Registered search providers. + * + * @var array + */ + protected array $providers = []; + + /** + * Register a search provider. + */ + public function register(SearchProvider $provider): void + { + $this->providers[] = $provider; + } + + /** + * Register multiple search providers. + * + * @param array $providers + */ + public function registerMany(array $providers): void + { + foreach ($providers as $provider) { + $this->register($provider); + } + } + + /** + * Get all registered providers. + * + * @return array + */ + public function providers(): array + { + return $this->providers; + } + + /** + * Get available providers for a given context. + * + * @param object|null $user The authenticated user + * @param object|null $workspace The current workspace context + * @return Collection + */ + public function availableProviders(?object $user, ?object $workspace): Collection + { + return collect($this->providers) + ->filter(fn (SearchProvider $provider) => $provider->isAvailable($user, $workspace)) + ->sortBy(fn (SearchProvider $provider) => $provider->searchPriority()); + } + + /** + * Search across all available providers. + * + * Returns results grouped by search type, sorted by provider priority. + * + * @param string $query The search query + * @param object|null $user The authenticated user + * @param object|null $workspace The current workspace context + * @param int $limitPerProvider Maximum results per provider + * @return array + */ + public function search( + string $query, + ?object $user, + ?object $workspace, + int $limitPerProvider = 5 + ): array { + $grouped = []; + + foreach ($this->availableProviders($user, $workspace) as $provider) { + $type = $provider->searchType(); + $results = $provider->search($query, $limitPerProvider); + + // Convert results to array format with type/icon + $formattedResults = $results->map(function ($result) use ($provider) { + if ($result instanceof SearchResult) { + return $result->withTypeAndIcon( + $provider->searchType(), + $provider->searchIcon() + )->toArray(); + } + + // Handle array results + if (is_array($result)) { + $searchResult = SearchResult::fromArray($result); + + return $searchResult->withTypeAndIcon( + $provider->searchType(), + $provider->searchIcon() + )->toArray(); + } + + // Handle model objects with getUrl + return [ + 'id' => (string) ($result->id ?? uniqid()), + 'title' => (string) ($result->title ?? $result->name ?? ''), + 'subtitle' => (string) ($result->subtitle ?? $result->description ?? ''), + 'url' => $provider->getUrl($result), + 'type' => $provider->searchType(), + 'icon' => $provider->searchIcon(), + 'meta' => [], + ]; + })->toArray(); + + if (! empty($formattedResults)) { + $grouped[$type] = [ + 'label' => $provider->searchLabel(), + 'icon' => $provider->searchIcon(), + 'results' => $formattedResults, + ]; + } + } + + return $grouped; + } + + /** + * Flatten search results into a single array for keyboard navigation. + * + * @param array $grouped Grouped search results + */ + public function flattenResults(array $grouped): array + { + $flat = []; + + foreach ($grouped as $type => $group) { + foreach ($group['results'] as $result) { + $flat[] = $result; + } + } + + return $flat; + } + + /** + * Check if a query fuzzy-matches a target string. + * + * Supports: + * - Case-insensitive partial matching + * - Word-start matching (e.g., "ps" matches "Post Settings") + * - Abbreviation matching (e.g., "gs" matches "Global Search") + * + * @param string $query The search query + * @param string $target The target string to match against + */ + public function fuzzyMatch(string $query, string $target): bool + { + $query = Str::lower(trim($query)); + $target = Str::lower(trim($target)); + + // Empty query matches nothing + if ($query === '') { + return false; + } + + // Direct substring match (most common case) + if (Str::contains($target, $query)) { + return true; + } + + // Word-start matching: each character matches start of consecutive words + // e.g., "ps" matches "Post Settings", "gs" matches "Global Search" + $words = preg_split('/\s+/', $target); + $queryChars = str_split($query); + $wordIndex = 0; + $charIndex = 0; + + while ($charIndex < count($queryChars) && $wordIndex < count($words)) { + $char = $queryChars[$charIndex]; + $word = $words[$wordIndex]; + + if (Str::startsWith($word, $char)) { + $charIndex++; + } + $wordIndex++; + } + + if ($charIndex === count($queryChars)) { + return true; + } + + // Abbreviation matching: all query chars appear in order + // e.g., "gsr" matches "Global Search Results" + $targetIndex = 0; + foreach ($queryChars as $char) { + $foundAt = strpos($target, $char, $targetIndex); + if ($foundAt === false) { + return false; + } + $targetIndex = $foundAt + 1; + } + + return true; + } + + /** + * Calculate a relevance score for sorting results. + * + * Higher scores indicate better matches. + * + * @param string $query The search query + * @param string $target The target string + * @return int Score from 0-100 + */ + public function relevanceScore(string $query, string $target): int + { + $query = Str::lower(trim($query)); + $target = Str::lower(trim($target)); + + if ($query === '' || $target === '') { + return 0; + } + + // Exact match + if ($target === $query) { + return 100; + } + + // Starts with query + if (Str::startsWith($target, $query)) { + return 90; + } + + // Contains query as whole word + if (preg_match('/\b'.preg_quote($query, '/').'\b/', $target)) { + return 80; + } + + // Contains query + if (Str::contains($target, $query)) { + return 70; + } + + // Word-start matching + $words = preg_split('/\s+/', $target); + $queryChars = str_split($query); + $matched = 0; + $wordIndex = 0; + + foreach ($queryChars as $char) { + while ($wordIndex < count($words)) { + if (Str::startsWith($words[$wordIndex], $char)) { + $matched++; + $wordIndex++; + break; + } + $wordIndex++; + } + } + + if ($matched === count($queryChars)) { + return 60; + } + + // Fuzzy match + if ($this->fuzzyMatch($query, $target)) { + return 40; + } + + return 0; + } +} diff --git a/packages/core-admin/src/Search/SearchResult.php b/packages/core-admin/src/Search/SearchResult.php new file mode 100644 index 0000000..7035317 --- /dev/null +++ b/packages/core-admin/src/Search/SearchResult.php @@ -0,0 +1,104 @@ +id, + title: $this->title, + url: $this->url, + type: $type, + icon: $this->icon !== 'document' ? $this->icon : $icon, + subtitle: $this->subtitle, + meta: $this->meta, + ); + } + + /** + * Convert the result to an array. + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'title' => $this->title, + 'subtitle' => $this->subtitle, + 'url' => $this->url, + 'type' => $this->type, + 'icon' => $this->icon, + 'meta' => $this->meta, + ]; + } + + /** + * Specify data which should be serialized to JSON. + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/packages/core-admin/src/Search/Tests/SearchProviderRegistryTest.php b/packages/core-admin/src/Search/Tests/SearchProviderRegistryTest.php new file mode 100644 index 0000000..55d7663 --- /dev/null +++ b/packages/core-admin/src/Search/Tests/SearchProviderRegistryTest.php @@ -0,0 +1,237 @@ +registry = new SearchProviderRegistry; + } + + public function test_can_register_provider(): void + { + $provider = $this->createMockProvider('test', 'Test', 'document'); + + $this->registry->register($provider); + + $this->assertCount(1, $this->registry->providers()); + } + + public function test_can_register_many_providers(): void + { + $providers = [ + $this->createMockProvider('pages', 'Pages', 'document'), + $this->createMockProvider('users', 'Users', 'user'), + ]; + + $this->registry->registerMany($providers); + + $this->assertCount(2, $this->registry->providers()); + } + + public function test_fuzzy_match_direct_substring(): void + { + $this->assertTrue($this->registry->fuzzyMatch('dash', 'Dashboard')); + $this->assertTrue($this->registry->fuzzyMatch('board', 'Dashboard')); + $this->assertTrue($this->registry->fuzzyMatch('settings', 'Account Settings')); + } + + public function test_fuzzy_match_case_insensitive(): void + { + $this->assertTrue($this->registry->fuzzyMatch('DASH', 'dashboard')); + $this->assertTrue($this->registry->fuzzyMatch('Dashboard', 'DASHBOARD')); + } + + public function test_fuzzy_match_word_start(): void + { + // "gs" should match "Global Search" (G + S) + $this->assertTrue($this->registry->fuzzyMatch('gs', 'Global Search')); + + // "ps" should match "Post Settings" + $this->assertTrue($this->registry->fuzzyMatch('ps', 'Post Settings')); + + // "ul" should match "Usage Limits" + $this->assertTrue($this->registry->fuzzyMatch('ul', 'Usage Limits')); + } + + public function test_fuzzy_match_abbreviation(): void + { + // Characters appear in order + $this->assertTrue($this->registry->fuzzyMatch('dbd', 'dashboard')); + $this->assertTrue($this->registry->fuzzyMatch('gsr', 'global search results')); + } + + public function test_fuzzy_match_empty_query_returns_false(): void + { + $this->assertFalse($this->registry->fuzzyMatch('', 'Dashboard')); + $this->assertFalse($this->registry->fuzzyMatch(' ', 'Dashboard')); + } + + public function test_fuzzy_match_no_match(): void + { + $this->assertFalse($this->registry->fuzzyMatch('xyz', 'Dashboard')); + $this->assertFalse($this->registry->fuzzyMatch('zzz', 'Settings')); + } + + public function test_relevance_score_exact_match(): void + { + $score = $this->registry->relevanceScore('dashboard', 'dashboard'); + $this->assertEquals(100, $score); + } + + public function test_relevance_score_starts_with(): void + { + $score = $this->registry->relevanceScore('dash', 'dashboard'); + $this->assertEquals(90, $score); + } + + public function test_relevance_score_contains(): void + { + $score = $this->registry->relevanceScore('board', 'dashboard'); + $this->assertEquals(70, $score); + } + + public function test_relevance_score_word_start(): void + { + $score = $this->registry->relevanceScore('gs', 'global search'); + $this->assertEquals(60, $score); + } + + public function test_relevance_score_no_match(): void + { + $score = $this->registry->relevanceScore('xyz', 'dashboard'); + $this->assertEquals(0, $score); + } + + public function test_search_returns_grouped_results(): void + { + $provider = $this->createMockProvider('pages', 'Pages', 'document', [ + new SearchResult('1', 'Dashboard', '/hub', 'pages', 'house', 'Overview'), + new SearchResult('2', 'Settings', '/hub/settings', 'pages', 'gear', 'Preferences'), + ]); + + $this->registry->register($provider); + + $results = $this->registry->search('dash', null, null); + + $this->assertArrayHasKey('pages', $results); + $this->assertEquals('Pages', $results['pages']['label']); + $this->assertEquals('document', $results['pages']['icon']); + $this->assertCount(2, $results['pages']['results']); + } + + public function test_search_respects_provider_availability(): void + { + $availableProvider = $this->createMockProvider('pages', 'Pages', 'document', [], true); + $unavailableProvider = $this->createMockProvider('admin', 'Admin', 'shield', [], false); + + $this->registry->register($availableProvider); + $this->registry->register($unavailableProvider); + + $available = $this->registry->availableProviders(null, null); + + $this->assertCount(1, $available); + } + + public function test_flatten_results(): void + { + $grouped = [ + 'pages' => [ + 'label' => 'Pages', + 'icon' => 'document', + 'results' => [ + ['id' => '1', 'title' => 'Dashboard'], + ['id' => '2', 'title' => 'Settings'], + ], + ], + 'users' => [ + 'label' => 'Users', + 'icon' => 'user', + 'results' => [ + ['id' => '3', 'title' => 'Admin'], + ], + ], + ]; + + $flat = $this->registry->flattenResults($grouped); + + $this->assertCount(3, $flat); + $this->assertEquals('Dashboard', $flat[0]['title']); + $this->assertEquals('Settings', $flat[1]['title']); + $this->assertEquals('Admin', $flat[2]['title']); + } + + /** + * Create a mock search provider. + */ + protected function createMockProvider( + string $type, + string $label, + string $icon, + array $results = [], + bool $available = true + ): SearchProvider { + return new class($type, $label, $icon, $results, $available) implements SearchProvider + { + use HasSearchProvider; + + public function __construct( + protected string $type, + protected string $label, + protected string $icon, + protected array $results, + protected bool $available + ) {} + + public function searchType(): string + { + return $this->type; + } + + public function searchLabel(): string + { + return $this->label; + } + + public function searchIcon(): string + { + return $this->icon; + } + + public function search(string $query, int $limit = 5): Collection + { + return collect($this->results)->take($limit); + } + + public function getUrl(mixed $result): string + { + return $result['url'] ?? '#'; + } + + public function isAvailable(?object $user, ?object $workspace): bool + { + return $this->available; + } + }; + } +} diff --git a/packages/core-admin/src/Search/Tests/SearchResultTest.php b/packages/core-admin/src/Search/Tests/SearchResultTest.php new file mode 100644 index 0000000..8a085ae --- /dev/null +++ b/packages/core-admin/src/Search/Tests/SearchResultTest.php @@ -0,0 +1,165 @@ + 'value'], + ); + + $this->assertEquals('123', $result->id); + $this->assertEquals('Dashboard', $result->title); + $this->assertEquals('/hub', $result->url); + $this->assertEquals('pages', $result->type); + $this->assertEquals('house', $result->icon); + $this->assertEquals('Overview and quick actions', $result->subtitle); + $this->assertEquals(['key' => 'value'], $result->meta); + } + + public function test_can_create_from_array(): void + { + $data = [ + 'id' => '456', + 'title' => 'Settings', + 'url' => '/hub/settings', + 'type' => 'pages', + 'icon' => 'gear', + 'subtitle' => 'Account settings', + 'meta' => ['order' => 2], + ]; + + $result = SearchResult::fromArray($data); + + $this->assertEquals('456', $result->id); + $this->assertEquals('Settings', $result->title); + $this->assertEquals('/hub/settings', $result->url); + $this->assertEquals('pages', $result->type); + $this->assertEquals('gear', $result->icon); + $this->assertEquals('Account settings', $result->subtitle); + $this->assertEquals(['order' => 2], $result->meta); + } + + public function test_from_array_with_missing_fields(): void + { + $data = [ + 'title' => 'Minimal', + ]; + + $result = SearchResult::fromArray($data); + + $this->assertNotEmpty($result->id); // Should generate an ID + $this->assertEquals('Minimal', $result->title); + $this->assertEquals('#', $result->url); + $this->assertEquals('unknown', $result->type); + $this->assertEquals('document', $result->icon); + $this->assertNull($result->subtitle); + $this->assertEquals([], $result->meta); + } + + public function test_to_array(): void + { + $result = new SearchResult( + id: '789', + title: 'Test', + url: '/test', + type: 'test', + icon: 'test-icon', + subtitle: 'Test subtitle', + meta: ['foo' => 'bar'], + ); + + $array = $result->toArray(); + + $this->assertEquals([ + 'id' => '789', + 'title' => 'Test', + 'subtitle' => 'Test subtitle', + 'url' => '/test', + 'type' => 'test', + 'icon' => 'test-icon', + 'meta' => ['foo' => 'bar'], + ], $array); + } + + public function test_json_serialize(): void + { + $result = new SearchResult( + id: '1', + title: 'JSON Test', + url: '/json', + type: 'json', + icon: 'code', + ); + + $json = json_encode($result); + $decoded = json_decode($json, true); + + $this->assertEquals('1', $decoded['id']); + $this->assertEquals('JSON Test', $decoded['title']); + $this->assertEquals('/json', $decoded['url']); + } + + public function test_with_type_and_icon(): void + { + $original = new SearchResult( + id: '1', + title: 'Test', + url: '/test', + type: 'old-type', + icon: 'document', // Default icon + ); + + $modified = $original->withTypeAndIcon('new-type', 'new-icon'); + + // Original should be unchanged (immutable) + $this->assertEquals('old-type', $original->type); + $this->assertEquals('document', $original->icon); + + // Modified should have new values + $this->assertEquals('new-type', $modified->type); + $this->assertEquals('new-icon', $modified->icon); + + // Other properties should be preserved + $this->assertEquals('1', $modified->id); + $this->assertEquals('Test', $modified->title); + $this->assertEquals('/test', $modified->url); + } + + public function test_with_type_and_icon_preserves_custom_icon(): void + { + $original = new SearchResult( + id: '1', + title: 'Test', + url: '/test', + type: 'old-type', + icon: 'custom-icon', // Not the default + ); + + $modified = $original->withTypeAndIcon('new-type', 'fallback-icon'); + + // Should keep the custom icon, not use the fallback + $this->assertEquals('custom-icon', $modified->icon); + $this->assertEquals('new-type', $modified->type); + } +} diff --git a/packages/core-admin/src/Website/Hub/Boot.php b/packages/core-admin/src/Website/Hub/Boot.php index 87e19f7..0b74e4c 100644 --- a/packages/core-admin/src/Website/Hub/Boot.php +++ b/packages/core-admin/src/Website/Hub/Boot.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace Website\Hub; -use Core\Events\DomainResolving; use Core\Events\AdminPanelBooting; +use Core\Events\DomainResolving; use Core\Front\Admin\AdminMenuRegistry; use Core\Front\Admin\Concerns\HasMenuPermissions; use Core\Front\Admin\Contracts\AdminMenuProvider; @@ -85,6 +85,7 @@ class Boot extends ServiceProvider implements AdminMenuProvider // Register Livewire components $event->livewire('hub.admin.workspace-switcher', \Website\Hub\View\Modal\Admin\WorkspaceSwitcher::class); + $event->livewire('hub.admin.global-search', \Website\Hub\View\Modal\Admin\GlobalSearch::class); // Register menu provider app(AdminMenuRegistry::class)->register($this); @@ -136,7 +137,7 @@ class Boot extends ServiceProvider implements AdminMenuProvider 'label' => __('hub::hub.quick_actions.profile.title'), 'icon' => 'user', 'href' => route('hub.account'), - 'active' => request()->routeIs('hub.account') && !request()->routeIs('hub.account.*'), + 'active' => request()->routeIs('hub.account') && ! request()->routeIs('hub.account.*'), ], ], diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/components/header.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/components/header.blade.php index 286b89a..f71d30a 100644 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/components/header.blade.php +++ b/packages/core-admin/src/Website/Hub/View/Blade/admin/components/header.blade.php @@ -29,9 +29,16 @@
- diff --git a/packages/core-admin/src/Website/Hub/View/Blade/admin/global-search.blade.php b/packages/core-admin/src/Website/Hub/View/Blade/admin/global-search.blade.php index ab4d27f..d6200f2 100644 --- a/packages/core-admin/src/Website/Hub/View/Blade/admin/global-search.blade.php +++ b/packages/core-admin/src/Website/Hub/View/Blade/admin/global-search.blade.php @@ -1,14 +1,22 @@ {{-- -Global search component with ⌘K keyboard shortcut. +Global search component with Command+K keyboard shortcut. Include in your layout: - + + +Features: +- Command+K / Ctrl+K to open +- Arrow key navigation (up/down) +- Enter to select +- Escape to close +- Recent searches +- Grouped results by provider type --}}
- {{-- Search trigger button (optional - can be placed in navbar) --}} - @if(false) - - @endif - {{-- Search modal --}}
@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) @php $currentIndex++; @endphp @@ -111,7 +114,7 @@ Include in your layout:
@endforelse - @if(collect($this->results)->flatten(1)->isEmpty() && strlen($query) >= 2) + @if(!$this->hasResults && strlen($query) >= 2)

@@ -139,6 +142,58 @@ Include in your layout:

+ + @elseif($this->showRecentSearches) + {{-- Recent searches --}} +
+
+ + {{ __('hub::hub.search.recent') }} + + +
+
+ @foreach($recentSearches as $index => $recent) +
+ + +
+ @endforeach +
+
+ @else {{-- Initial state --}}
@@ -146,6 +201,9 @@ Include in your layout:

{{ __('hub::hub.search.start_typing') }}

+

+ {{ __('hub::hub.search.tips') }} +

@endif
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 []; } // ========================================