feat(search): implement global search component with keyboard navigation and recent searches
This commit is contained in:
parent
edb34e38d5
commit
b8531676e2
34 changed files with 3137 additions and 364 deletions
|
|
@ -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.*
|
||||
|
|
|
|||
70
packages/core-admin/changelog/2026/jan/features.md
Normal file
70
packages/core-admin/changelog/2026/jan/features.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
101
packages/core-admin/src/Forms/Concerns/HasAuthorizationProps.php
Normal file
101
packages/core-admin/src/Forms/Concerns/HasAuthorizationProps.php
Normal 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);
|
||||
}
|
||||
}
|
||||
135
packages/core-admin/src/Forms/View/Components/Button.php
Normal file
135
packages/core-admin/src/Forms/View/Components/Button.php
Normal 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');
|
||||
}
|
||||
}
|
||||
89
packages/core-admin/src/Forms/View/Components/Checkbox.php
Normal file
89
packages/core-admin/src/Forms/View/Components/Checkbox.php
Normal 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');
|
||||
}
|
||||
}
|
||||
88
packages/core-admin/src/Forms/View/Components/FormGroup.php
Normal file
88
packages/core-admin/src/Forms/View/Components/FormGroup.php
Normal 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');
|
||||
}
|
||||
}
|
||||
99
packages/core-admin/src/Forms/View/Components/Input.php
Normal file
99
packages/core-admin/src/Forms/View/Components/Input.php
Normal 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');
|
||||
}
|
||||
}
|
||||
146
packages/core-admin/src/Forms/View/Components/Select.php
Normal file
146
packages/core-admin/src/Forms/View/Components/Select.php
Normal 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');
|
||||
}
|
||||
}
|
||||
104
packages/core-admin/src/Forms/View/Components/Textarea.php
Normal file
104
packages/core-admin/src/Forms/View/Components/Textarea.php
Normal 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');
|
||||
}
|
||||
}
|
||||
127
packages/core-admin/src/Forms/View/Components/Toggle.php
Normal file
127
packages/core-admin/src/Forms/View/Components/Toggle.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
120
packages/core-admin/src/Search/Contracts/SearchProvider.php
Normal file
120
packages/core-admin/src/Search/Contracts/SearchProvider.php
Normal 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;
|
||||
}
|
||||
|
|
@ -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'] ?? '#';
|
||||
}
|
||||
}
|
||||
305
packages/core-admin/src/Search/SearchProviderRegistry.php
Normal file
305
packages/core-admin/src/Search/SearchProviderRegistry.php
Normal 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;
|
||||
}
|
||||
}
|
||||
104
packages/core-admin/src/Search/SearchResult.php
Normal file
104
packages/core-admin/src/Search/SearchResult.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
165
packages/core-admin/src/Search/Tests/SearchResultTest.php
Normal file
165
packages/core-admin/src/Search/Tests/SearchResultTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.*'),
|
||||
],
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
|
||||
// ========================================
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue