feat(search): implement global search component with keyboard navigation and recent searches

This commit is contained in:
Snider 2026-01-26 14:24:15 +00:00
parent edb34e38d5
commit b8531676e2
34 changed files with 3137 additions and 364 deletions

View file

@ -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
<input {{ $attributes->merge(['disabled' => $disabled]) }} />
```
### Usage
```blade
<x-forms.input canGate="update" :canResource="$biolink" id="name" label="Name" />
<x-forms.button canGate="update" :canResource="$biolink">Save</x-forms.button>
```
### 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
<x-forms.input id="name" label="Name" helper="Enter a display name" />
<x-forms.toggle id="is_public" label="Public" instantSave />
```
*See `changelog/2026/jan/` for completed features.*

View file

@ -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:**
- `<x-core-forms.input />` - Text input with label, helper, error
- `<x-core-forms.textarea />` - Textarea with auto-resize
- `<x-core-forms.select />` - Dropdown with grouped options
- `<x-core-forms.checkbox />` - Checkbox with description
- `<x-core-forms.button />` - Button with variants, loading state
- `<x-core-forms.toggle />` - Toggle with instant save
- `<x-core-forms.form-group />` - Wrapper for spacing
**Usage:**
```blade
<x-core-forms.input
id="name"
label="Name"
canGate="update"
:canResource="$model"
wire:model="name"
/>
<x-core-forms.button variant="danger" canGate="delete" :canResource="$model" canHide>
Delete
</x-core-forms.button>
```
---
### 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.

View file

@ -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:
<x-core-forms.button variant="primary" icon="check">
Save Changes
</x-core-forms.button>
<x-core-forms.button
variant="danger"
canGate="delete"
:canResource="$model"
canHide
>
Delete
</x-core-forms.button>
{{-- With loading state --}}
<x-core-forms.button
variant="primary"
wire:click="save"
wire:loading.attr="disabled"
loadingText="Saving..."
>
<span wire:loading.remove>Save</span>
<span wire:loading>Saving...</span>
</x-core-forms.button>
--}}
@if(!$hidden)
<button
type="{{ $type }}"
@if($disabled) disabled @endif
{{ $attributes->class([
'inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-900',
'disabled:cursor-not-allowed disabled:opacity-60',
$variantClasses,
$sizeClasses,
]) }}
>
{{-- Loading spinner (wire:loading compatible) --}}
@if($loading)
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
@endif
{{-- Left icon --}}
@if($icon && !$loading)
<flux:icon :name="$icon" class="w-4 h-4" />
@endif
{{-- Button content --}}
@if($loading && $loadingText)
{{ $loadingText }}
@else
{{ $slot }}
@endif
{{-- Right icon --}}
@if($iconRight)
<flux:icon :name="$iconRight" class="w-4 h-4" />
@endif
</button>
@endif

View file

@ -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:
<x-core-forms.checkbox
id="is_active"
label="Active"
description="Enable this feature for users"
canGate="update"
:canResource="$model"
wire:model="is_active"
/>
{{-- Label on left --}}
<x-core-forms.checkbox
id="remember"
label="Remember me"
labelPosition="left"
wire:model="remember"
/>
--}}
@if(!$hidden)
<div {{ $attributes->only(['class', 'x-show', 'x-if', 'x-cloak'])->merge(['class' => 'space-y-1']) }}>
<div @class([
'flex items-start gap-3',
'flex-row-reverse justify-end' => $labelPosition === 'left',
])>
{{-- Checkbox --}}
<div class="flex items-center h-5">
<input
type="checkbox"
id="{{ $id }}"
name="{{ $id }}"
@if($disabled) disabled @endif
{{ $attributes->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,
]) }}
/>
</div>
{{-- Label and description --}}
@if($label || $description)
<div class="text-sm">
@if($label)
<label for="{{ $id }}" @class([
'font-medium',
'text-gray-700 dark:text-gray-300' => !$disabled,
'text-gray-500 dark:text-gray-500 cursor-not-allowed' => $disabled,
])>
{{ $label }}
</label>
@endif
@if($description)
<p class="text-gray-500 dark:text-gray-400">{{ $description }}</p>
@endif
</div>
@endif
</div>
{{-- Error message --}}
@if($error)
<p class="text-sm text-red-600 dark:text-red-400">{{ $error }}</p>
@elseif($errors->has($id))
<p class="text-sm text-red-600 dark:text-red-400">{{ $errors->first($id) }}</p>
@endif
</div>
@endif

View file

@ -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:
<x-core-forms.form-group label="Email" for="email" error="email" required>
<input type="email" id="email" wire:model="email" />
</x-core-forms.form-group>
{{-- Without label --}}
<x-core-forms.form-group error="terms">
<x-core-forms.checkbox id="terms" label="I agree to the terms" />
</x-core-forms.form-group>
--}}
<div {{ $attributes->merge(['class' => 'space-y-1']) }}>
{{-- Label --}}
@if($label)
<label
@if($for) for="{{ $for }}" @endif
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ $label }}
@if($required)
<span class="text-red-500">*</span>
@endif
</label>
@endif
{{-- Content slot --}}
{{ $slot }}
{{-- Helper text --}}
@if($helper && !$hasError())
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $helper }}</p>
@endif
{{-- Error message --}}
@if($hasError())
<p class="text-sm text-red-600 dark:text-red-400">{{ $errorMessage }}</p>
@endif
</div>

View file

@ -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:
<x-core-forms.input
id="name"
label="Display Name"
helper="Enter a memorable name"
canGate="update"
:canResource="$model"
wire:model="name"
/>
--}}
@if(!$hidden)
<div {{ $attributes->only(['class', 'x-show', 'x-if', 'x-cloak'])->merge(['class' => 'space-y-1']) }}>
{{-- Label --}}
@if($label)
<label for="{{ $id }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ $label }}
@if($required)
<span class="text-red-500">*</span>
@endif
</label>
@endif
{{-- Input --}}
<input
type="{{ $type }}"
id="{{ $id }}"
name="{{ $id }}"
@if($placeholder) placeholder="{{ $placeholder }}" @endif
@if($disabled) disabled @endif
@if($required) required @endif
{{ $attributes->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)
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $helper }}</p>
@endif
{{-- Error message --}}
@if($error)
<p class="text-sm text-red-600 dark:text-red-400">{{ $error }}</p>
@elseif($errors->has($id))
<p class="text-sm text-red-600 dark:text-red-400">{{ $errors->first($id) }}</p>
@endif
</div>
@endif

View file

@ -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:
<x-core-forms.select
id="status"
label="Status"
:options="['draft' => 'Draft', 'published' => 'Published']"
placeholder="Select a status..."
canGate="update"
:canResource="$model"
wire:model="status"
/>
{{-- With grouped options --}}
<x-core-forms.select
id="timezone"
:options="[
'America' => ['America/New_York' => 'New York', 'America/Los_Angeles' => 'Los Angeles'],
'Europe' => ['Europe/London' => 'London', 'Europe/Paris' => 'Paris'],
]"
/>
--}}
@if(!$hidden)
<div {{ $attributes->only(['class', 'x-show', 'x-if', 'x-cloak'])->merge(['class' => 'space-y-1']) }}>
{{-- Label --}}
@if($label)
<label for="{{ $id }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ $label }}
@if($required)
<span class="text-red-500">*</span>
@endif
</label>
@endif
{{-- Select --}}
<select
id="{{ $id }}"
name="{{ $id }}"
@if($multiple) multiple @endif
@if($disabled) disabled @endif
@if($required) required @endif
{{ $attributes->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',
'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,
]) }}
>
{{-- Placeholder option --}}
@if($placeholder)
<option value="" disabled selected>{{ $placeholder }}</option>
@endif
{{-- Options --}}
@foreach($normalizedOptions as $value => $labelOrGroup)
@if(is_array($labelOrGroup))
{{-- Optgroup --}}
<optgroup label="{{ $value }}">
@foreach($labelOrGroup as $optValue => $optLabel)
<option value="{{ $optValue }}">{{ $optLabel }}</option>
@endforeach
</optgroup>
@else
<option value="{{ $value }}">{{ $labelOrGroup }}</option>
@endif
@endforeach
{{-- Slot for custom options --}}
{{ $slot }}
</select>
{{-- Helper text --}}
@if($helper && !$error)
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $helper }}</p>
@endif
{{-- Error message --}}
@if($error)
<p class="text-sm text-red-600 dark:text-red-400">{{ $error }}</p>
@elseif($errors->has($id))
<p class="text-sm text-red-600 dark:text-red-400">{{ $errors->first($id) }}</p>
@endif
</div>
@endif

View file

@ -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:
<x-core-forms.textarea
id="description"
label="Description"
rows="4"
autoResize
canGate="update"
:canResource="$model"
wire:model="description"
/>
--}}
@if(!$hidden)
<div {{ $attributes->only(['class', 'x-show', 'x-if', 'x-cloak'])->merge(['class' => 'space-y-1']) }}>
{{-- Label --}}
@if($label)
<label for="{{ $id }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ $label }}
@if($required)
<span class="text-red-500">*</span>
@endif
</label>
@endif
{{-- Textarea --}}
<textarea
id="{{ $id }}"
name="{{ $id }}"
rows="{{ $rows }}"
@if($placeholder) placeholder="{{ $placeholder }}" @endif
@if($disabled) disabled @endif
@if($required) required @endif
@if($autoResize)
x-data="{ resize: () => { $el.style.height = 'auto'; $el.style.height = $el.scrollHeight + 'px' } }"
x-init="resize()"
x-on:input="resize()"
style="overflow: hidden;"
@endif
{{ $attributes->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',
'resize-y' => !$autoResize,
'resize-none' => $autoResize,
// 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,
]) }}
>{{ $slot }}</textarea>
{{-- Helper text --}}
@if($helper && !$error)
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $helper }}</p>
@endif
{{-- Error message --}}
@if($error)
<p class="text-sm text-red-600 dark:text-red-400">{{ $error }}</p>
@elseif($errors->has($id))
<p class="text-sm text-red-600 dark:text-red-400">{{ $errors->first($id) }}</p>
@endif
</div>
@endif

View file

@ -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:
<x-core-forms.toggle
id="is_public"
label="Public"
description="Make this visible to everyone"
canGate="update"
:canResource="$model"
wire:model="is_public"
/>
{{-- With instant save --}}
<x-core-forms.toggle
id="notifications"
label="Notifications"
instantSave
instantSaveMethod="savePreferences"
wire:model.live="notifications"
/>
--}}
@if(!$hidden)
<div {{ $attributes->only(['class', 'x-show', 'x-if', 'x-cloak'])->merge(['class' => 'space-y-1']) }}>
<div class="flex items-center justify-between gap-4">
{{-- Label and description --}}
@if($label || $description)
<div class="flex-1">
@if($label)
<label for="{{ $id }}" @class([
'block text-sm font-medium',
'text-gray-700 dark:text-gray-300' => !$disabled,
'text-gray-500 dark:text-gray-500' => $disabled,
])>
{{ $label }}
</label>
@endif
@if($description)
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $description }}</p>
@endif
</div>
@endif
{{-- Toggle switch --}}
<button
type="button"
role="switch"
id="{{ $id }}"
@if($disabled) disabled @endif
x-data="{ enabled: $wire?.entangle?.('{{ $id }}') ?? false }"
x-on:click="enabled = !enabled; $el.setAttribute('aria-checked', enabled)"
:aria-checked="enabled"
@if($instantSave && $wireChange())
x-on:click.debounce.300ms="$wire.{{ $wireChange() }}()"
@endif
{{ $attributes->except(['class', 'x-show', 'x-if', 'x-cloak', 'wire:model', 'wire:model.live', 'wire:model.defer'])->class([
'relative inline-flex shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out',
'focus:outline-none focus:ring-2 focus:ring-violet-500/20 focus:ring-offset-2 dark:focus:ring-offset-gray-900',
'cursor-pointer' => !$disabled,
'cursor-not-allowed opacity-60' => $disabled,
$trackClasses,
]) }}
:class="enabled ? 'bg-violet-600' : 'bg-gray-200 dark:bg-gray-700'"
>
<span class="sr-only">{{ $label ?? 'Toggle' }}</span>
<span
aria-hidden="true"
class="pointer-events-none inline-block rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {{ $thumbClasses }}"
:class="enabled ? 'translate-x-5' : 'translate-x-0'"
x-bind:class="{
'translate-x-5': enabled && '{{ $size }}' === 'md',
'translate-x-4': enabled && '{{ $size }}' === 'sm',
'translate-x-7': enabled && '{{ $size }}' === 'lg',
'translate-x-0': !enabled
}"
></span>
</button>
</div>
{{-- Error message --}}
@if($error)
<p class="text-sm text-red-600 dark:text-red-400">{{ $error }}</p>
@elseif($errors->has($id))
<p class="text-sm text-red-600 dark:text-red-400">{{ $errors->first($id) }}</p>
@endif
</div>
@endif

View file

@ -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:
* - <x-core-forms.input />
* - <x-core-forms.textarea />
* - <x-core-forms.select />
* - <x-core-forms.checkbox />
* - <x-core-forms.button />
* - <x-core-forms.toggle />
* - <x-core-forms.form-group />
*/
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));
}
}

View file

@ -0,0 +1,101 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Admin\Forms\Concerns;
/**
* Provides authorization-aware props for form components.
*
* Components using this trait can accept `canGate` and `canResource` props
* to automatically disable or hide based on user permissions.
*
* Usage:
* ```blade
* <x-core-forms.input canGate="update" :canResource="$biolink" id="name" />
* <x-core-forms.button canGate="delete" :canResource="$biolink" canHide>Delete</x-core-forms.button>
* ```
*/
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);
}
}

View file

@ -0,0 +1,135 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Admin\Forms\View\Components;
use Core\Admin\Forms\Concerns\HasAuthorizationProps;
use Illuminate\View\Component;
/**
* Button component with authorization support.
*
* Features:
* - Authorization via `canGate` / `canResource` props (disables or hides)
* - Variants: primary, secondary, danger, ghost
* - Loading state support (with wire:loading integration)
* - Icon support (left and right positions)
* - Size variants: sm, md, lg
* - Dark mode support
*
* Usage:
* ```blade
* <x-core-forms.button
* variant="primary"
* icon="check"
* canGate="update"
* :canResource="$model"
* >
* Save Changes
* </x-core-forms.button>
*
* <x-core-forms.button
* variant="danger"
* canGate="delete"
* :canResource="$model"
* canHide
* >
* Delete
* </x-core-forms.button>
* ```
*/
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');
}
}

View file

@ -0,0 +1,89 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Admin\Forms\View\Components;
use Core\Admin\Forms\Concerns\HasAuthorizationProps;
use Illuminate\View\Component;
/**
* Checkbox component with authorization support.
*
* Features:
* - Authorization via `canGate` / `canResource` props
* - Label positioning (left/right)
* - Description text
* - Error display from validation
* - Dark mode support
*
* Usage:
* ```blade
* <x-core-forms.checkbox
* id="is_active"
* label="Active"
* description="Enable this feature for users"
* canGate="update"
* :canResource="$model"
* wire:model="is_active"
* />
* ```
*/
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');
}
}

View file

@ -0,0 +1,88 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Admin\Forms\View\Components;
use Illuminate\View\Component;
/**
* Form group wrapper component for consistent spacing and error display.
*
* Features:
* - Consistent spacing between form elements
* - Error display from validation bag
* - Label support
* - Helper text support
* - Optional required indicator
*
* Usage:
* ```blade
* <x-core-forms.form-group label="Email" for="email" error="email" required>
* <input type="email" id="email" wire:model="email" />
* </x-core-forms.form-group>
* ```
*/
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');
}
}

View file

@ -0,0 +1,99 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Admin\Forms\View\Components;
use Core\Admin\Forms\Concerns\HasAuthorizationProps;
use Illuminate\View\Component;
/**
* Text input component with authorization support.
*
* Features:
* - Authorization via `canGate` / `canResource` props
* - Label with automatic `for` attribute
* - Helper text support
* - Error display from validation
* - Dark mode support
* - Disabled state styling
* - Livewire and Alpine.js compatible
*
* Usage:
* ```blade
* <x-core-forms.input
* id="name"
* label="Display Name"
* helper="Enter a memorable display name"
* canGate="update"
* :canResource="$model"
* wire:model="name"
* />
* ```
*/
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');
}
}

View file

@ -0,0 +1,146 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Admin\Forms\View\Components;
use Core\Admin\Forms\Concerns\HasAuthorizationProps;
use Illuminate\View\Component;
/**
* Select dropdown component with authorization support.
*
* Features:
* - Authorization via `canGate` / `canResource` props
* - Options array support (value => 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
* <x-core-forms.select
* id="status"
* label="Status"
* :options="['draft' => 'Draft', 'published' => 'Published']"
* placeholder="Select a status..."
* canGate="update"
* :canResource="$model"
* wire:model="status"
* />
* ```
*/
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');
}
}

View file

@ -0,0 +1,104 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Admin\Forms\View\Components;
use Core\Admin\Forms\Concerns\HasAuthorizationProps;
use Illuminate\View\Component;
/**
* Textarea component with authorization support.
*
* Features:
* - Authorization via `canGate` / `canResource` props
* - Configurable rows
* - Auto-resize option (via Alpine.js)
* - Label with automatic `for` attribute
* - Helper text support
* - Error display from validation
* - Dark mode support
*
* Usage:
* ```blade
* <x-core-forms.textarea
* id="description"
* label="Description"
* rows="4"
* autoResize
* canGate="update"
* :canResource="$model"
* wire:model="description"
* />
* ```
*/
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');
}
}

View file

@ -0,0 +1,127 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Admin\Forms\View\Components;
use Core\Admin\Forms\Concerns\HasAuthorizationProps;
use Illuminate\View\Component;
/**
* Toggle switch component with authorization support.
*
* Features:
* - Authorization via `canGate` / `canResource` props
* - `instantSave` for Livewire real-time persistence
* - Label and description
* - Size variants: sm, md, lg
* - Dark mode support
*
* Usage:
* ```blade
* <x-core-forms.toggle
* id="is_public"
* label="Public"
* description="Make this visible to everyone"
* instantSave
* canGate="update"
* :canResource="$model"
* wire:model="is_public"
* />
* ```
*/
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');
}
}

View file

@ -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');
}

View file

@ -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

View file

@ -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();
}
/**

View file

@ -0,0 +1,49 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Admin\Search\Concerns;
/**
* Trait providing default implementations for SearchProvider methods.
*
* Use this trait to reduce boilerplate when implementing SearchProvider.
*/
trait HasSearchProvider
{
/**
* Get the priority for ordering in search results.
*/
public function searchPriority(): int
{
return 50;
}
/**
* Check if this provider should be active for the current context.
*
* Default implementation returns true (always available).
*
* @param object|null $user The authenticated user
* @param object|null $workspace The current workspace context
*/
public function isAvailable(?object $user, ?object $workspace): bool
{
return true;
}
/**
* Escape LIKE wildcard characters for safe SQL queries.
*/
protected function escapeLikeWildcards(string $value): string
{
return str_replace(['%', '_'], ['\\%', '\\_'], $value);
}
}

View file

@ -0,0 +1,120 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Admin\Search\Contracts;
use Illuminate\Support\Collection;
/**
* Interface for search providers.
*
* Modules implement this interface to contribute searchable content to the
* global search (Command+K). Each provider is responsible for:
*
* - Defining a search type (e.g., 'pages', 'users', 'posts')
* - Providing an icon for visual identification
* - Executing searches against their data source
* - Generating URLs for navigation to results
*
* ## Search Result Format
*
* The `search()` method should return a Collection of SearchResult objects
* or arrays with the following structure:
*
* ```php
* [
* 'id' => '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<int, SearchResult|array> 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;
}

View file

@ -0,0 +1,216 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Admin\Search\Providers;
use Core\Admin\Search\Concerns\HasSearchProvider;
use Core\Admin\Search\Contracts\SearchProvider;
use Core\Admin\Search\SearchProviderRegistry;
use Core\Admin\Search\SearchResult;
use Illuminate\Support\Collection;
/**
* Search provider for admin navigation pages.
*
* Provides quick access to admin pages via global search.
* This is a built-in provider that indexes all admin navigation items.
*/
class AdminPageSearchProvider implements SearchProvider
{
use HasSearchProvider;
/**
* Static list of admin pages.
*
* These are the core admin navigation items that are always available.
* Modules can register additional search providers for their own pages.
*
* @var array<array{id: string, title: string, subtitle: string, url: string, icon: string}>
*/
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'] ?? '#';
}
}

View file

@ -0,0 +1,305 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Admin\Search;
use Core\Admin\Search\Contracts\SearchProvider;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
/**
* Registry for search providers.
*
* Manages registration and discovery of SearchProvider implementations.
* Coordinates searching across all registered providers and aggregates
* results into a unified structure for the GlobalSearch component.
*
* ## Fuzzy Matching
*
* The registry provides built-in fuzzy matching support via the `fuzzyMatch()`
* method. Providers can use this for consistent search behavior:
*
* ```php
* public function search(string $query, int $limit = 5): Collection
* {
* $results = $this->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<SearchProvider>
*/
protected array $providers = [];
/**
* Register a search provider.
*/
public function register(SearchProvider $provider): void
{
$this->providers[] = $provider;
}
/**
* Register multiple search providers.
*
* @param array<SearchProvider> $providers
*/
public function registerMany(array $providers): void
{
foreach ($providers as $provider) {
$this->register($provider);
}
}
/**
* Get all registered providers.
*
* @return array<SearchProvider>
*/
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<int, SearchProvider>
*/
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<string, array{label: string, icon: string, results: 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;
}
}

View file

@ -0,0 +1,104 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Admin\Search;
use Illuminate\Contracts\Support\Arrayable;
use JsonSerializable;
/**
* Data transfer object for search results.
*
* Represents a single search result from a SearchProvider. Implements
* Arrayable and JsonSerializable for easy serialization to Livewire
* and JavaScript.
*/
final class SearchResult implements Arrayable, JsonSerializable
{
/**
* Create a new search result instance.
*
* @param string $id Unique identifier for the result
* @param string $title Primary display text
* @param string $url Navigation URL
* @param string $type The search type (from provider)
* @param string $icon Icon name for display
* @param string|null $subtitle Secondary display text
* @param array $meta Additional metadata
*/
public function __construct(
public readonly string $id,
public readonly string $title,
public readonly string $url,
public readonly string $type,
public readonly string $icon,
public readonly ?string $subtitle = null,
public readonly array $meta = [],
) {}
/**
* Create a SearchResult from an array.
*/
public static function fromArray(array $data): static
{
return new self(
id: (string) ($data['id'] ?? uniqid()),
title: (string) ($data['title'] ?? ''),
url: (string) ($data['url'] ?? '#'),
type: (string) ($data['type'] ?? 'unknown'),
icon: (string) ($data['icon'] ?? 'document'),
subtitle: $data['subtitle'] ?? null,
meta: $data['meta'] ?? [],
);
}
/**
* Create a SearchResult with a new type and icon.
*
* Used by the registry to set type/icon from the provider.
*/
public function withTypeAndIcon(string $type, string $icon): static
{
return new static(
id: $this->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();
}
}

View file

@ -0,0 +1,237 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Admin\Search\Tests;
use Core\Admin\Search\Concerns\HasSearchProvider;
use Core\Admin\Search\Contracts\SearchProvider;
use Core\Admin\Search\SearchProviderRegistry;
use Core\Admin\Search\SearchResult;
use Illuminate\Support\Collection;
use PHPUnit\Framework\TestCase;
class SearchProviderRegistryTest extends TestCase
{
protected SearchProviderRegistry $registry;
protected function setUp(): void
{
parent::setUp();
$this->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;
}
};
}
}

View file

@ -0,0 +1,165 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Admin\Search\Tests;
use Core\Admin\Search\SearchResult;
use PHPUnit\Framework\TestCase;
class SearchResultTest extends TestCase
{
public function test_can_create_search_result(): void
{
$result = new SearchResult(
id: '123',
title: 'Dashboard',
url: '/hub',
type: 'pages',
icon: 'house',
subtitle: 'Overview and quick actions',
meta: ['key' => '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);
}
}

View file

@ -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.*'),
],
],

View file

@ -29,9 +29,16 @@
<div class="flex items-center space-x-1">
<!-- Search button -->
<button class="flex items-center justify-center w-11 h-11 hover:bg-gray-100 lg:hover:bg-gray-200 dark:hover:bg-gray-700/50 dark:lg:hover:bg-gray-800 rounded-full transition-colors">
<span class="sr-only">Search</span>
<core:icon name="magnifying-glass" size="fa-lg" class="text-gray-500 dark:text-gray-400" />
<button
x-data
@click="$dispatch('open-global-search')"
class="flex items-center justify-center gap-2 px-3 h-9 hover:bg-gray-100 lg:hover:bg-gray-200 dark:hover:bg-gray-700/50 dark:lg:hover:bg-gray-800 rounded-lg transition-colors"
>
<core:icon name="magnifying-glass" class="h-4 w-4 text-gray-500 dark:text-gray-400" />
<span class="hidden sm:inline text-sm text-gray-500 dark:text-gray-400">{{ __('hub::hub.search.button') }}</span>
<kbd class="hidden lg:inline-flex items-center gap-0.5 rounded bg-gray-200 px-1.5 py-0.5 text-xs font-medium text-gray-500 dark:bg-gray-700 dark:text-gray-400">
<span class="text-xs">{{ PHP_OS_FAMILY === 'Darwin' ? '⌘' : 'Ctrl' }}</span>K
</kbd>
</button>
<!-- Notifications button -->

View file

@ -1,14 +1,22 @@
{{--
Global search component with K keyboard shortcut.
Global search component with Command+K keyboard shortcut.
Include in your layout:
<admin:global-search />
<livewire:hub.admin.global-search />
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
--}}
<div
x-data="{
init() {
// Listen for ⌘K / Ctrl+K keyboard shortcut
// Listen for Command+K / Ctrl+K keyboard shortcut
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
@ -19,21 +27,6 @@ Include in your layout:
}"
x-on:navigate-to-url.window="Livewire.navigate($event.detail.url)"
>
{{-- Search trigger button (optional - can be placed in navbar) --}}
@if(false)
<button
wire:click="openSearch"
type="button"
class="flex items-center gap-2 rounded-lg bg-zinc-100 px-3 py-2 text-sm text-zinc-500 transition hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-700"
>
<core:icon name="magnifying-glass" class="h-4 w-4" />
<span>{{ __('hub::hub.search.button') }}</span>
<kbd class="ml-2 hidden rounded bg-zinc-200 px-1.5 py-0.5 text-xs font-medium text-zinc-500 dark:bg-zinc-700 dark:text-zinc-400 sm:inline-block">
⌘K
</kbd>
</button>
@endif
{{-- Search modal --}}
<core:modal wire:model="open" class="max-w-xl" variant="bare">
<div
@ -69,33 +62,43 @@ Include in your layout:
<div class="max-h-96 overflow-y-auto border-t border-zinc-200 dark:border-zinc-700">
@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 --}}
<div class="sticky top-0 bg-zinc-50 px-4 py-2 text-xs font-semibold uppercase tracking-wider text-zinc-500 dark:bg-zinc-800/50 dark:text-zinc-400">
{{ str($type)->title()->plural() }}
<div class="sticky top-0 z-10 flex items-center gap-2 bg-zinc-50 px-4 py-2 dark:bg-zinc-800/80 backdrop-blur-sm">
<core:icon :name="$group['icon']" class="h-3.5 w-3.5 text-zinc-400" />
<span class="text-xs font-semibold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
{{ $group['label'] }}
</span>
</div>
{{-- Results list --}}
@foreach($items as $item)
@foreach($group['results'] as $item)
<button
wire:click="navigateTo({{ json_encode($item) }})"
type="button"
class="flex w-full items-center gap-3 px-4 py-3 text-left transition {{ $selectedIndex === $currentIndex ? 'bg-blue-50 dark:bg-blue-900/20' : 'hover:bg-zinc-50 dark:hover:bg-zinc-700/50' }}"
>
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-zinc-100 text-zinc-500 dark:bg-zinc-700 dark:text-zinc-400">
<core:icon name="{{ $item['icon'] }}" class="h-5 w-5" />
<div class="flex h-10 w-10 items-center justify-center rounded-lg {{ $selectedIndex === $currentIndex ? 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-400' : 'bg-zinc-100 text-zinc-500 dark:bg-zinc-700 dark:text-zinc-400' }}">
<core:icon :name="$item['icon']" class="h-5 w-5" />
</div>
<div class="min-w-0 flex-1">
<div class="truncate font-medium text-zinc-900 dark:text-white">
<div class="truncate font-medium {{ $selectedIndex === $currentIndex ? 'text-blue-900 dark:text-blue-100' : 'text-zinc-900 dark:text-white' }}">
{{ $item['title'] }}
</div>
<div class="truncate text-sm text-zinc-500 dark:text-zinc-400">
{{ $item['subtitle'] }}
</div>
@if($item['subtitle'])
<div class="truncate text-sm {{ $selectedIndex === $currentIndex ? 'text-blue-600 dark:text-blue-300' : 'text-zinc-500 dark:text-zinc-400' }}">
{{ $item['subtitle'] }}
</div>
@endif
</div>
@if($selectedIndex === $currentIndex)
<core:icon name="arrow-right" class="h-4 w-4 text-blue-500" />
<div class="flex items-center gap-1">
<kbd class="rounded bg-blue-100 px-1.5 py-0.5 text-xs font-mono text-blue-600 dark:bg-blue-900/40 dark:text-blue-400">
Enter
</kbd>
<core:icon name="arrow-right" class="h-4 w-4 text-blue-500" />
</div>
@endif
</button>
@php $currentIndex++; @endphp
@ -111,7 +114,7 @@ Include in your layout:
</div>
@endforelse
@if(collect($this->results)->flatten(1)->isEmpty() && strlen($query) >= 2)
@if(!$this->hasResults && strlen($query) >= 2)
<div class="px-4 py-12 text-center">
<core:icon name="magnifying-glass" class="mx-auto h-10 w-10 text-zinc-300 dark:text-zinc-600" />
<p class="mt-3 text-sm text-zinc-500 dark:text-zinc-400">
@ -139,6 +142,58 @@ Include in your layout:
</span>
</div>
</div>
@elseif($this->showRecentSearches)
{{-- Recent searches --}}
<div class="border-t border-zinc-200 dark:border-zinc-700">
<div class="flex items-center justify-between px-4 py-2 bg-zinc-50 dark:bg-zinc-800/80">
<span class="text-xs font-semibold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
{{ __('hub::hub.search.recent') }}
</span>
<button
wire:click="clearRecentSearches"
type="button"
class="text-xs text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300"
>
{{ __('hub::hub.search.clear_recent') }}
</button>
</div>
<div class="max-h-72 overflow-y-auto">
@foreach($recentSearches as $index => $recent)
<div class="group flex items-center">
<button
wire:click="navigateToRecent({{ $index }})"
type="button"
class="flex flex-1 items-center gap-3 px-4 py-3 text-left hover:bg-zinc-50 dark:hover:bg-zinc-700/50 transition"
>
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-zinc-100 text-zinc-400 dark:bg-zinc-700 dark:text-zinc-500">
<core:icon :name="$recent['icon']" class="h-4 w-4" />
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-zinc-700 dark:text-zinc-200">
{{ $recent['title'] }}
</div>
@if($recent['subtitle'])
<div class="truncate text-xs text-zinc-500 dark:text-zinc-400">
{{ $recent['subtitle'] }}
</div>
@endif
</div>
<core:icon name="clock-rotate-left" class="h-4 w-4 text-zinc-300 dark:text-zinc-600" />
</button>
<button
wire:click="removeRecentSearch({{ $index }})"
type="button"
class="p-2 mr-2 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 opacity-0 group-hover:opacity-100 transition-opacity"
title="{{ __('hub::hub.search.remove') }}"
>
<core:icon name="x-mark" class="h-4 w-4" />
</button>
</div>
@endforeach
</div>
</div>
@else
{{-- Initial state --}}
<div class="border-t border-zinc-200 px-4 py-12 text-center dark:border-zinc-700">
@ -146,6 +201,9 @@ Include in your layout:
<p class="mt-3 text-sm text-zinc-500 dark:text-zinc-400">
{{ __('hub::hub.search.start_typing') }}
</p>
<p class="mt-1 text-xs text-zinc-400 dark:text-zinc-500">
{{ __('hub::hub.search.tips') }}
</p>
</div>
@endif
</div>

View file

@ -92,6 +92,11 @@
<flux:toast position="bottom end" />
@endpersist
<!-- Global Search (Command+K) -->
@persist('global-search')
<livewire:hub.admin.global-search />
@endpersist
<!-- Developer Bar (Hades accounts only) -->
@include('hub::admin.components.developer-bar')

View file

@ -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);
}
}

View file

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