monorepo sepration

This commit is contained in:
Snider 2026-01-26 20:56:28 +00:00
parent 8ee3a54482
commit 71c0805bfd
113 changed files with 25853 additions and 175 deletions

201
README.md
View file

@ -1,138 +1,113 @@
# Core PHP Framework Project
# Core Admin Package
[![CI](https://github.com/host-uk/core-template/actions/workflows/ci.yml/badge.svg)](https://github.com/host-uk/core-template/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/host-uk/core-template/graph/badge.svg)](https://codecov.io/gh/host-uk/core-template)
[![PHP Version](https://img.shields.io/packagist/php-v/host-uk/core-template)](https://packagist.org/packages/host-uk/core-template)
[![Laravel](https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel)](https://laravel.com)
[![License](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](LICENSE)
A modular monolith Laravel application built with Core PHP Framework.
## Features
- **Core Framework** - Event-driven module system with lazy loading
- **Admin Panel** - Livewire-powered admin interface with Flux UI
- **REST API** - Scoped API keys, rate limiting, webhooks, OpenAPI docs
- **MCP Tools** - Model Context Protocol for AI agent integration
## Requirements
- PHP 8.2+
- Composer 2.x
- SQLite (default) or MySQL/PostgreSQL
- Node.js 18+ (for frontend assets)
Admin panel components, Livewire modals, and service management interface for the Core PHP Framework.
## Installation
```bash
# Clone or create from template
git clone https://github.com/host-uk/core-template.git my-project
cd my-project
# Install dependencies
composer install
npm install
# Configure environment
cp .env.example .env
php artisan key:generate
# Set up database
touch database/database.sqlite
php artisan migrate
# Start development server
php artisan serve
composer require host-uk/core-admin
```
Visit: http://localhost:8000
## Features
## Project Structure
```
app/
├── Console/ # Artisan commands
├── Http/ # Controllers & Middleware
├── Models/ # Eloquent models
├── Mod/ # Your custom modules
└── Providers/ # Service providers
config/
└── core.php # Core framework configuration
routes/
├── web.php # Public web routes
├── api.php # REST API routes
└── console.php # Artisan commands
```
## Creating Modules
```bash
# Create a new module with all features
php artisan make:mod Blog --all
# Create module with specific features
php artisan make:mod Shop --web --api --admin
```
Modules follow the event-driven pattern:
### Admin Menu System
Declarative menu registration with automatic permission checking:
```php
<?php
use Core\Front\Admin\Contracts\AdminMenuProvider;
namespace App\Mod\Blog;
use Core\Events\WebRoutesRegistering;
use Core\Events\ApiRoutesRegistering;
use Core\Events\AdminPanelBooting;
class Boot
class MyModuleMenu implements AdminMenuProvider
{
public static array $listens = [
WebRoutesRegistering::class => 'onWebRoutes',
ApiRoutesRegistering::class => 'onApiRoutes',
AdminPanelBooting::class => 'onAdminPanel',
];
public function onWebRoutes(WebRoutesRegistering $event): void
public function registerMenu(AdminMenuRegistry $registry): void
{
$event->routes(fn() => require __DIR__.'/Routes/web.php');
$event->views('blog', __DIR__.'/Views');
$registry->addItem('products', [
'label' => 'Products',
'icon' => 'cube',
'route' => 'admin.products.index',
'permission' => 'products.view',
]);
}
}
```
## Core Packages
### Livewire Modals
Full-page Livewire components for admin interfaces:
| Package | Description |
|---------|-------------|
| `host-uk/core` | Core framework components |
| `host-uk/core-admin` | Admin panel & Livewire modals |
| `host-uk/core-api` | REST API with scopes & webhooks |
| `host-uk/core-mcp` | Model Context Protocol tools |
```php
use Livewire\Component;
use Livewire\Attributes\Title;
## Flux Pro (Optional)
This template uses the free Flux UI components. If you have a Flux Pro license:
```bash
# Configure authentication
composer config http-basic.composer.fluxui.dev your-email your-license-key
# Add the repository
composer config repositories.flux-pro composer https://composer.fluxui.dev
# Install Flux Pro
composer require livewire/flux-pro
#[Title('Product Manager')]
class ProductManager extends Component
{
public function render(): View
{
return view('admin.products.manager')
->layout('hub::admin.layouts.app');
}
}
```
## Documentation
### Form Components
Reusable form components with authorization:
- [Core PHP Framework](https://github.com/host-uk/core-php)
- [Getting Started Guide](https://host-uk.github.io/core-php/guide/)
- [Architecture](https://host-uk.github.io/core-php/architecture/)
- `<x-forms.input>` - Text inputs with validation
- `<x-forms.select>` - Dropdowns
- `<x-forms.checkbox>` - Checkboxes
- `<x-forms.toggle>` - Toggle switches
- `<x-forms.textarea>` - Text areas
- `<x-forms.button>` - Buttons with loading states
```blade
<x-forms.input
name="name"
label="Product Name"
wire:model="name"
required
/>
```
### Global Search
Extensible search provider system:
```php
use Core\Admin\Search\Contracts\SearchProvider;
class ProductSearchProvider implements SearchProvider
{
public function search(string $query): array
{
return Product::where('name', 'like', "%{$query}%")
->take(5)
->get()
->map(fn($p) => new SearchResult(
title: $p->name,
url: route('admin.products.edit', $p),
icon: 'cube'
))
->toArray();
}
}
```
### Service Management Interface
Unified dashboard for viewing workspace services and statistics.
## Configuration
The package auto-discovers admin menu providers and search providers from your modules.
## Requirements
- PHP 8.2+
- Laravel 11+ or 12+
- Livewire 3.0+
- Flux UI 2.0+
## Changelog
See [changelog/2026/jan/features.md](changelog/2026/jan/features.md) for recent changes.
## License
EUPL-1.2 (European Union Public Licence)
EUPL-1.2 - See [LICENSE](../../LICENSE) for details.

227
TODO.md Normal file
View file

@ -0,0 +1,227 @@
# Core-Admin TODO
## Testing & Quality Assurance
### High Priority
- [ ] **Test Coverage: Search System** - Test global search functionality
- [ ] Test SearchProviderRegistry with multiple providers
- [ ] Test AdminPageSearchProvider query matching
- [ ] Test SearchResult highlighting
- [ ] Test search analytics tracking
- [ ] Test workspace-scoped search results
- **Estimated effort:** 3-4 hours
- [ ] **Test Coverage: Form Components** - Test authorization props
- [ ] Test Button component with :can/:cannot props
- [ ] Test Input component with authorization
- [ ] Test Select/Checkbox/Toggle with permissions
- [ ] Test workspace context in form components
- **Estimated effort:** 2-3 hours
- [ ] **Test Coverage: Livewire Modals** - Test modal system
- [ ] Test modal opening/closing
- [ ] Test file uploads in modals
- [ ] Test validation in modals
- [ ] Test nested modals
- [ ] Test modal events and lifecycle
- **Estimated effort:** 3-4 hours
### Medium Priority
- [ ] **Test Coverage: Admin Menu System** - Test menu building
- [ ] Test AdminMenuRegistry with multiple providers
- [ ] Test MenuItemBuilder with badges
- [ ] Test menu authorization (can/canAny)
- [ ] Test menu active state detection
- [ ] Test IconValidator
- **Estimated effort:** 2-3 hours
- [ ] **Test Coverage: HLCRF Components** - Test layout system
- [ ] Test HierarchicalLayoutBuilder parsing
- [ ] Test nested layout rendering
- [ ] Test self-documenting IDs (H-0, C-R-2, etc.)
- [ ] Test responsive breakpoints
- **Estimated effort:** 4-5 hours
### Low Priority
- [ ] **Test Coverage: Teapot/Honeypot** - Test anti-spam
- [ ] Test TeapotController honeypot detection
- [ ] Test HoneypotHit recording
- [ ] Test automatic IP blocking
- [ ] Test hit pruning
- **Estimated effort:** 2-3 hours
## Features & Enhancements
### High Priority
- [ ] **Feature: Data Tables Component** - Reusable admin tables
- [ ] Create sortable table component
- [ ] Add bulk action support
- [ ] Implement column filtering
- [ ] Add export to CSV/Excel
- [ ] Test with large datasets (1000+ rows)
- **Estimated effort:** 6-8 hours
- **Files:** `src/Admin/Tables/`
- [ ] **Feature: Dashboard Widgets** - Composable dashboard
- [ ] Create widget system with layouts
- [ ] Add drag-and-drop widget arrangement
- [ ] Implement widget state persistence
- [ ] Create common widgets (stats, charts, lists)
- [ ] Test widget refresh and real-time updates
- **Estimated effort:** 8-10 hours
- **Files:** `src/Admin/Dashboard/`
- [ ] **Feature: Notification Center** - In-app notifications
- [ ] Create notification inbox component
- [ ] Add real-time notification delivery
- [ ] Implement notification preferences
- [ ] Add notification grouping
- [ ] Test with high notification volume
- **Estimated effort:** 6-8 hours
- **Files:** `src/Admin/Notifications/`
### Medium Priority
- [ ] **Enhancement: Form Builder** - Dynamic form generation
- [ ] Create form builder UI
- [ ] Support custom field types
- [ ] Add conditional field visibility
- [ ] Implement form templates
- [ ] Test complex multi-step forms
- **Estimated effort:** 8-10 hours
- **Files:** `src/Forms/Builder/`
- [ ] **Enhancement: Activity Feed Component** - Visual activity log
- [ ] Create activity feed Livewire component
- [ ] Add filtering by event type/user/date
- [ ] Implement infinite scroll
- [ ] Add export functionality
- [ ] Test with large activity logs
- **Estimated effort:** 4-5 hours
- **Files:** `src/Activity/Components/`
- [ ] **Enhancement: File Manager** - Media browser
- [ ] Create file browser component
- [ ] Add upload with drag-and-drop
- [ ] Implement folder organization
- [ ] Add image preview and editing
- [ ] Test with S3/CDN integration
- **Estimated effort:** 10-12 hours
- **Files:** `src/Media/Manager/`
### Low Priority
- [ ] **Enhancement: Theme Customizer** - Visual theme editor
- [ ] Create color picker for brand colors
- [ ] Add font selection
- [ ] Implement logo upload
- [ ] Add CSS custom property generation
- [ ] Test theme persistence per workspace
- **Estimated effort:** 6-8 hours
- **Files:** `src/Theming/`
- [ ] **Enhancement: Keyboard Shortcuts** - Power user features
- [ ] Implement global shortcut system
- [ ] Add command palette (Cmd+K)
- [ ] Create shortcut configuration UI
- [ ] Add accessibility support
- **Estimated effort:** 4-5 hours
- **Files:** `src/Shortcuts/`
## Security & Authorization
- [ ] **Audit: Admin Route Security** - Verify all admin routes protected
- [ ] Audit all admin controllers for authorization
- [ ] Ensure #[Action] attributes on sensitive operations
- [ ] Verify middleware chains
- [ ] Test unauthorized access attempts
- **Estimated effort:** 3-4 hours
- [ ] **Enhancement: Action Audit Log** - Track admin actions
- [ ] Log all admin operations
- [ ] Track who/what/when for compliance
- [ ] Add audit log viewer
- [ ] Implement tamper-proof logging
- **Estimated effort:** 4-5 hours
- **Files:** `src/Audit/`
## Documentation
- [x] **Guide: Creating Admin Panels** - Step-by-step guide
- [x] Document menu registration
- [x] Show modal creation examples
- [x] Explain authorization integration
- [x] Add complete example module
- **Completed:** January 2026
- **File:** `docs/packages/admin/creating-admin-panels.md`
- [x] **Guide: HLCRF Deep Dive** - Advanced layout patterns
- [x] Document all layout combinations
- [x] Show responsive design patterns
- [x] Explain ID system in detail
- [x] Add complex real-world examples
- **Completed:** January 2026
- **File:** `docs/packages/admin/hlcrf-deep-dive.md`
- [x] **API Reference: Components** - Component prop documentation
- [x] Document all form component props
- [x] Add prop validation rules
- [x] Show authorization prop examples
- [x] Include accessibility notes
- **Completed:** January 2026
- **File:** `docs/packages/admin/components-reference.md`
## Code Quality
- [ ] **Refactor: Extract Modal Manager** - Separate concerns
- [ ] Extract modal state management
- [ ] Create dedicated ModalManager service
- [ ] Add modal queue support
- [ ] Test modal lifecycle
- **Estimated effort:** 3-4 hours
- [ ] **Refactor: Standardize Component Props** - Consistent API
- [ ] Audit all component props
- [ ] Standardize naming (can/cannot/canAny)
- [ ] Add prop validation
- [ ] Update documentation
- **Estimated effort:** 2-3 hours
- [ ] **PHPStan: Fix Level 5 Errors** - Improve type safety
- [ ] Fix property type declarations
- [ ] Add missing return types
- [ ] Fix array shape types
- **Estimated effort:** 2-3 hours
## Performance
- [ ] **Optimization: Search Indexing** - Faster admin search
- [ ] Profile search performance
- [ ] Add search result caching
- [ ] Implement debounced search
- [ ] Optimize query building
- **Estimated effort:** 2-3 hours
- [ ] **Optimization: Menu Rendering** - Reduce menu overhead
- [ ] Cache menu structure
- [ ] Lazy load menu icons
- [ ] Optimize authorization checks
- **Estimated effort:** 1-2 hours
---
## Completed (January 2026)
- [x] **Forms: Authorization Props** - Added :can/:cannot/:canAny to all form components
- [x] **Search: Provider System** - Global search with multiple providers
- [x] **Search: Analytics** - Track search queries and results
- [x] **Documentation** - Complete admin package documentation
- [x] **Guide: Creating Admin Panels** - Menu registration, modals, authorization, example module
- [x] **Guide: HLCRF Deep Dive** - Layout combinations, ID system, responsive patterns
- [x] **API Reference: Components** - Form component props with authorization examples
*See `changelog/2026/jan/` for completed features.*

View file

@ -0,0 +1,70 @@
# Core-Admin - January 2026
## Features Implemented
### Form Authorization Components
Authorization-aware form components that automatically disable/hide based on permissions.
**Files:**
- `src/Forms/Concerns/HasAuthorizationProps.php` - Authorization trait
- `src/Forms/View/Components/` - Input, Textarea, Select, Checkbox, Button, Toggle, FormGroup
- `resources/views/components/forms/` - Blade templates
**Components:**
- `<x-core-forms.input />` - Text input with label, helper, error
- `<x-core-forms.textarea />` - Textarea with auto-resize
- `<x-core-forms.select />` - Dropdown with grouped options
- `<x-core-forms.checkbox />` - Checkbox with description
- `<x-core-forms.button />` - Button with variants, loading state
- `<x-core-forms.toggle />` - Toggle with instant save
- `<x-core-forms.form-group />` - Wrapper for spacing
**Usage:**
```blade
<x-core-forms.input
id="name"
label="Name"
canGate="update"
:canResource="$model"
wire:model="name"
/>
<x-core-forms.button variant="danger" canGate="delete" :canResource="$model" canHide>
Delete
</x-core-forms.button>
```
---
### Global Search (⌘K)
Unified search across resources with keyboard navigation.
**Files:**
- `src/Search/Contracts/SearchProvider.php` - Provider interface
- `src/Search/SearchProviderRegistry.php` - Registry with fuzzy matching
- `src/Search/SearchResult.php` - Result DTO
- `src/Search/Providers/AdminPageSearchProvider.php` - Built-in provider
- `src/Website/Hub/View/Modal/Admin/GlobalSearch.php` - Livewire component
**Features:**
- ⌘K / Ctrl+K keyboard shortcut
- Arrow key navigation, Enter to select
- Fuzzy matching support
- Recent searches
- Grouped results by provider
**Usage:**
```php
// Register custom provider
app(SearchProviderRegistry::class)->register(new MySearchProvider());
```
---
## Design Decisions
### Soketi (Real-time WebSocket)
Excluded per project decision. Self-hosted Soketi integration not required at this time.

View file

@ -1,76 +1,23 @@
{
"name": "host-uk/core-template",
"type": "project",
"description": "Core PHP Framework - Project Template",
"keywords": ["laravel", "core-php", "modular", "framework", "template"],
"name": "host-uk/core-admin",
"description": "Admin panel module for Core PHP framework",
"keywords": ["laravel", "admin", "panel", "dashboard"],
"license": "EUPL-1.2",
"require": {
"php": "^8.2",
"laravel/framework": "^12.0",
"laravel/tinker": "^2.10",
"livewire/flux": "^2.0",
"livewire/livewire": "^3.0",
"host-uk/core": "dev-main",
"host-uk/core-admin": "dev-main",
"host-uk/core-api": "dev-main",
"host-uk/core-mcp": "dev-main"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/pail": "^1.2",
"laravel/pint": "^1.18",
"laravel/sail": "^1.41",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"pestphp/pest": "^3.0",
"pestphp/pest-plugin-laravel": "^3.0"
"host-uk/core": "@dev"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
"Core\\Admin\\": "src/",
"Website\\Hub\\": "src/Website/Hub/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"repositories": [
{
"type": "vcs",
"url": "https://github.com/host-uk/core-php.git"
}
],
"scripts": {
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"php-http/discovery": true
"providers": [
"Core\\Admin\\Boot"
]
}
},
"minimum-stability": "stable",

View file

@ -0,0 +1,82 @@
{{--
Button Component
A button with authorization support, variants, loading states, and icons.
Props:
- type: string - Button type (button, submit, reset)
- variant: string - Button style: primary, secondary, danger, ghost
- size: string - Button size: sm, md, lg
- icon: string|null - Icon name (left position)
- iconRight: string|null - Icon name (right position)
- loading: bool - Show loading state
- loadingText: string|null - Text to show during loading
- disabled: bool - Whether button is disabled
- canGate: string|null - Gate/ability to check
- canResource: mixed|null - Resource to check against
- canHide: bool - Hide instead of disable when unauthorized
Usage:
<x-core-forms.button variant="primary" icon="check">
Save Changes
</x-core-forms.button>
<x-core-forms.button
variant="danger"
canGate="delete"
:canResource="$model"
canHide
>
Delete
</x-core-forms.button>
{{-- With loading state --}}
<x-core-forms.button
variant="primary"
wire:click="save"
wire:loading.attr="disabled"
loadingText="Saving..."
>
<span wire:loading.remove>Save</span>
<span wire:loading>Saving...</span>
</x-core-forms.button>
--}}
@if(!$hidden)
<button
type="{{ $type }}"
@if($disabled) disabled @endif
{{ $attributes->class([
'inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-900',
'disabled:cursor-not-allowed disabled:opacity-60',
$variantClasses,
$sizeClasses,
]) }}
>
{{-- Loading spinner (wire:loading compatible) --}}
@if($loading)
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
@endif
{{-- Left icon --}}
@if($icon && !$loading)
<flux:icon :name="$icon" class="w-4 h-4" />
@endif
{{-- Button content --}}
@if($loading && $loadingText)
{{ $loadingText }}
@else
{{ $slot }}
@endif
{{-- Right icon --}}
@if($iconRight)
<flux:icon :name="$iconRight" class="w-4 h-4" />
@endif
</button>
@endif

View file

@ -0,0 +1,88 @@
{{--
Checkbox Component
A checkbox with authorization support, label positioning, and description.
Props:
- id: string (required) - Checkbox element ID
- label: string|null - Label text
- description: string|null - Description text below label
- error: string|null - Error message
- labelPosition: string - Label position: 'left' or 'right' (default: 'right')
- disabled: bool - Whether checkbox is disabled
- canGate: string|null - Gate/ability to check
- canResource: mixed|null - Resource to check against
- canHide: bool - Hide instead of disable when unauthorized
Usage:
<x-core-forms.checkbox
id="is_active"
label="Active"
description="Enable this feature for users"
canGate="update"
:canResource="$model"
wire:model="is_active"
/>
{{-- Label on left --}}
<x-core-forms.checkbox
id="remember"
label="Remember me"
labelPosition="left"
wire:model="remember"
/>
--}}
@if(!$hidden)
<div {{ $attributes->only(['class', 'x-show', 'x-if', 'x-cloak'])->merge(['class' => 'space-y-1']) }}>
<div @class([
'flex items-start gap-3',
'flex-row-reverse justify-end' => $labelPosition === 'left',
])>
{{-- Checkbox --}}
<div class="flex items-center h-5">
<input
type="checkbox"
id="{{ $id }}"
name="{{ $id }}"
@if($disabled) disabled @endif
{{ $attributes->except(['class', 'x-show', 'x-if', 'x-cloak'])->class([
'h-4 w-4 rounded transition-colors duration-200',
'border-gray-300 dark:border-gray-600',
'text-violet-600 dark:text-violet-500',
'focus:ring-2 focus:ring-violet-500/20 focus:ring-offset-0',
'bg-white dark:bg-gray-800',
// Disabled state
'bg-gray-100 dark:bg-gray-900 cursor-not-allowed' => $disabled,
]) }}
/>
</div>
{{-- Label and description --}}
@if($label || $description)
<div class="text-sm">
@if($label)
<label for="{{ $id }}" @class([
'font-medium',
'text-gray-700 dark:text-gray-300' => !$disabled,
'text-gray-500 dark:text-gray-500 cursor-not-allowed' => $disabled,
])>
{{ $label }}
</label>
@endif
@if($description)
<p class="text-gray-500 dark:text-gray-400">{{ $description }}</p>
@endif
</div>
@endif
</div>
{{-- Error message --}}
@if($error)
<p class="text-sm text-red-600 dark:text-red-400">{{ $error }}</p>
@elseif($errors->has($id))
<p class="text-sm text-red-600 dark:text-red-400">{{ $errors->first($id) }}</p>
@endif
</div>
@endif

View file

@ -0,0 +1,50 @@
{{--
Form Group Component
A wrapper component for consistent form field spacing and error display.
Props:
- label: string|null - Label text
- for: string|null - ID of the form element (for label)
- error: string|null - Error bag key to check
- helper: string|null - Helper text
- required: bool - Show required indicator
Usage:
<x-core-forms.form-group label="Email" for="email" error="email" required>
<input type="email" id="email" wire:model="email" />
</x-core-forms.form-group>
{{-- Without label --}}
<x-core-forms.form-group error="terms">
<x-core-forms.checkbox id="terms" label="I agree to the terms" />
</x-core-forms.form-group>
--}}
<div {{ $attributes->merge(['class' => 'space-y-1']) }}>
{{-- Label --}}
@if($label)
<label
@if($for) for="{{ $for }}" @endif
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ $label }}
@if($required)
<span class="text-red-500">*</span>
@endif
</label>
@endif
{{-- Content slot --}}
{{ $slot }}
{{-- Helper text --}}
@if($helper && !$hasError())
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $helper }}</p>
@endif
{{-- Error message --}}
@if($hasError())
<p class="text-sm text-red-600 dark:text-red-400">{{ $errorMessage }}</p>
@endif
</div>

View file

@ -0,0 +1,77 @@
{{--
Input Component
A text input with authorization support, labels, helper text, and error display.
Props:
- id: string (required) - Input element ID
- label: string|null - Label text
- helper: string|null - Helper text below input
- error: string|null - Error message (auto-resolved from validation bag if not provided)
- type: string - Input type (text, email, password, etc.)
- placeholder: string|null - Placeholder text
- disabled: bool - Whether input is disabled
- required: bool - Whether input is required
- canGate: string|null - Gate/ability to check
- canResource: mixed|null - Resource to check against
- canHide: bool - Hide instead of disable when unauthorized
Usage:
<x-core-forms.input
id="name"
label="Display Name"
helper="Enter a memorable name"
canGate="update"
:canResource="$model"
wire:model="name"
/>
--}}
@if(!$hidden)
<div {{ $attributes->only(['class', 'x-show', 'x-if', 'x-cloak'])->merge(['class' => 'space-y-1']) }}>
{{-- Label --}}
@if($label)
<label for="{{ $id }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ $label }}
@if($required)
<span class="text-red-500">*</span>
@endif
</label>
@endif
{{-- Input --}}
<input
type="{{ $type }}"
id="{{ $id }}"
name="{{ $id }}"
@if($placeholder) placeholder="{{ $placeholder }}" @endif
@if($disabled) disabled @endif
@if($required) required @endif
{{ $attributes->except(['class', 'x-show', 'x-if', 'x-cloak'])->class([
'block w-full rounded-lg border px-3 py-2 text-sm transition-colors duration-200',
'bg-white dark:bg-gray-800',
'text-gray-900 dark:text-gray-100',
'placeholder-gray-400 dark:placeholder-gray-500',
'focus:outline-none focus:ring-2 focus:ring-offset-0',
// Normal state
'border-gray-300 dark:border-gray-600 focus:border-violet-500 focus:ring-violet-500/20' => !$error,
// Error state
'border-red-500 dark:border-red-500 focus:border-red-500 focus:ring-red-500/20' => $error,
// Disabled state
'bg-gray-50 dark:bg-gray-900 text-gray-500 dark:text-gray-400 cursor-not-allowed' => $disabled,
]) }}
/>
{{-- Helper text --}}
@if($helper && !$error)
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $helper }}</p>
@endif
{{-- Error message --}}
@if($error)
<p class="text-sm text-red-600 dark:text-red-400">{{ $error }}</p>
@elseif($errors->has($id))
<p class="text-sm text-red-600 dark:text-red-400">{{ $errors->first($id) }}</p>
@endif
</div>
@endif

View file

@ -0,0 +1,108 @@
{{--
Select Component
A dropdown select with authorization support, options, and error display.
Props:
- id: string (required) - Select element ID
- options: array - Options as value => label or grouped options
- label: string|null - Label text
- helper: string|null - Helper text below select
- error: string|null - Error message
- placeholder: string|null - Placeholder option text
- multiple: bool - Allow multiple selection
- disabled: bool - Whether select is disabled
- required: bool - Whether select is required
- canGate: string|null - Gate/ability to check
- canResource: mixed|null - Resource to check against
- canHide: bool - Hide instead of disable when unauthorized
Usage:
<x-core-forms.select
id="status"
label="Status"
:options="['draft' => 'Draft', 'published' => 'Published']"
placeholder="Select a status..."
canGate="update"
:canResource="$model"
wire:model="status"
/>
{{-- With grouped options --}}
<x-core-forms.select
id="timezone"
:options="[
'America' => ['America/New_York' => 'New York', 'America/Los_Angeles' => 'Los Angeles'],
'Europe' => ['Europe/London' => 'London', 'Europe/Paris' => 'Paris'],
]"
/>
--}}
@if(!$hidden)
<div {{ $attributes->only(['class', 'x-show', 'x-if', 'x-cloak'])->merge(['class' => 'space-y-1']) }}>
{{-- Label --}}
@if($label)
<label for="{{ $id }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ $label }}
@if($required)
<span class="text-red-500">*</span>
@endif
</label>
@endif
{{-- Select --}}
<select
id="{{ $id }}"
name="{{ $id }}"
@if($multiple) multiple @endif
@if($disabled) disabled @endif
@if($required) required @endif
{{ $attributes->except(['class', 'x-show', 'x-if', 'x-cloak'])->class([
'block w-full rounded-lg border px-3 py-2 text-sm transition-colors duration-200',
'bg-white dark:bg-gray-800',
'text-gray-900 dark:text-gray-100',
'focus:outline-none focus:ring-2 focus:ring-offset-0',
// Normal state
'border-gray-300 dark:border-gray-600 focus:border-violet-500 focus:ring-violet-500/20' => !$error,
// Error state
'border-red-500 dark:border-red-500 focus:border-red-500 focus:ring-red-500/20' => $error,
// Disabled state
'bg-gray-50 dark:bg-gray-900 text-gray-500 dark:text-gray-400 cursor-not-allowed' => $disabled,
]) }}
>
{{-- Placeholder option --}}
@if($placeholder)
<option value="" disabled selected>{{ $placeholder }}</option>
@endif
{{-- Options --}}
@foreach($normalizedOptions as $value => $labelOrGroup)
@if(is_array($labelOrGroup))
{{-- Optgroup --}}
<optgroup label="{{ $value }}">
@foreach($labelOrGroup as $optValue => $optLabel)
<option value="{{ $optValue }}">{{ $optLabel }}</option>
@endforeach
</optgroup>
@else
<option value="{{ $value }}">{{ $labelOrGroup }}</option>
@endif
@endforeach
{{-- Slot for custom options --}}
{{ $slot }}
</select>
{{-- Helper text --}}
@if($helper && !$error)
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $helper }}</p>
@endif
{{-- Error message --}}
@if($error)
<p class="text-sm text-red-600 dark:text-red-400">{{ $error }}</p>
@elseif($errors->has($id))
<p class="text-sm text-red-600 dark:text-red-400">{{ $errors->first($id) }}</p>
@endif
</div>
@endif

View file

@ -0,0 +1,87 @@
{{--
Textarea Component
A textarea with authorization support, auto-resize, labels, and error display.
Props:
- id: string (required) - Textarea element ID
- label: string|null - Label text
- helper: string|null - Helper text below textarea
- error: string|null - Error message
- placeholder: string|null - Placeholder text
- rows: int - Number of visible rows (default: 3)
- autoResize: bool - Enable auto-resize via Alpine.js
- disabled: bool - Whether textarea is disabled
- required: bool - Whether textarea is required
- canGate: string|null - Gate/ability to check
- canResource: mixed|null - Resource to check against
- canHide: bool - Hide instead of disable when unauthorized
Usage:
<x-core-forms.textarea
id="description"
label="Description"
rows="4"
autoResize
canGate="update"
:canResource="$model"
wire:model="description"
/>
--}}
@if(!$hidden)
<div {{ $attributes->only(['class', 'x-show', 'x-if', 'x-cloak'])->merge(['class' => 'space-y-1']) }}>
{{-- Label --}}
@if($label)
<label for="{{ $id }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ $label }}
@if($required)
<span class="text-red-500">*</span>
@endif
</label>
@endif
{{-- Textarea --}}
<textarea
id="{{ $id }}"
name="{{ $id }}"
rows="{{ $rows }}"
@if($placeholder) placeholder="{{ $placeholder }}" @endif
@if($disabled) disabled @endif
@if($required) required @endif
@if($autoResize)
x-data="{ resize: () => { $el.style.height = 'auto'; $el.style.height = $el.scrollHeight + 'px' } }"
x-init="resize()"
x-on:input="resize()"
style="overflow: hidden;"
@endif
{{ $attributes->except(['class', 'x-show', 'x-if', 'x-cloak'])->class([
'block w-full rounded-lg border px-3 py-2 text-sm transition-colors duration-200',
'bg-white dark:bg-gray-800',
'text-gray-900 dark:text-gray-100',
'placeholder-gray-400 dark:placeholder-gray-500',
'focus:outline-none focus:ring-2 focus:ring-offset-0',
'resize-y' => !$autoResize,
'resize-none' => $autoResize,
// Normal state
'border-gray-300 dark:border-gray-600 focus:border-violet-500 focus:ring-violet-500/20' => !$error,
// Error state
'border-red-500 dark:border-red-500 focus:border-red-500 focus:ring-red-500/20' => $error,
// Disabled state
'bg-gray-50 dark:bg-gray-900 text-gray-500 dark:text-gray-400 cursor-not-allowed' => $disabled,
]) }}
>{{ $slot }}</textarea>
{{-- Helper text --}}
@if($helper && !$error)
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $helper }}</p>
@endif
{{-- Error message --}}
@if($error)
<p class="text-sm text-red-600 dark:text-red-400">{{ $error }}</p>
@elseif($errors->has($id))
<p class="text-sm text-red-600 dark:text-red-400">{{ $errors->first($id) }}</p>
@endif
</div>
@endif

View file

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

88
src/Boot.php Normal file
View file

@ -0,0 +1,88 @@
<?php
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 and initializes
* admin-specific services like the search provider registry.
*/
class Boot extends ServiceProvider
{
public function register(): void
{
// Register our Website modules with the scanner
app(ModuleRegistry::class)->addPaths([
__DIR__.'/Website',
]);
// Register the search provider registry as a singleton
$this->app->singleton(SearchProviderRegistry::class);
}
public function boot(): void
{
// Load Hub translations
$this->loadTranslationsFrom(__DIR__.'/Mod/Hub/Lang', 'hub');
// Register form components
$this->registerFormComponents();
// Register the default search providers
$this->registerSearchProviders();
}
/**
* Register form components with authorization support.
*
* Components are registered with the 'core-forms' prefix:
* - <x-core-forms.input />
* - <x-core-forms.textarea />
* - <x-core-forms.select />
* - <x-core-forms.checkbox />
* - <x-core-forms.button />
* - <x-core-forms.toggle />
* - <x-core-forms.form-group />
*/
protected function registerFormComponents(): void
{
// Register views namespace for form component templates
$this->loadViewsFrom(dirname(__DIR__).'/resources/views', 'core-forms');
// Register class-backed form components
Blade::component('core-forms.input', Input::class);
Blade::component('core-forms.textarea', Textarea::class);
Blade::component('core-forms.select', Select::class);
Blade::component('core-forms.checkbox', Checkbox::class);
Blade::component('core-forms.button', Button::class);
Blade::component('core-forms.toggle', Toggle::class);
Blade::component('core-forms.form-group', FormGroup::class);
}
/**
* Register the default search providers.
*/
protected function registerSearchProviders(): void
{
$registry = $this->app->make(SearchProviderRegistry::class);
// Register the built-in admin page search provider
$registry->register($this->app->make(AdminPageSearchProvider::class));
}
}

View file

@ -0,0 +1,101 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Admin\Forms\Concerns;
/**
* Provides authorization-aware props for form components.
*
* Components using this trait can accept `canGate` and `canResource` props
* to automatically disable or hide based on user permissions.
*
* Usage:
* ```blade
* <x-core-forms.input canGate="update" :canResource="$biolink" id="name" />
* <x-core-forms.button canGate="delete" :canResource="$biolink" canHide>Delete</x-core-forms.button>
* ```
*/
trait HasAuthorizationProps
{
/**
* The gate/ability to check (e.g., 'update', 'delete').
*/
public ?string $canGate = null;
/**
* The resource/model to check the gate against.
*/
public mixed $canResource = null;
/**
* Whether to hide the component (instead of disabling) when unauthorized.
*/
public bool $canHide = false;
/**
* Resolve whether the component should be disabled based on authorization.
*
* If `canGate` and `canResource` are both provided and the user lacks
* the required permission, the component will be disabled.
*
* @param bool $explicitlyDisabled Whether the component was explicitly disabled via props
*/
protected function resolveDisabledState(bool $explicitlyDisabled = false): bool
{
// Already explicitly disabled - no need to check authorization
if ($explicitlyDisabled) {
return true;
}
// No authorization check configured
if (! $this->canGate || $this->canResource === null) {
return false;
}
// Check if user can perform the action
return ! $this->userCan();
}
/**
* Resolve whether the component should be hidden based on authorization.
*
* Only hides if `canHide` is true and the user lacks permission.
*/
protected function resolveHiddenState(): bool
{
// Not configured to hide on unauthorized
if (! $this->canHide) {
return false;
}
// No authorization check configured
if (! $this->canGate || $this->canResource === null) {
return false;
}
// Hide if user cannot perform the action
return ! $this->userCan();
}
/**
* Check if the current user can perform the gate action on the resource.
*/
protected function userCan(): bool
{
$user = auth()->user();
if (! $user) {
return false;
}
return $user->can($this->canGate, $this->canResource);
}
}

View file

@ -0,0 +1,135 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Admin\Forms\View\Components;
use Core\Admin\Forms\Concerns\HasAuthorizationProps;
use Illuminate\View\Component;
/**
* Button component with authorization support.
*
* Features:
* - Authorization via `canGate` / `canResource` props (disables or hides)
* - Variants: primary, secondary, danger, ghost
* - Loading state support (with wire:loading integration)
* - Icon support (left and right positions)
* - Size variants: sm, md, lg
* - Dark mode support
*
* Usage:
* ```blade
* <x-core-forms.button
* variant="primary"
* icon="check"
* canGate="update"
* :canResource="$model"
* >
* Save Changes
* </x-core-forms.button>
*
* <x-core-forms.button
* variant="danger"
* canGate="delete"
* :canResource="$model"
* canHide
* >
* Delete
* </x-core-forms.button>
* ```
*/
class Button extends Component
{
use HasAuthorizationProps;
public string $type;
public string $variant;
public string $size;
public ?string $icon;
public ?string $iconRight;
public bool $loading;
public ?string $loadingText;
public bool $disabled;
public bool $hidden;
public string $variantClasses;
public string $sizeClasses;
public function __construct(
string $type = 'button',
string $variant = 'primary',
string $size = 'md',
?string $icon = null,
?string $iconRight = null,
bool $loading = false,
?string $loadingText = null,
bool $disabled = false,
// Authorization props
?string $canGate = null,
mixed $canResource = null,
bool $canHide = false,
) {
$this->type = $type;
$this->variant = $variant;
$this->size = $size;
$this->icon = $icon;
$this->iconRight = $iconRight;
$this->loading = $loading;
$this->loadingText = $loadingText;
// Authorization setup
$this->canGate = $canGate;
$this->canResource = $canResource;
$this->canHide = $canHide;
// Resolve states based on authorization
$this->disabled = $this->resolveDisabledState($disabled);
$this->hidden = $this->resolveHiddenState();
// Resolve variant and size classes
$this->variantClasses = $this->resolveVariantClasses();
$this->sizeClasses = $this->resolveSizeClasses();
}
protected function resolveVariantClasses(): string
{
return match ($this->variant) {
'primary' => 'bg-violet-600 hover:bg-violet-700 text-white focus:ring-violet-500 disabled:bg-violet-400',
'secondary' => 'bg-gray-100 hover:bg-gray-200 text-gray-700 dark:bg-gray-700 dark:hover:bg-gray-600 dark:text-gray-200 focus:ring-gray-500 disabled:bg-gray-100 disabled:dark:bg-gray-800',
'danger' => 'bg-red-600 hover:bg-red-700 text-white focus:ring-red-500 disabled:bg-red-400',
'ghost' => 'bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300 focus:ring-gray-500',
default => 'bg-violet-600 hover:bg-violet-700 text-white focus:ring-violet-500 disabled:bg-violet-400',
};
}
protected function resolveSizeClasses(): string
{
return match ($this->size) {
'sm' => 'px-3 py-1.5 text-sm',
'lg' => 'px-6 py-3 text-base',
default => 'px-4 py-2 text-sm',
};
}
public function render()
{
return view('core-forms::components.forms.button');
}
}

View file

@ -0,0 +1,89 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Admin\Forms\View\Components;
use Core\Admin\Forms\Concerns\HasAuthorizationProps;
use Illuminate\View\Component;
/**
* Checkbox component with authorization support.
*
* Features:
* - Authorization via `canGate` / `canResource` props
* - Label positioning (left/right)
* - Description text
* - Error display from validation
* - Dark mode support
*
* Usage:
* ```blade
* <x-core-forms.checkbox
* id="is_active"
* label="Active"
* description="Enable this feature for users"
* canGate="update"
* :canResource="$model"
* wire:model="is_active"
* />
* ```
*/
class Checkbox extends Component
{
use HasAuthorizationProps;
public string $id;
public ?string $label;
public ?string $description;
public ?string $error;
public string $labelPosition;
public bool $disabled;
public bool $hidden;
public function __construct(
string $id,
?string $label = null,
?string $description = null,
?string $error = null,
string $labelPosition = 'right',
bool $disabled = false,
// Authorization props
?string $canGate = null,
mixed $canResource = null,
bool $canHide = false,
) {
$this->id = $id;
$this->label = $label;
$this->description = $description;
$this->error = $error;
$this->labelPosition = $labelPosition;
// Authorization setup
$this->canGate = $canGate;
$this->canResource = $canResource;
$this->canHide = $canHide;
// Resolve states based on authorization
$this->disabled = $this->resolveDisabledState($disabled);
$this->hidden = $this->resolveHiddenState();
}
public function render()
{
return view('core-forms::components.forms.checkbox');
}
}

View file

@ -0,0 +1,88 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Admin\Forms\View\Components;
use Illuminate\View\Component;
/**
* Form group wrapper component for consistent spacing and error display.
*
* Features:
* - Consistent spacing between form elements
* - Error display from validation bag
* - Label support
* - Helper text support
* - Optional required indicator
*
* Usage:
* ```blade
* <x-core-forms.form-group label="Email" for="email" error="email" required>
* <input type="email" id="email" wire:model="email" />
* </x-core-forms.form-group>
* ```
*/
class FormGroup extends Component
{
public ?string $label;
public ?string $for;
public ?string $error;
public ?string $helper;
public bool $required;
public string $errorMessage;
public function __construct(
?string $label = null,
?string $for = null,
?string $error = null,
?string $helper = null,
bool $required = false,
) {
$this->label = $label;
$this->for = $for;
$this->error = $error;
$this->helper = $helper;
$this->required = $required;
// Resolve error message from validation bag
$this->errorMessage = $this->resolveError();
}
protected function resolveError(): string
{
if (! $this->error) {
return '';
}
$errors = session('errors');
if (! $errors) {
return '';
}
return $errors->first($this->error) ?? '';
}
public function hasError(): bool
{
return ! empty($this->errorMessage);
}
public function render()
{
return view('core-forms::components.forms.form-group');
}
}

View file

@ -0,0 +1,99 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Admin\Forms\View\Components;
use Core\Admin\Forms\Concerns\HasAuthorizationProps;
use Illuminate\View\Component;
/**
* Text input component with authorization support.
*
* Features:
* - Authorization via `canGate` / `canResource` props
* - Label with automatic `for` attribute
* - Helper text support
* - Error display from validation
* - Dark mode support
* - Disabled state styling
* - Livewire and Alpine.js compatible
*
* Usage:
* ```blade
* <x-core-forms.input
* id="name"
* label="Display Name"
* helper="Enter a memorable display name"
* canGate="update"
* :canResource="$model"
* wire:model="name"
* />
* ```
*/
class Input extends Component
{
use HasAuthorizationProps;
public string $id;
public ?string $label;
public ?string $helper;
public ?string $error;
public string $type;
public ?string $placeholder;
public bool $disabled;
public bool $hidden;
public bool $required;
public function __construct(
string $id,
?string $label = null,
?string $helper = null,
?string $error = null,
string $type = 'text',
?string $placeholder = null,
bool $disabled = false,
bool $required = false,
// Authorization props
?string $canGate = null,
mixed $canResource = null,
bool $canHide = false,
) {
$this->id = $id;
$this->label = $label;
$this->helper = $helper;
$this->error = $error;
$this->type = $type;
$this->placeholder = $placeholder;
$this->required = $required;
// Authorization setup
$this->canGate = $canGate;
$this->canResource = $canResource;
$this->canHide = $canHide;
// Resolve states based on authorization
$this->disabled = $this->resolveDisabledState($disabled);
$this->hidden = $this->resolveHiddenState();
}
public function render()
{
return view('core-forms::components.forms.input');
}
}

View file

@ -0,0 +1,146 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Admin\Forms\View\Components;
use Core\Admin\Forms\Concerns\HasAuthorizationProps;
use Illuminate\View\Component;
/**
* Select dropdown component with authorization support.
*
* Features:
* - Authorization via `canGate` / `canResource` props
* - Options array support (value => label or flat array)
* - Placeholder option
* - Multiple selection support
* - Label with automatic `for` attribute
* - Helper text support
* - Error display from validation
* - Dark mode support
*
* Usage:
* ```blade
* <x-core-forms.select
* id="status"
* label="Status"
* :options="['draft' => 'Draft', 'published' => 'Published']"
* placeholder="Select a status..."
* canGate="update"
* :canResource="$model"
* wire:model="status"
* />
* ```
*/
class Select extends Component
{
use HasAuthorizationProps;
public string $id;
public ?string $label;
public ?string $helper;
public ?string $error;
public ?string $placeholder;
public array $options;
public array $normalizedOptions;
public bool $multiple;
public bool $disabled;
public bool $hidden;
public bool $required;
public function __construct(
string $id,
array $options = [],
?string $label = null,
?string $helper = null,
?string $error = null,
?string $placeholder = null,
bool $multiple = false,
bool $disabled = false,
bool $required = false,
// Authorization props
?string $canGate = null,
mixed $canResource = null,
bool $canHide = false,
) {
$this->id = $id;
$this->label = $label;
$this->helper = $helper;
$this->error = $error;
$this->placeholder = $placeholder;
$this->options = $options;
$this->multiple = $multiple;
$this->required = $required;
// Normalize options to value => label format
$this->normalizedOptions = $this->normalizeOptions($options);
// Authorization setup
$this->canGate = $canGate;
$this->canResource = $canResource;
$this->canHide = $canHide;
// Resolve states based on authorization
$this->disabled = $this->resolveDisabledState($disabled);
$this->hidden = $this->resolveHiddenState();
}
/**
* Normalize options to ensure consistent value => label format.
*/
protected function normalizeOptions(array $options): array
{
$normalized = [];
foreach ($options as $key => $value) {
// Handle grouped options (optgroup)
if (is_array($value) && ! isset($value['label'])) {
$normalized[$key] = $this->normalizeOptions($value);
continue;
}
// Handle array format: ['label' => 'Display', 'value' => 'actual']
if (is_array($value) && isset($value['label'])) {
$normalized[$value['value'] ?? $key] = $value['label'];
continue;
}
// Handle flat array: ['option1', 'option2']
if (is_int($key)) {
$normalized[$value] = $value;
continue;
}
// Handle associative array: ['value' => 'Label']
$normalized[$key] = $value;
}
return $normalized;
}
public function render()
{
return view('core-forms::components.forms.select');
}
}

View file

@ -0,0 +1,104 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Admin\Forms\View\Components;
use Core\Admin\Forms\Concerns\HasAuthorizationProps;
use Illuminate\View\Component;
/**
* Textarea component with authorization support.
*
* Features:
* - Authorization via `canGate` / `canResource` props
* - Configurable rows
* - Auto-resize option (via Alpine.js)
* - Label with automatic `for` attribute
* - Helper text support
* - Error display from validation
* - Dark mode support
*
* Usage:
* ```blade
* <x-core-forms.textarea
* id="description"
* label="Description"
* rows="4"
* autoResize
* canGate="update"
* :canResource="$model"
* wire:model="description"
* />
* ```
*/
class Textarea extends Component
{
use HasAuthorizationProps;
public string $id;
public ?string $label;
public ?string $helper;
public ?string $error;
public ?string $placeholder;
public int $rows;
public bool $autoResize;
public bool $disabled;
public bool $hidden;
public bool $required;
public function __construct(
string $id,
?string $label = null,
?string $helper = null,
?string $error = null,
?string $placeholder = null,
int $rows = 3,
bool $autoResize = false,
bool $disabled = false,
bool $required = false,
// Authorization props
?string $canGate = null,
mixed $canResource = null,
bool $canHide = false,
) {
$this->id = $id;
$this->label = $label;
$this->helper = $helper;
$this->error = $error;
$this->placeholder = $placeholder;
$this->rows = $rows;
$this->autoResize = $autoResize;
$this->required = $required;
// Authorization setup
$this->canGate = $canGate;
$this->canResource = $canResource;
$this->canHide = $canHide;
// Resolve states based on authorization
$this->disabled = $this->resolveDisabledState($disabled);
$this->hidden = $this->resolveHiddenState();
}
public function render()
{
return view('core-forms::components.forms.textarea');
}
}

View file

@ -0,0 +1,127 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Admin\Forms\View\Components;
use Core\Admin\Forms\Concerns\HasAuthorizationProps;
use Illuminate\View\Component;
/**
* Toggle switch component with authorization support.
*
* Features:
* - Authorization via `canGate` / `canResource` props
* - `instantSave` for Livewire real-time persistence
* - Label and description
* - Size variants: sm, md, lg
* - Dark mode support
*
* Usage:
* ```blade
* <x-core-forms.toggle
* id="is_public"
* label="Public"
* description="Make this visible to everyone"
* instantSave
* canGate="update"
* :canResource="$model"
* wire:model="is_public"
* />
* ```
*/
class Toggle extends Component
{
use HasAuthorizationProps;
public string $id;
public ?string $label;
public ?string $description;
public ?string $error;
public string $size;
public bool $instantSave;
public ?string $instantSaveMethod;
public bool $disabled;
public bool $hidden;
public string $trackClasses;
public string $thumbClasses;
public function __construct(
string $id,
?string $label = null,
?string $description = null,
?string $error = null,
string $size = 'md',
bool $instantSave = false,
?string $instantSaveMethod = null,
bool $disabled = false,
// Authorization props
?string $canGate = null,
mixed $canResource = null,
bool $canHide = false,
) {
$this->id = $id;
$this->label = $label;
$this->description = $description;
$this->error = $error;
$this->size = $size;
$this->instantSave = $instantSave;
$this->instantSaveMethod = $instantSaveMethod;
// Authorization setup
$this->canGate = $canGate;
$this->canResource = $canResource;
$this->canHide = $canHide;
// Resolve states based on authorization
$this->disabled = $this->resolveDisabledState($disabled);
$this->hidden = $this->resolveHiddenState();
// Resolve size classes
[$this->trackClasses, $this->thumbClasses] = $this->resolveSizeClasses();
}
protected function resolveSizeClasses(): array
{
return match ($this->size) {
'sm' => ['w-8 h-4', 'w-3 h-3'],
'lg' => ['w-14 h-7', 'w-6 h-6'],
default => ['w-11 h-6', 'w-5 h-5'],
};
}
/**
* Get the wire:change directive for instant save.
*/
public function wireChange(): ?string
{
if (! $this->instantSave) {
return null;
}
// Default to 'save' method if not specified
return $this->instantSaveMethod ?? 'save';
}
public function render()
{
return view('core-forms::components.forms.toggle');
}
}

268
src/Mod/Hub/Boot.php Normal file
View file

@ -0,0 +1,268 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Hub;
use Core\Events\AdminPanelBooting;
use Core\Front\Admin\AdminMenuRegistry;
use Core\Front\Admin\Concerns\HasMenuPermissions;
use Core\Front\Admin\Contracts\AdminMenuProvider;
use Illuminate\Support\ServiceProvider;
use Core\Mod\Tenant\Services\WorkspaceService;
class Boot extends ServiceProvider implements AdminMenuProvider
{
use HasMenuPermissions;
protected string $moduleName = 'hub';
/**
* Events this module listens to for lazy loading.
*
* @var array<class-string, string>
*/
public static array $listens = [
AdminPanelBooting::class => 'onAdminPanel',
];
public function boot(): void
{
$this->loadMigrationsFrom(__DIR__.'/Migrations');
$this->loadTranslationsFrom(__DIR__.'/Lang', 'hub');
app(AdminMenuRegistry::class)->register($this);
}
/**
* Admin menu items for Hub (platform base items).
*/
public function adminMenuItems(): array
{
return [
// Dashboard
[
'group' => 'dashboard',
'priority' => 0,
'item' => fn () => [
'label' => 'Dashboard',
'href' => route('hub.dashboard'),
'icon' => 'gauge',
'color' => 'indigo',
'active' => request()->routeIs('hub.dashboard'),
],
],
// Workspaces - Overview
[
'group' => 'workspaces',
'priority' => 10,
'item' => fn () => [
'label' => 'Overview',
'href' => route('hub.sites'),
'icon' => 'layer-group',
'color' => 'blue',
'active' => request()->routeIs('hub.sites') || request()->routeIs('hub.sites.settings'),
],
],
// Workspaces - Content
[
'group' => 'workspaces',
'priority' => 20,
'item' => fn () => [
'label' => 'Content',
'href' => route('hub.content-manager', ['workspace' => app(WorkspaceService::class)->currentSlug()]),
'icon' => 'file-lines',
'color' => 'emerald',
'active' => request()->routeIs('hub.content-manager') || request()->routeIs('hub.content-editor*'),
],
],
// Workspaces - Configuration
[
'group' => 'workspaces',
'priority' => 30,
'item' => fn () => [
'label' => 'Configuration',
'href' => '/hub/config',
'icon' => 'sliders',
'color' => 'slate',
'active' => request()->is('hub/config*'),
],
],
// Account - Profile
[
'group' => 'settings',
'priority' => 10,
'item' => fn () => [
'label' => 'Profile',
'href' => route('hub.account'),
'icon' => 'user',
'color' => 'sky',
'active' => request()->routeIs('hub.account') && ! request()->routeIs('hub.account.*'),
],
],
// Account - Settings
[
'group' => 'settings',
'priority' => 20,
'item' => fn () => [
'label' => 'Settings',
'href' => route('hub.account.settings'),
'icon' => 'gear',
'color' => 'zinc',
'active' => request()->routeIs('hub.account.settings*'),
],
],
// Account - Usage (consolidated: usage overview, boosts, AI services)
[
'group' => 'settings',
'priority' => 30,
'item' => fn () => [
'label' => 'Usage',
'href' => route('hub.account.usage'),
'icon' => 'chart-pie',
'color' => 'amber',
'active' => request()->routeIs('hub.account.usage'),
],
],
// Admin - Platform
[
'group' => 'admin',
'priority' => 10,
'admin' => true,
'item' => fn () => [
'label' => 'Platform',
'href' => route('hub.platform'),
'icon' => 'crown',
'color' => 'amber',
'active' => request()->routeIs('hub.platform*'),
],
],
// Admin - Entitlements
[
'group' => 'admin',
'priority' => 11,
'admin' => true,
'item' => fn () => [
'label' => 'Entitlements',
'href' => route('hub.entitlements'),
'icon' => 'key',
'color' => 'violet',
'active' => request()->routeIs('hub.entitlements*'),
],
],
// Admin - Services
[
'group' => 'admin',
'priority' => 13,
'admin' => true,
'item' => fn () => [
'label' => 'Services',
'href' => route('hub.admin.services'),
'icon' => 'cubes',
'color' => 'indigo',
'active' => request()->routeIs('hub.admin.services'),
],
],
// Admin - Infrastructure
[
'group' => 'admin',
'priority' => 60,
'admin' => true,
'item' => fn () => [
'label' => 'Infrastructure',
'icon' => 'server',
'color' => 'slate',
'active' => request()->routeIs('hub.console*') || request()->routeIs('hub.databases*') || request()->routeIs('hub.deployments*') || request()->routeIs('hub.honeypot'),
'children' => [
['label' => 'Console', 'icon' => 'terminal', 'href' => route('hub.console'), 'active' => request()->routeIs('hub.console*')],
['label' => 'Databases', 'icon' => 'database', 'href' => route('hub.databases'), 'active' => request()->routeIs('hub.databases*')],
['label' => 'Deployments', 'icon' => 'rocket', 'href' => route('hub.deployments'), 'active' => request()->routeIs('hub.deployments*')],
['label' => 'Honeypot', 'icon' => 'bug', 'href' => route('hub.honeypot'), 'active' => request()->routeIs('hub.honeypot')],
],
],
],
// Admin - Config
[
'group' => 'admin',
'priority' => 85,
'admin' => true,
'item' => fn () => [
'label' => 'Config',
'href' => route('admin.config'),
'icon' => 'sliders',
'color' => 'zinc',
'active' => request()->routeIs('admin.config'),
],
],
// Admin - Workspaces
[
'group' => 'admin',
'priority' => 15,
'admin' => true,
'item' => fn () => [
'label' => 'Workspaces',
'href' => route('hub.admin.workspaces'),
'icon' => 'layer-group',
'color' => 'blue',
'active' => request()->routeIs('hub.admin.workspaces'),
],
],
];
}
public function register(): void
{
//
}
// -------------------------------------------------------------------------
// Event-driven handlers
// -------------------------------------------------------------------------
public function onAdminPanel(AdminPanelBooting $event): void
{
$event->views($this->moduleName, __DIR__.'/View/Blade');
if (file_exists(__DIR__.'/Routes/admin.php')) {
$event->routes(fn () => require __DIR__.'/Routes/admin.php');
}
// Core admin components
$event->livewire('hub.admin.dashboard', View\Modal\Admin\Dashboard::class);
$event->livewire('hub.admin.content', View\Modal\Admin\Content::class);
$event->livewire('hub.admin.content-manager', View\Modal\Admin\ContentManager::class);
$event->livewire('hub.admin.content-editor', View\Modal\Admin\ContentEditor::class);
$event->livewire('hub.admin.sites', View\Modal\Admin\Sites::class);
$event->livewire('hub.admin.console', View\Modal\Admin\Console::class);
$event->livewire('hub.admin.databases', View\Modal\Admin\Databases::class);
$event->livewire('hub.admin.profile', View\Modal\Admin\Profile::class);
$event->livewire('hub.admin.settings', View\Modal\Admin\Settings::class);
$event->livewire('hub.admin.account-usage', View\Modal\Admin\AccountUsage::class);
$event->livewire('hub.admin.site-settings', View\Modal\Admin\SiteSettings::class);
$event->livewire('hub.admin.deployments', View\Modal\Admin\Deployments::class);
$event->livewire('hub.admin.platform', View\Modal\Admin\Platform::class);
$event->livewire('hub.admin.platform-user', View\Modal\Admin\PlatformUser::class);
$event->livewire('hub.admin.prompt-manager', View\Modal\Admin\PromptManager::class);
$event->livewire('hub.admin.waitlist-manager', View\Modal\Admin\WaitlistManager::class);
$event->livewire('hub.admin.workspace-switcher', View\Modal\Admin\WorkspaceSwitcher::class);
$event->livewire('hub.admin.wp-connector-settings', View\Modal\Admin\WpConnectorSettings::class);
$event->livewire('hub.admin.services-admin', View\Modal\Admin\ServicesAdmin::class);
$event->livewire('hub.admin.service-manager', View\Modal\Admin\ServiceManager::class);
// Entitlement
$event->livewire('hub.admin.entitlement.dashboard', View\Modal\Admin\Entitlement\Dashboard::class);
$event->livewire('hub.admin.entitlement.feature-manager', View\Modal\Admin\Entitlement\FeatureManager::class);
$event->livewire('hub.admin.entitlement.package-manager', View\Modal\Admin\Entitlement\PackageManager::class);
// Global UI components
$event->livewire('hub.admin.global-search', View\Modal\Admin\GlobalSearch::class);
$event->livewire('hub.admin.activity-log', View\Modal\Admin\ActivityLog::class);
// Security
$event->livewire('hub.admin.honeypot', View\Modal\Admin\Honeypot::class);
// Workspace management (Tenant module)
$event->livewire('tenant.admin.workspace-manager', \Core\Mod\Tenant\View\Modal\Admin\WorkspaceManager::class);
$event->livewire('tenant.admin.workspace-details', \Core\Mod\Tenant\View\Modal\Admin\WorkspaceDetails::class);
}
}

View file

@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Hub\Controllers;
use Core\Bouncer\BlocklistService;
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.
*
* This endpoint is listed as disallowed in robots.txt. Any request to it
* indicates a crawler that doesn't respect robots.txt, which is often
* malicious or at least poorly behaved.
*/
class TeapotController
{
public function __invoke(Request $request): Response
{
// Log the hit
$userAgent = $request->userAgent();
$botName = HoneypotHit::detectBot($userAgent);
$path = $request->path();
$severity = HoneypotHit::severityForPath($path);
$ip = $request->ip();
// 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);
if (! RateLimiter::tooManyAttempts($rateLimitKey, $maxAttempts)) {
RateLimiter::hit($rateLimitKey, $decaySeconds);
// 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);
$isCritical = $severity === HoneypotHit::getSeverityCritical();
if ($autoBlockEnabled && $isCritical && ! $isLocalhost) {
app(BlocklistService::class)->block($ip, 'honeypot_critical');
}
// Return the 418 I'm a teapot response
return response($this->teapotBody(), 418, [
'Content-Type' => 'text/html; charset=utf-8',
'X-Powered-By' => 'Earl Grey',
'X-Severity' => $severity,
]);
}
/**
* Remove sensitive headers before storing.
*/
protected function sanitizeHeaders(array $headers): array
{
$sensitive = ['cookie', 'authorization', 'x-csrf-token', 'x-xsrf-token'];
foreach ($sensitive as $key) {
unset($headers[$key]);
}
return $headers;
}
/**
* The teapot response body.
*/
protected function teapotBody(): string
{
return <<<'HTML'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>418 I'm a Teapot</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-family: system-ui, -apple-system, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
text-align: center;
padding: 2rem;
}
.teapot {
font-size: 8rem;
margin-bottom: 1rem;
animation: wobble 2s ease-in-out infinite;
}
@keyframes wobble {
0%, 100% { transform: rotate(-5deg); }
50% { transform: rotate(5deg); }
}
h1 {
font-size: 3rem;
margin-bottom: 0.5rem;
}
p {
font-size: 1.25rem;
opacity: 0.9;
max-width: 500px;
}
.rfc {
margin-top: 2rem;
font-size: 0.875rem;
opacity: 0.7;
}
a {
color: inherit;
text-decoration: underline;
}
</style>
</head>
<body>
<div class="teapot">🫖</div>
<h1>418 I'm a Teapot</h1>
<p>The server refuses to brew coffee because it is, permanently, a teapot.</p>
<p class="rfc">
<a href="https://www.rfc-editor.org/rfc/rfc2324" target="_blank" rel="noopener">RFC 2324</a> &middot;
<a href="https://www.rfc-editor.org/rfc/rfc7168" target="_blank" rel="noopener">RFC 7168</a>
</p>
</body>
</html>
HTML;
}
}

View file

@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Hub\Database\Seeders;
use Core\Service\Contracts\ServiceDefinition;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Schema;
use Core\Mod\Hub\Models\Service;
/**
* Seeds platform services from service definitions.
*
* Iterates all Service classes with definition() and creates/updates
* corresponding entries in the platform_services table.
*
* Run with: php artisan db:seed --class="\\Core\Mod\\Hub\\Database\\Seeders\\ServiceSeeder"
*/
class ServiceSeeder extends Seeder
{
/**
* List of service classes that provide service definitions.
*
* @var array<class-string<ServiceDefinition>>
*/
protected array $services = [
\Service\Hub\Boot::class, // Internal service
\Service\Bio\Boot::class,
\Service\Social\Boot::class,
\Service\Analytics\Boot::class,
\Service\Trust\Boot::class,
\Service\Notify\Boot::class,
\Service\Support\Boot::class,
\Service\Commerce\Boot::class,
\Service\Agentic\Boot::class,
];
public function run(): void
{
if (! Schema::hasTable('platform_services')) {
$this->command?->warn('platform_services table does not exist. Run migrations first.');
return;
}
$seeded = 0;
$updated = 0;
foreach ($this->services as $serviceClass) {
if (! class_exists($serviceClass)) {
$this->command?->warn("Service class not found: {$serviceClass}");
continue;
}
if (! method_exists($serviceClass, 'definition')) {
$this->command?->warn("Service {$serviceClass} does not have definition()");
continue;
}
$definition = $serviceClass::definition();
if (! $definition) {
continue;
}
$existing = Service::where('code', $definition['code'])->first();
if ($existing) {
// Sync core fields from definition (code is source of truth)
$existing->update([
'module' => $definition['module'],
'name' => $definition['name'],
'tagline' => $definition['tagline'] ?? null,
'description' => $definition['description'] ?? null,
'icon' => $definition['icon'] ?? null,
'color' => $definition['color'] ?? null,
'entitlement_code' => $definition['entitlement_code'] ?? null,
'sort_order' => $definition['sort_order'] ?? 50,
// Domain routing - only set if not already configured (admin can override)
'marketing_domain' => $existing->marketing_domain ?? ($definition['marketing_domain'] ?? null),
'website_class' => $existing->website_class ?? ($definition['website_class'] ?? null),
]);
$updated++;
} else {
Service::create([
'code' => $definition['code'],
'module' => $definition['module'],
'name' => $definition['name'],
'tagline' => $definition['tagline'] ?? null,
'description' => $definition['description'] ?? null,
'icon' => $definition['icon'] ?? null,
'color' => $definition['color'] ?? null,
'marketing_domain' => $definition['marketing_domain'] ?? null,
'website_class' => $definition['website_class'] ?? null,
'entitlement_code' => $definition['entitlement_code'] ?? null,
'sort_order' => $definition['sort_order'] ?? 50,
'is_enabled' => true,
'is_public' => true,
'is_featured' => false,
]);
$seeded++;
}
}
$this->command?->info("Services seeded: {$seeded} created, {$updated} updated.");
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('honeypot_hits', function (Blueprint $table) {
$table->id();
$table->string('ip_address', 45);
$table->string('user_agent', 1000)->nullable();
$table->string('referer', 2000)->nullable();
$table->string('path', 255);
$table->string('method', 10);
$table->json('headers')->nullable();
$table->string('country', 2)->nullable();
$table->string('city', 100)->nullable();
$table->boolean('is_bot')->default(false);
$table->string('bot_name', 100)->nullable();
$table->timestamps();
$table->index('ip_address');
$table->index('created_at');
$table->index('is_bot');
});
}
public function down(): void
{
Schema::dropIfExists('honeypot_hits');
}
};

View file

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('platform_services', function (Blueprint $table) {
$table->id();
$table->string('code', 50)->unique(); // 'bio', 'social' - matches module's service key
$table->string('module', 50); // 'WebPage', 'Social' - source module name
$table->string('name', 100); // 'Bio' - display name
$table->string('tagline', 200)->nullable(); // 'Link-in-bio pages' - short marketing tagline
$table->text('description')->nullable(); // Marketing description
$table->string('icon', 50)->nullable(); // Font Awesome icon name
$table->string('color', 20)->nullable(); // Tailwind color name
$table->string('marketing_domain', 100)->nullable(); // 'lthn.test', 'social.host.test'
$table->string('marketing_url', 255)->nullable(); // Full marketing page URL override
$table->string('docs_url', 255)->nullable(); // Documentation URL
$table->boolean('is_enabled')->default(true); // Global enable/disable
$table->boolean('is_public')->default(true); // Show in public service catalogue
$table->boolean('is_featured')->default(false); // Feature in marketing
$table->string('entitlement_code', 50)->nullable(); // 'core.srv.bio' - links to entitlement system
$table->integer('sort_order')->default(50);
$table->json('metadata')->nullable(); // Extensible for future needs
$table->timestamps();
$table->index('is_enabled');
$table->index('is_public');
$table->index('sort_order');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('platform_services');
}
};

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('platform_services', function (Blueprint $table) {
// Mod class to handle marketing_domain routing
// e.g., 'Mod\LtHn\Boot' for lthn.test
$table->string('website_class', 150)->nullable()->after('marketing_domain');
$table->index('marketing_domain');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('platform_services', function (Blueprint $table) {
$table->dropIndex(['marketing_domain']);
$table->dropColumn('website_class');
});
}
};

View file

@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Hub\Models;
use Illuminate\Database\Eloquent\Model;
class HoneypotHit extends Model
{
protected $fillable = [
'ip_address',
'user_agent',
'referer',
'path',
'method',
'headers',
'country',
'city',
'is_bot',
'bot_name',
'severity',
];
protected $casts = [
'headers' => 'array',
'is_bot' => 'boolean',
];
/**
* 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
{
$criticalPaths = self::getCriticalPaths();
$path = ltrim($path, '/');
foreach ($criticalPaths as $critical) {
if (str_starts_with($path, $critical)) {
return self::getSeverityCritical();
}
}
return self::getSeverityWarning();
}
/**
* Known bad bot patterns.
*/
protected static array $botPatterns = [
'AhrefsBot' => 'Ahrefs',
'SemrushBot' => 'Semrush',
'MJ12bot' => 'Majestic',
'DotBot' => 'Moz',
'BLEXBot' => 'BLEXBot',
'PetalBot' => 'Petal',
'YandexBot' => 'Yandex',
'bingbot' => 'Bing',
'Googlebot' => 'Google',
'Bytespider' => 'ByteDance',
'GPTBot' => 'OpenAI',
'CCBot' => 'Common Crawl',
'ClaudeBot' => 'Anthropic',
'anthropic-ai' => 'Anthropic',
'DataForSeoBot' => 'DataForSEO',
'serpstatbot' => 'Serpstat',
'curl/' => 'cURL',
'python-requests' => 'Python',
'Go-http-client' => 'Go',
'wget' => 'Wget',
'scrapy' => 'Scrapy',
'HeadlessChrome' => 'HeadlessChrome',
'PhantomJS' => 'PhantomJS',
];
/**
* Detect if the user agent is a known bot.
*/
public static function detectBot(?string $userAgent): ?string
{
if (empty($userAgent)) {
return 'Unknown (no UA)';
}
foreach (self::$botPatterns as $pattern => $name) {
if (stripos($userAgent, $pattern) !== false) {
return $name;
}
}
return null;
}
/**
* Scope for recent hits.
*/
public function scopeRecent($query, int $hours = 24)
{
return $query->where('created_at', '>=', now()->subHours($hours));
}
/**
* Scope for a specific IP.
*/
public function scopeFromIp($query, string $ip)
{
return $query->where('ip_address', $ip);
}
/**
* Scope for bots only.
*/
public function scopeBots($query)
{
return $query->where('is_bot', true);
}
/**
* Scope for critical severity (blocklist candidates).
*/
public function scopeCritical($query)
{
return $query->where('severity', self::SEVERITY_CRITICAL);
}
/**
* Scope for warning severity.
*/
public function scopeWarning($query)
{
return $query->where('severity', self::SEVERITY_WARNING);
}
/**
* Get stats for the dashboard.
*/
public static function getStats(): array
{
return [
'total' => self::count(),
'today' => self::whereDate('created_at', today())->count(),
'this_week' => self::where('created_at', '>=', now()->subWeek())->count(),
'unique_ips' => self::distinct('ip_address')->count('ip_address'),
'bots' => self::where('is_bot', true)->count(),
'top_ips' => self::selectRaw('ip_address, COUNT(*) as hits')
->groupBy('ip_address')
->orderByDesc('hits')
->limit(10)
->get(),
'top_bots' => self::selectRaw('bot_name, COUNT(*) as hits')
->whereNotNull('bot_name')
->groupBy('bot_name')
->orderByDesc('hits')
->limit(10)
->get(),
];
}
}

View file

@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Hub\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class Service extends Model
{
protected $table = 'platform_services';
protected $fillable = [
'code',
'module',
'name',
'tagline',
'description',
'icon',
'color',
'marketing_domain',
'website_class',
'marketing_url',
'docs_url',
'is_enabled',
'is_public',
'is_featured',
'entitlement_code',
'sort_order',
'metadata',
];
protected $casts = [
'is_enabled' => 'boolean',
'is_public' => 'boolean',
'is_featured' => 'boolean',
'metadata' => 'array',
'sort_order' => 'integer',
];
/**
* Scope: only enabled services.
*/
public function scopeEnabled(Builder $query): Builder
{
return $query->where('is_enabled', true);
}
/**
* Scope: only public services (visible in catalogue).
*/
public function scopePublic(Builder $query): Builder
{
return $query->where('is_public', true);
}
/**
* Scope: only featured services.
*/
public function scopeFeatured(Builder $query): Builder
{
return $query->where('is_featured', true);
}
/**
* Scope: order by sort_order, then name.
*/
public function scopeOrdered(Builder $query): Builder
{
return $query->orderBy('sort_order')->orderBy('name');
}
/**
* Scope: services with a marketing domain configured.
*/
public function scopeWithMarketingDomain(Builder $query): Builder
{
return $query->whereNotNull('marketing_domain')
->whereNotNull('website_class');
}
/**
* Find a service by its code.
*/
public static function findByCode(string $code): ?self
{
return self::where('code', $code)->first();
}
/**
* Get domain website_class mappings for enabled services.
*
* Used by DomainResolver for routing marketing domains.
*
* @return array<string, string> domain => website_class
*/
public static function getDomainMappings(): array
{
return self::enabled()
->withMarketingDomain()
->pluck('website_class', 'marketing_domain')
->toArray();
}
/**
* Get the marketing URL, falling back to marketing_domain if no override set.
*/
public function getMarketingUrlAttribute(?string $value): ?string
{
if ($value) {
return $value;
}
if ($this->marketing_domain) {
$scheme = app()->environment('local') ? 'http' : 'https';
return "{$scheme}://{$this->marketing_domain}";
}
return null;
}
/**
* Check if a specific metadata key exists.
*/
public function hasMeta(string $key): bool
{
return isset($this->metadata[$key]);
}
/**
* Get a specific metadata value.
*/
public function getMeta(string $key, mixed $default = null): mixed
{
return $this->metadata[$key] ?? $default;
}
/**
* Set a metadata value.
*/
public function setMeta(string $key, mixed $value): void
{
$metadata = $this->metadata ?? [];
$metadata[$key] = $value;
$this->metadata = $metadata;
}
}

View file

@ -0,0 +1,255 @@
<?php
declare(strict_types=1);
/**
* Hub Routes Tests (TASK-010 Phase 2)
*
* Comprehensive tests for all authenticated hub routes.
* Each test asserts meaningful HTML content, not just status codes.
*/
use Core\Mod\Tenant\Models\User;
beforeEach(function () {
$this->user = User::factory()->create([
'account_type' => 'hades',
]);
});
describe('Hub Routes (Guest)', function () {
it('redirects guests from hub home to login', function () {
$this->get('/hub')
->assertRedirect();
});
it('redirects guests from hub dashboard to login', function () {
$this->get('/hub/dashboard')
->assertRedirect();
});
it('redirects guests from SocialHost to login', function () {
$this->get('/hub/social')
->assertRedirect();
});
it('redirects guests from profile to login', function () {
$this->get('/hub/profile')
->assertRedirect();
});
it('redirects guests from settings to login', function () {
$this->get('/hub/settings')
->assertRedirect();
});
it('redirects guests from billing to login', function () {
$this->get('/hub/billing')
->assertRedirect();
});
it('redirects guests from analytics to login', function () {
$this->get('/hub/analytics')
->assertRedirect();
});
it('redirects guests from bio to login', function () {
$this->get('/hub/bio')
->assertRedirect();
});
it('redirects guests from notify to login', function () {
$this->get('/hub/notify')
->assertRedirect();
});
it('redirects guests from trust to login', function () {
$this->get('/hub/trust')
->assertRedirect();
});
});
describe('Hub Home (Authenticated)', function () {
it('renders hub home with welcome banner', function () {
$this->actingAs($this->user)
->get('/hub')
->assertOk()
->assertSee('Dashboard')
->assertSee('Your creator toolkit at a glance');
});
it('displays service cards on hub home', function () {
$this->actingAs($this->user)
->get('/hub')
->assertOk()
->assertSee('BioHost')
->assertSee('SocialHost');
});
});
describe('Hub Profile (Authenticated)', function () {
it('renders profile page with user information', function () {
$this->actingAs($this->user)
->get('/hub/profile')
->assertOk()
->assertSee($this->user->name)
->assertSee($this->user->email);
});
it('displays tier badge on profile', function () {
$this->actingAs($this->user)
->get('/hub/profile')
->assertOk()
->assertSee('Settings');
});
});
describe('Hub Settings (Authenticated)', function () {
it('renders settings page with profile form', function () {
$this->actingAs($this->user)
->get('/hub/settings')
->assertOk()
->assertSee('Account Settings')
->assertSee('Profile Information');
});
it('displays save button on settings', function () {
$this->actingAs($this->user)
->get('/hub/settings')
->assertOk()
->assertSee('Save Profile');
});
});
describe('Billing Dashboard (Authenticated)', function () {
it('renders billing dashboard with current plan', function () {
$this->actingAs($this->user)
->get('/hub/billing')
->assertOk()
->assertSee('Billing')
->assertSee('Current Plan');
});
it('displays plan upgrade option', function () {
$this->actingAs($this->user)
->get('/hub/billing')
->assertOk()
->assertSee('Upgrade');
});
});
describe('SocialHost Dashboard (Authenticated)', function () {
it('renders social dashboard with analytics heading', function () {
$this->actingAs($this->user)
->get('/hub/social')
->assertOk()
->assertSee('Dashboard')
->assertSee('social accounts');
});
it('displays period selector on social dashboard', function () {
$this->actingAs($this->user)
->get('/hub/social')
->assertOk()
->assertSee('7 days')
->assertSee('30 days');
});
});
describe('AnalyticsHost Index (Authenticated)', function () {
it('renders analytics index with page header', function () {
$this->actingAs($this->user)
->get('/hub/analytics')
->assertOk()
->assertSee('Analytics')
->assertSee('Privacy-focused');
});
it('displays add website button on analytics', function () {
$this->actingAs($this->user)
->get('/hub/analytics')
->assertOk()
->assertSee('Add Mod');
});
});
describe('BioHost Index (Authenticated)', function () {
it('renders bio index with page header', function () {
$this->actingAs($this->user)
->get('/hub/bio')
->assertOk()
->assertSee('Bio');
});
it('displays new bio page button', function () {
$this->actingAs($this->user)
->get('/hub/bio')
->assertOk()
->assertSee('New');
});
});
describe('NotifyHost Index (Authenticated)', function () {
it('renders notify index with page header', function () {
$this->actingAs($this->user)
->get('/hub/notify')
->assertOk()
->assertSee('Notify');
});
it('displays add website button on notify', function () {
$this->actingAs($this->user)
->get('/hub/notify')
->assertOk()
->assertSee('Add');
});
});
describe('TrustHost Index (Authenticated)', function () {
it('renders trust index with page header', function () {
$this->actingAs($this->user)
->get('/hub/trust')
->assertOk()
->assertSee('Trust');
});
it('displays add campaign button on trust', function () {
$this->actingAs($this->user)
->get('/hub/trust')
->assertOk()
->assertSee('Add');
});
});
describe('Dev API Routes (Hades only)', function () {
it('allows Hades users to access dev logs API', function () {
$this->actingAs($this->user)
->getJson('/hub/api/dev/logs')
->assertOk()
->assertJsonIsArray();
});
it('allows Hades users to access dev routes API', function () {
$this->actingAs($this->user)
->getJson('/hub/api/dev/routes')
->assertOk()
->assertJsonIsArray();
});
it('allows Hades users to access dev session API', function () {
$this->actingAs($this->user)
->getJson('/hub/api/dev/session')
->assertOk()
->assertJsonStructure(['id', 'ip', 'user_agent']);
});
it('denies non-Hades users access to dev APIs', function () {
$regularUser = User::factory()->create([
'account_type' => 'apollo',
]);
$this->actingAs($regularUser)
->getJson('/hub/api/dev/logs')
->assertForbidden();
});
});

View file

@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Hub\Tests\Feature;
use Core\Mod\Hub\View\Modal\Admin\WorkspaceSwitcher;
use Core\Mod\Tenant\Models\User;
use Core\Mod\Tenant\Models\Workspace;
use Core\Mod\Tenant\Services\WorkspaceService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class WorkspaceSwitcherTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Workspace $workspaceA;
protected Workspace $workspaceB;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
$this->workspaceA = Workspace::factory()->create([
'name' => 'Workspace A',
'slug' => 'workspace-a',
]);
$this->workspaceB = Workspace::factory()->create([
'name' => 'Workspace B',
'slug' => 'workspace-b',
]);
// Attach user to both workspaces
$this->user->hostWorkspaces()->attach($this->workspaceA, ['role' => 'owner', 'is_default' => true]);
$this->user->hostWorkspaces()->attach($this->workspaceB, ['role' => 'editor']);
}
public function test_component_loads_with_user_workspaces(): void
{
$this->actingAs($this->user);
Livewire::test(WorkspaceSwitcher::class)
->assertSet('workspaces', function ($workspaces) {
return count($workspaces) === 2
&& isset($workspaces['workspace-a'])
&& isset($workspaces['workspace-b']);
})
->assertSet('current.slug', 'workspace-a'); // Default workspace
}
public function test_current_workspace_is_set_from_session(): void
{
$this->actingAs($this->user);
// Set workspace B in session
session(['workspace' => 'workspace-b']);
Livewire::test(WorkspaceSwitcher::class)
->assertSet('current.slug', 'workspace-b');
}
public function test_switch_workspace_updates_session(): void
{
$this->actingAs($this->user);
// Initialize - currentModel() sets session to default workspace
$service = app(WorkspaceService::class);
$model = $service->currentModel();
$this->assertEquals('workspace-a', $model->slug);
$this->assertEquals('workspace-a', session('workspace'));
Livewire::test(WorkspaceSwitcher::class)
->call('switchWorkspace', 'workspace-b');
// Check session was updated
$this->assertEquals('workspace-b', session('workspace'));
}
public function test_switch_workspace_dispatches_event(): void
{
$this->actingAs($this->user);
Livewire::test(WorkspaceSwitcher::class)
->call('switchWorkspace', 'workspace-b')
->assertDispatched('workspace-changed', workspace: 'workspace-b');
}
public function test_switch_workspace_redirects(): void
{
$this->actingAs($this->user);
Livewire::test(WorkspaceSwitcher::class)
->call('switchWorkspace', 'workspace-b')
->assertRedirect();
}
public function test_cannot_switch_to_workspace_user_does_not_belong_to(): void
{
$this->actingAs($this->user);
$otherWorkspace = Workspace::factory()->create(['slug' => 'other-workspace']);
Livewire::test(WorkspaceSwitcher::class)
->call('switchWorkspace', 'other-workspace');
// Session should NOT be changed to the other workspace
$this->assertNotEquals('other-workspace', session('workspace'));
}
public function test_workspace_service_set_current_returns_false_for_invalid_workspace(): void
{
$this->actingAs($this->user);
$service = app(WorkspaceService::class);
$this->assertFalse($service->setCurrent('nonexistent-workspace'));
$this->assertTrue($service->setCurrent('workspace-b'));
}
public function test_switched_workspace_persists_across_component_instances(): void
{
$this->actingAs($this->user);
// Initialize session with default workspace
app(WorkspaceService::class)->currentModel();
// Switch workspace
Livewire::test(WorkspaceSwitcher::class)
->call('switchWorkspace', 'workspace-b');
// Create a NEW component instance - it should see the switched workspace
// Note: We need to manually set the session since Livewire tests are isolated
session(['workspace' => 'workspace-b']);
Livewire::test(WorkspaceSwitcher::class)
->assertSet('current.slug', 'workspace-b')
->assertSet('current.name', 'Workspace B');
}
public function test_switch_workspace_closes_dropdown(): void
{
$this->actingAs($this->user);
Livewire::test(WorkspaceSwitcher::class)
->set('open', true)
->call('switchWorkspace', 'workspace-b')
->assertSet('open', false);
}
public function test_component_renders_all_workspaces_in_dropdown(): void
{
$this->actingAs($this->user);
Livewire::test(WorkspaceSwitcher::class)
->assertSee('Workspace A')
->assertSee('Workspace B')
->assertSee('Switch Workspace');
}
public function test_switch_workspace_redirects_to_captured_url(): void
{
$this->actingAs($this->user);
// Set a specific returnUrl and verify redirect uses it
Livewire::test(WorkspaceSwitcher::class)
->set('returnUrl', 'https://example.com/test-page')
->call('switchWorkspace', 'workspace-b')
->assertRedirect('https://example.com/test-page');
}
public function test_return_url_is_captured_on_mount(): void
{
$this->actingAs($this->user);
// Just verify returnUrl is set (not empty)
Livewire::test(WorkspaceSwitcher::class)
->assertSet('returnUrl', fn ($url) => ! empty($url));
}
public function test_switch_workspace_falls_back_to_dashboard_if_no_return_url(): void
{
$this->actingAs($this->user);
// If returnUrl is empty, should redirect to dashboard
Livewire::test(WorkspaceSwitcher::class)
->set('returnUrl', '')
->call('switchWorkspace', 'workspace-b')
->assertRedirect(route('hub.dashboard'));
}
}

View file

@ -0,0 +1,53 @@
<?php
/**
* UseCase: Hub Dashboard (Basic Flow)
*
* Acceptance test for the Hub admin dashboard.
* Tests the happy path user journey through the browser.
*
* Uses translation keys to get expected values - tests won't break on copy changes.
*/
use Core\Mod\Tenant\Models\User;
use Core\Mod\Tenant\Models\Workspace;
describe('Hub Dashboard', function () {
beforeEach(function () {
// Create user with workspace
$this->user = User::factory()->create([
'email' => 'test@example.com',
'password' => bcrypt('password'),
]);
$this->workspace = Workspace::factory()->create();
$this->workspace->users()->attach($this->user->id, [
'role' => 'owner',
'is_default' => true,
]);
});
it('can login and view the dashboard with all sections', function () {
// Login
$page = visit('/login');
$page->fill('email', 'test@example.com')
->fill('password', 'password')
->click(__('pages::pages.login.submit'))
->assertPathContains('/hub');
// Verify dashboard title and subtitle (from translations)
$page->assertSee(__('hub::hub.dashboard.title'))
->assertSee(__('hub::hub.dashboard.subtitle'));
// Verify action button
$page->assertSee(__('hub::hub.dashboard.actions.edit_content'));
// Check activity section
$page->assertSee(__('hub::hub.dashboard.sections.recent_activity'));
// Check quick actions section
$page->assertSee(__('hub::hub.quick_actions.manage_workspaces.title'))
->assertSee(__('hub::hub.quick_actions.profile.title'));
});
});

View file

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

View file

@ -0,0 +1,120 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Admin\Search\Contracts;
use Illuminate\Support\Collection;
/**
* Interface for search providers.
*
* Modules implement this interface to contribute searchable content to the
* global search (Command+K). Each provider is responsible for:
*
* - Defining a search type (e.g., 'pages', 'users', 'posts')
* - Providing an icon for visual identification
* - Executing searches against their data source
* - Generating URLs for navigation to results
*
* ## Search Result Format
*
* The `search()` method should return a Collection of SearchResult objects
* or arrays with the following structure:
*
* ```php
* [
* 'id' => 'unique-identifier',
* 'title' => 'Result Title',
* 'subtitle' => 'Optional description',
* 'url' => '/path/to/resource',
* 'icon' => 'optional-override-icon',
* 'meta' => ['optional' => 'metadata'],
* ]
* ```
*
* ## Registration
*
* Providers are typically registered via `SearchProviderRegistry::register()`
* during the AdminPanelBooting event or in a service provider's boot method.
*
*
* @see SearchProviderRegistry For provider registration and discovery
* @see SearchResult For the result data structure
*/
interface SearchProvider
{
/**
* Get the search type identifier.
*
* This is used for grouping results in the UI and for filtering.
* Examples: 'pages', 'users', 'posts', 'products', 'settings'.
*/
public function searchType(): string;
/**
* Get the display label for this search type.
*
* This is shown as the group header in the search results.
* Should be a human-readable, translatable string.
*/
public function searchLabel(): string;
/**
* Get the icon name for this search type.
*
* Used to display an icon next to search results from this provider.
* Should be a valid Heroicon or FontAwesome icon name.
*/
public function searchIcon(): string;
/**
* Execute a search query.
*
* Searches the provider's data source for matches against the query.
* Should implement fuzzy matching where appropriate for better UX.
*
* @param string $query The search query string
* @param int $limit Maximum number of results to return (default: 5)
* @return Collection<int, SearchResult|array> Collection of search results
*/
public function search(string $query, int $limit = 5): Collection;
/**
* Get the URL for a search result.
*
* Generates the navigation URL for a given search result.
* This allows providers to implement custom URL generation logic.
*
* @param mixed $result The search result (model or array)
* @return string The URL to navigate to
*/
public function getUrl(mixed $result): string;
/**
* Get the priority for ordering in search results.
*
* Lower numbers appear first. Default should be 50.
* Use lower numbers (10-40) for important/frequently accessed resources.
* Use higher numbers (60-100) for less important resources.
*/
public function searchPriority(): int;
/**
* Check if this provider should be active for the current context.
*
* Override this to implement permission checks or context-based filtering.
* For example, only show certain searches to admin users.
*
* @param object|null $user The authenticated user
* @param object|null $workspace The current workspace context
*/
public function isAvailable(?object $user, ?object $workspace): bool;
}

View file

@ -0,0 +1,216 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Admin\Search\Providers;
use Core\Admin\Search\Concerns\HasSearchProvider;
use Core\Admin\Search\Contracts\SearchProvider;
use Core\Admin\Search\SearchProviderRegistry;
use Core\Admin\Search\SearchResult;
use Illuminate\Support\Collection;
/**
* Search provider for admin navigation pages.
*
* Provides quick access to admin pages via global search.
* This is a built-in provider that indexes all admin navigation items.
*/
class AdminPageSearchProvider implements SearchProvider
{
use HasSearchProvider;
/**
* Static list of admin pages.
*
* These are the core admin navigation items that are always available.
* Modules can register additional search providers for their own pages.
*
* @var array<array{id: string, title: string, subtitle: string, url: string, icon: string}>
*/
protected array $pages = [
[
'id' => 'dashboard',
'title' => 'Dashboard',
'subtitle' => 'Overview and quick actions',
'url' => '/hub',
'icon' => 'house',
],
[
'id' => 'workspaces',
'title' => 'Workspaces',
'subtitle' => 'Manage your workspaces',
'url' => '/hub/sites',
'icon' => 'folders',
],
[
'id' => 'profile',
'title' => 'Profile',
'subtitle' => 'Your account profile',
'url' => '/hub/account',
'icon' => 'user',
],
[
'id' => 'settings',
'title' => 'Settings',
'subtitle' => 'Account settings and preferences',
'url' => '/hub/account/settings',
'icon' => 'gear',
],
[
'id' => 'usage',
'title' => 'Usage & Limits',
'subtitle' => 'Monitor your usage and quotas',
'url' => '/hub/account/usage',
'icon' => 'chart-pie',
],
[
'id' => 'ai-services',
'title' => 'AI Services',
'subtitle' => 'Configure AI providers',
'url' => '/hub/ai-services',
'icon' => 'sparkles',
],
[
'id' => 'prompts',
'title' => 'Prompt Manager',
'subtitle' => 'Manage AI prompts',
'url' => '/hub/prompts',
'icon' => 'command',
],
[
'id' => 'content-manager',
'title' => 'Content Manager',
'subtitle' => 'Manage WordPress content',
'url' => '/hub/content-manager',
'icon' => 'newspaper',
],
[
'id' => 'deployments',
'title' => 'Deployments',
'subtitle' => 'View deployment history',
'url' => '/hub/deployments',
'icon' => 'rocket',
],
[
'id' => 'databases',
'title' => 'Databases',
'subtitle' => 'Database management',
'url' => '/hub/databases',
'icon' => 'database',
],
[
'id' => 'console',
'title' => 'Server Console',
'subtitle' => 'Terminal access',
'url' => '/hub/console',
'icon' => 'terminal',
],
[
'id' => 'analytics',
'title' => 'Analytics',
'subtitle' => 'Traffic and performance',
'url' => '/hub/analytics',
'icon' => 'chart-line',
],
[
'id' => 'activity',
'title' => 'Activity Log',
'subtitle' => 'Recent account activity',
'url' => '/hub/activity',
'icon' => 'clock-rotate-left',
],
];
protected SearchProviderRegistry $registry;
public function __construct(SearchProviderRegistry $registry)
{
$this->registry = $registry;
}
/**
* Get the search type identifier.
*/
public function searchType(): string
{
return 'pages';
}
/**
* Get the display label for this search type.
*/
public function searchLabel(): string
{
return __('Pages');
}
/**
* Get the icon name for this search type.
*/
public function searchIcon(): string
{
return 'rectangle-stack';
}
/**
* Get the priority for ordering in search results.
*/
public function searchPriority(): int
{
return 10; // Show pages first
}
/**
* Execute a search query.
*
* @param string $query The search query string
* @param int $limit Maximum number of results to return
*/
public function search(string $query, int $limit = 5): Collection
{
return collect($this->pages)
->filter(function ($page) use ($query) {
// Match against title and subtitle
return $this->registry->fuzzyMatch($query, $page['title'])
|| $this->registry->fuzzyMatch($query, $page['subtitle']);
})
->sortByDesc(function ($page) use ($query) {
// Sort by relevance to title
return $this->registry->relevanceScore($query, $page['title']);
})
->take($limit)
->map(function ($page) {
return new SearchResult(
id: $page['id'],
title: $page['title'],
url: $page['url'],
type: $this->searchType(),
icon: $page['icon'],
subtitle: $page['subtitle'],
);
})
->values();
}
/**
* Get the URL for a search result.
*
* @param mixed $result The search result
*/
public function getUrl(mixed $result): string
{
if ($result instanceof SearchResult) {
return $result->url;
}
return $result['url'] ?? '#';
}
}

View file

@ -0,0 +1,305 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Admin\Search;
use Core\Admin\Search\Contracts\SearchProvider;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
/**
* Registry for search providers.
*
* Manages registration and discovery of SearchProvider implementations.
* Coordinates searching across all registered providers and aggregates
* results into a unified structure for the GlobalSearch component.
*
* ## Fuzzy Matching
*
* The registry provides built-in fuzzy matching support via the `fuzzyMatch()`
* method. Providers can use this for consistent search behavior:
*
* ```php
* public function search(string $query, int $limit = 5): Collection
* {
* $results = $this->getAllItems();
* return $results->filter(function ($item) use ($query) {
* return app(SearchProviderRegistry::class)
* ->fuzzyMatch($query, $item->title);
* })->take($limit);
* }
* ```
*/
class SearchProviderRegistry
{
/**
* Registered search providers.
*
* @var array<SearchProvider>
*/
protected array $providers = [];
/**
* Register a search provider.
*/
public function register(SearchProvider $provider): void
{
$this->providers[] = $provider;
}
/**
* Register multiple search providers.
*
* @param array<SearchProvider> $providers
*/
public function registerMany(array $providers): void
{
foreach ($providers as $provider) {
$this->register($provider);
}
}
/**
* Get all registered providers.
*
* @return array<SearchProvider>
*/
public function providers(): array
{
return $this->providers;
}
/**
* Get available providers for a given context.
*
* @param object|null $user The authenticated user
* @param object|null $workspace The current workspace context
* @return Collection<int, SearchProvider>
*/
public function availableProviders(?object $user, ?object $workspace): Collection
{
return collect($this->providers)
->filter(fn (SearchProvider $provider) => $provider->isAvailable($user, $workspace))
->sortBy(fn (SearchProvider $provider) => $provider->searchPriority());
}
/**
* Search across all available providers.
*
* Returns results grouped by search type, sorted by provider priority.
*
* @param string $query The search query
* @param object|null $user The authenticated user
* @param object|null $workspace The current workspace context
* @param int $limitPerProvider Maximum results per provider
* @return array<string, array{label: string, icon: string, results: array}>
*/
public function search(
string $query,
?object $user,
?object $workspace,
int $limitPerProvider = 5
): array {
$grouped = [];
foreach ($this->availableProviders($user, $workspace) as $provider) {
$type = $provider->searchType();
$results = $provider->search($query, $limitPerProvider);
// Convert results to array format with type/icon
$formattedResults = $results->map(function ($result) use ($provider) {
if ($result instanceof SearchResult) {
return $result->withTypeAndIcon(
$provider->searchType(),
$provider->searchIcon()
)->toArray();
}
// Handle array results
if (is_array($result)) {
$searchResult = SearchResult::fromArray($result);
return $searchResult->withTypeAndIcon(
$provider->searchType(),
$provider->searchIcon()
)->toArray();
}
// Handle model objects with getUrl
return [
'id' => (string) ($result->id ?? uniqid()),
'title' => (string) ($result->title ?? $result->name ?? ''),
'subtitle' => (string) ($result->subtitle ?? $result->description ?? ''),
'url' => $provider->getUrl($result),
'type' => $provider->searchType(),
'icon' => $provider->searchIcon(),
'meta' => [],
];
})->toArray();
if (! empty($formattedResults)) {
$grouped[$type] = [
'label' => $provider->searchLabel(),
'icon' => $provider->searchIcon(),
'results' => $formattedResults,
];
}
}
return $grouped;
}
/**
* Flatten search results into a single array for keyboard navigation.
*
* @param array $grouped Grouped search results
*/
public function flattenResults(array $grouped): array
{
$flat = [];
foreach ($grouped as $type => $group) {
foreach ($group['results'] as $result) {
$flat[] = $result;
}
}
return $flat;
}
/**
* Check if a query fuzzy-matches a target string.
*
* Supports:
* - Case-insensitive partial matching
* - Word-start matching (e.g., "ps" matches "Post Settings")
* - Abbreviation matching (e.g., "gs" matches "Global Search")
*
* @param string $query The search query
* @param string $target The target string to match against
*/
public function fuzzyMatch(string $query, string $target): bool
{
$query = Str::lower(trim($query));
$target = Str::lower(trim($target));
// Empty query matches nothing
if ($query === '') {
return false;
}
// Direct substring match (most common case)
if (Str::contains($target, $query)) {
return true;
}
// Word-start matching: each character matches start of consecutive words
// e.g., "ps" matches "Post Settings", "gs" matches "Global Search"
$words = preg_split('/\s+/', $target);
$queryChars = str_split($query);
$wordIndex = 0;
$charIndex = 0;
while ($charIndex < count($queryChars) && $wordIndex < count($words)) {
$char = $queryChars[$charIndex];
$word = $words[$wordIndex];
if (Str::startsWith($word, $char)) {
$charIndex++;
}
$wordIndex++;
}
if ($charIndex === count($queryChars)) {
return true;
}
// Abbreviation matching: all query chars appear in order
// e.g., "gsr" matches "Global Search Results"
$targetIndex = 0;
foreach ($queryChars as $char) {
$foundAt = strpos($target, $char, $targetIndex);
if ($foundAt === false) {
return false;
}
$targetIndex = $foundAt + 1;
}
return true;
}
/**
* Calculate a relevance score for sorting results.
*
* Higher scores indicate better matches.
*
* @param string $query The search query
* @param string $target The target string
* @return int Score from 0-100
*/
public function relevanceScore(string $query, string $target): int
{
$query = Str::lower(trim($query));
$target = Str::lower(trim($target));
if ($query === '' || $target === '') {
return 0;
}
// Exact match
if ($target === $query) {
return 100;
}
// Starts with query
if (Str::startsWith($target, $query)) {
return 90;
}
// Contains query as whole word
if (preg_match('/\b'.preg_quote($query, '/').'\b/', $target)) {
return 80;
}
// Contains query
if (Str::contains($target, $query)) {
return 70;
}
// Word-start matching
$words = preg_split('/\s+/', $target);
$queryChars = str_split($query);
$matched = 0;
$wordIndex = 0;
foreach ($queryChars as $char) {
while ($wordIndex < count($words)) {
if (Str::startsWith($words[$wordIndex], $char)) {
$matched++;
$wordIndex++;
break;
}
$wordIndex++;
}
}
if ($matched === count($queryChars)) {
return 60;
}
// Fuzzy match
if ($this->fuzzyMatch($query, $target)) {
return 40;
}
return 0;
}
}

104
src/Search/SearchResult.php Normal file
View file

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

View file

@ -0,0 +1,237 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Admin\Search\Tests;
use Core\Admin\Search\Concerns\HasSearchProvider;
use Core\Admin\Search\Contracts\SearchProvider;
use Core\Admin\Search\SearchProviderRegistry;
use Core\Admin\Search\SearchResult;
use Illuminate\Support\Collection;
use PHPUnit\Framework\TestCase;
class SearchProviderRegistryTest extends TestCase
{
protected SearchProviderRegistry $registry;
protected function setUp(): void
{
parent::setUp();
$this->registry = new SearchProviderRegistry;
}
public function test_can_register_provider(): void
{
$provider = $this->createMockProvider('test', 'Test', 'document');
$this->registry->register($provider);
$this->assertCount(1, $this->registry->providers());
}
public function test_can_register_many_providers(): void
{
$providers = [
$this->createMockProvider('pages', 'Pages', 'document'),
$this->createMockProvider('users', 'Users', 'user'),
];
$this->registry->registerMany($providers);
$this->assertCount(2, $this->registry->providers());
}
public function test_fuzzy_match_direct_substring(): void
{
$this->assertTrue($this->registry->fuzzyMatch('dash', 'Dashboard'));
$this->assertTrue($this->registry->fuzzyMatch('board', 'Dashboard'));
$this->assertTrue($this->registry->fuzzyMatch('settings', 'Account Settings'));
}
public function test_fuzzy_match_case_insensitive(): void
{
$this->assertTrue($this->registry->fuzzyMatch('DASH', 'dashboard'));
$this->assertTrue($this->registry->fuzzyMatch('Dashboard', 'DASHBOARD'));
}
public function test_fuzzy_match_word_start(): void
{
// "gs" should match "Global Search" (G + S)
$this->assertTrue($this->registry->fuzzyMatch('gs', 'Global Search'));
// "ps" should match "Post Settings"
$this->assertTrue($this->registry->fuzzyMatch('ps', 'Post Settings'));
// "ul" should match "Usage Limits"
$this->assertTrue($this->registry->fuzzyMatch('ul', 'Usage Limits'));
}
public function test_fuzzy_match_abbreviation(): void
{
// Characters appear in order
$this->assertTrue($this->registry->fuzzyMatch('dbd', 'dashboard'));
$this->assertTrue($this->registry->fuzzyMatch('gsr', 'global search results'));
}
public function test_fuzzy_match_empty_query_returns_false(): void
{
$this->assertFalse($this->registry->fuzzyMatch('', 'Dashboard'));
$this->assertFalse($this->registry->fuzzyMatch(' ', 'Dashboard'));
}
public function test_fuzzy_match_no_match(): void
{
$this->assertFalse($this->registry->fuzzyMatch('xyz', 'Dashboard'));
$this->assertFalse($this->registry->fuzzyMatch('zzz', 'Settings'));
}
public function test_relevance_score_exact_match(): void
{
$score = $this->registry->relevanceScore('dashboard', 'dashboard');
$this->assertEquals(100, $score);
}
public function test_relevance_score_starts_with(): void
{
$score = $this->registry->relevanceScore('dash', 'dashboard');
$this->assertEquals(90, $score);
}
public function test_relevance_score_contains(): void
{
$score = $this->registry->relevanceScore('board', 'dashboard');
$this->assertEquals(70, $score);
}
public function test_relevance_score_word_start(): void
{
$score = $this->registry->relevanceScore('gs', 'global search');
$this->assertEquals(60, $score);
}
public function test_relevance_score_no_match(): void
{
$score = $this->registry->relevanceScore('xyz', 'dashboard');
$this->assertEquals(0, $score);
}
public function test_search_returns_grouped_results(): void
{
$provider = $this->createMockProvider('pages', 'Pages', 'document', [
new SearchResult('1', 'Dashboard', '/hub', 'pages', 'house', 'Overview'),
new SearchResult('2', 'Settings', '/hub/settings', 'pages', 'gear', 'Preferences'),
]);
$this->registry->register($provider);
$results = $this->registry->search('dash', null, null);
$this->assertArrayHasKey('pages', $results);
$this->assertEquals('Pages', $results['pages']['label']);
$this->assertEquals('document', $results['pages']['icon']);
$this->assertCount(2, $results['pages']['results']);
}
public function test_search_respects_provider_availability(): void
{
$availableProvider = $this->createMockProvider('pages', 'Pages', 'document', [], true);
$unavailableProvider = $this->createMockProvider('admin', 'Admin', 'shield', [], false);
$this->registry->register($availableProvider);
$this->registry->register($unavailableProvider);
$available = $this->registry->availableProviders(null, null);
$this->assertCount(1, $available);
}
public function test_flatten_results(): void
{
$grouped = [
'pages' => [
'label' => 'Pages',
'icon' => 'document',
'results' => [
['id' => '1', 'title' => 'Dashboard'],
['id' => '2', 'title' => 'Settings'],
],
],
'users' => [
'label' => 'Users',
'icon' => 'user',
'results' => [
['id' => '3', 'title' => 'Admin'],
],
],
];
$flat = $this->registry->flattenResults($grouped);
$this->assertCount(3, $flat);
$this->assertEquals('Dashboard', $flat[0]['title']);
$this->assertEquals('Settings', $flat[1]['title']);
$this->assertEquals('Admin', $flat[2]['title']);
}
/**
* Create a mock search provider.
*/
protected function createMockProvider(
string $type,
string $label,
string $icon,
array $results = [],
bool $available = true
): SearchProvider {
return new class($type, $label, $icon, $results, $available) implements SearchProvider
{
use HasSearchProvider;
public function __construct(
protected string $type,
protected string $label,
protected string $icon,
protected array $results,
protected bool $available
) {}
public function searchType(): string
{
return $this->type;
}
public function searchLabel(): string
{
return $this->label;
}
public function searchIcon(): string
{
return $this->icon;
}
public function search(string $query, int $limit = 5): Collection
{
return collect($this->results)->take($limit);
}
public function getUrl(mixed $result): string
{
return $result['url'] ?? '#';
}
public function isAvailable(?object $user, ?object $workspace): bool
{
return $this->available;
}
};
}
}

View file

@ -0,0 +1,165 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Admin\Search\Tests;
use Core\Admin\Search\SearchResult;
use PHPUnit\Framework\TestCase;
class SearchResultTest extends TestCase
{
public function test_can_create_search_result(): void
{
$result = new SearchResult(
id: '123',
title: 'Dashboard',
url: '/hub',
type: 'pages',
icon: 'house',
subtitle: 'Overview and quick actions',
meta: ['key' => 'value'],
);
$this->assertEquals('123', $result->id);
$this->assertEquals('Dashboard', $result->title);
$this->assertEquals('/hub', $result->url);
$this->assertEquals('pages', $result->type);
$this->assertEquals('house', $result->icon);
$this->assertEquals('Overview and quick actions', $result->subtitle);
$this->assertEquals(['key' => 'value'], $result->meta);
}
public function test_can_create_from_array(): void
{
$data = [
'id' => '456',
'title' => 'Settings',
'url' => '/hub/settings',
'type' => 'pages',
'icon' => 'gear',
'subtitle' => 'Account settings',
'meta' => ['order' => 2],
];
$result = SearchResult::fromArray($data);
$this->assertEquals('456', $result->id);
$this->assertEquals('Settings', $result->title);
$this->assertEquals('/hub/settings', $result->url);
$this->assertEquals('pages', $result->type);
$this->assertEquals('gear', $result->icon);
$this->assertEquals('Account settings', $result->subtitle);
$this->assertEquals(['order' => 2], $result->meta);
}
public function test_from_array_with_missing_fields(): void
{
$data = [
'title' => 'Minimal',
];
$result = SearchResult::fromArray($data);
$this->assertNotEmpty($result->id); // Should generate an ID
$this->assertEquals('Minimal', $result->title);
$this->assertEquals('#', $result->url);
$this->assertEquals('unknown', $result->type);
$this->assertEquals('document', $result->icon);
$this->assertNull($result->subtitle);
$this->assertEquals([], $result->meta);
}
public function test_to_array(): void
{
$result = new SearchResult(
id: '789',
title: 'Test',
url: '/test',
type: 'test',
icon: 'test-icon',
subtitle: 'Test subtitle',
meta: ['foo' => 'bar'],
);
$array = $result->toArray();
$this->assertEquals([
'id' => '789',
'title' => 'Test',
'subtitle' => 'Test subtitle',
'url' => '/test',
'type' => 'test',
'icon' => 'test-icon',
'meta' => ['foo' => 'bar'],
], $array);
}
public function test_json_serialize(): void
{
$result = new SearchResult(
id: '1',
title: 'JSON Test',
url: '/json',
type: 'json',
icon: 'code',
);
$json = json_encode($result);
$decoded = json_decode($json, true);
$this->assertEquals('1', $decoded['id']);
$this->assertEquals('JSON Test', $decoded['title']);
$this->assertEquals('/json', $decoded['url']);
}
public function test_with_type_and_icon(): void
{
$original = new SearchResult(
id: '1',
title: 'Test',
url: '/test',
type: 'old-type',
icon: 'document', // Default icon
);
$modified = $original->withTypeAndIcon('new-type', 'new-icon');
// Original should be unchanged (immutable)
$this->assertEquals('old-type', $original->type);
$this->assertEquals('document', $original->icon);
// Modified should have new values
$this->assertEquals('new-type', $modified->type);
$this->assertEquals('new-icon', $modified->icon);
// Other properties should be preserved
$this->assertEquals('1', $modified->id);
$this->assertEquals('Test', $modified->title);
$this->assertEquals('/test', $modified->url);
}
public function test_with_type_and_icon_preserves_custom_icon(): void
{
$original = new SearchResult(
id: '1',
title: 'Test',
url: '/test',
type: 'old-type',
icon: 'custom-icon', // Not the default
);
$modified = $original->withTypeAndIcon('new-type', 'fallback-icon');
// Should keep the custom icon, not use the fallback
$this->assertEquals('custom-icon', $modified->icon);
$this->assertEquals('new-type', $modified->type);
}
}

195
src/Website/Hub/Boot.php Normal file
View file

@ -0,0 +1,195 @@
<?php
declare(strict_types=1);
namespace Website\Hub;
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;
use Core\Website\DomainResolver;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
/**
* Hub Website - Admin dashboard.
*
* The authenticated admin panel for managing workspaces.
* Uses the event-driven $listens pattern for lazy loading.
*/
class Boot extends ServiceProvider implements AdminMenuProvider
{
use HasMenuPermissions;
/**
* Domain patterns this website responds to.
* Listed separately so DomainResolver can expand them.
*
* @var array<string>
*/
public static array $domains = [
'/^core\.(test|localhost)$/',
'/^hub\.core\.(test|localhost)$/',
];
/**
* Events this module listens to for lazy loading.
*
* @var array<class-string, string>
*/
public static array $listens = [
DomainResolving::class => 'onDomainResolving',
AdminPanelBooting::class => 'onAdminPanel',
];
/**
* Handle domain resolution - register if we match.
*/
public function onDomainResolving(DomainResolving $event): void
{
foreach (static::$domains as $pattern) {
if ($event->matches($pattern)) {
$event->register(static::class);
return;
}
}
}
public function register(): void
{
//
}
/**
* Get domains for this website.
*
* @return array<string>
*/
protected function domains(): array
{
return app(DomainResolver::class)->domainsFor(self::class);
}
/**
* Register admin panel routes and components.
*/
public function onAdminPanel(AdminPanelBooting $event): void
{
$event->views('hub', __DIR__.'/View/Blade');
// Load translations (path should point to Lang folder, Laravel adds locale subdirectory)
$event->translations('hub', dirname(__DIR__, 2).'/Mod/Hub/Lang');
// 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);
// Register routes for configured domains
foreach ($this->domains() as $domain) {
$event->routes(fn () => Route::prefix('hub')
->name('hub.')
->domain($domain)
->group(__DIR__.'/Routes/admin.php'));
}
}
/**
* Provide admin menu items.
*/
public function adminMenuItems(): array
{
return [
// Dashboard - standalone group
[
'group' => 'dashboard',
'priority' => 10,
'item' => fn () => [
'label' => __('hub::hub.dashboard.title'),
'icon' => 'house',
'href' => route('hub.dashboard'),
'active' => request()->routeIs('hub.dashboard'),
],
],
// Workspaces
[
'group' => 'workspaces',
'priority' => 10,
'item' => fn () => [
'label' => __('hub::hub.workspaces.title'),
'icon' => 'folders',
'href' => route('hub.sites'),
'active' => request()->routeIs('hub.sites*'),
],
],
// Account - Profile
[
'group' => 'settings',
'priority' => 10,
'item' => fn () => [
'label' => __('hub::hub.quick_actions.profile.title'),
'icon' => 'user',
'href' => route('hub.account'),
'active' => request()->routeIs('hub.account') && ! request()->routeIs('hub.account.*'),
],
],
// Account - Settings
[
'group' => 'settings',
'priority' => 20,
'item' => fn () => [
'label' => __('hub::hub.settings.title'),
'icon' => 'gear',
'href' => route('hub.account.settings'),
'active' => request()->routeIs('hub.account.settings'),
],
],
// Account - Usage
[
'group' => 'settings',
'priority' => 30,
'item' => fn () => [
'label' => __('hub::hub.usage.title'),
'icon' => 'chart-pie',
'href' => route('hub.account.usage'),
'active' => request()->routeIs('hub.account.usage'),
],
],
// Admin - Platform (Hades only)
[
'group' => 'admin',
'priority' => 10,
'admin' => true,
'item' => fn () => [
'label' => 'Platform',
'icon' => 'server',
'href' => route('hub.platform'),
'active' => request()->routeIs('hub.platform*'),
],
],
// Admin - Services (Hades only)
[
'group' => 'admin',
'priority' => 20,
'admin' => true,
'item' => fn () => [
'label' => 'Services',
'icon' => 'puzzle-piece',
'href' => route('hub.admin.services'),
'active' => request()->routeIs('hub.admin.services'),
],
],
];
}
}

View file

@ -0,0 +1,74 @@
<?php
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| Host Hub Routes
|--------------------------------------------------------------------------
|
| Core routes for the Host Hub admin/customer panel.
| Note: The 'hub' prefix and 'hub.' name prefix are added by Boot.php
|
*/
Route::get('/', \Website\Hub\View\Modal\Admin\Dashboard::class)->name('dashboard');
Route::redirect('/dashboard', '/hub')->name('dashboard.redirect');
Route::get('/content/{workspace}/{type}', \Website\Hub\View\Modal\Admin\Content::class)->name('content')
->where('type', 'posts|pages|media');
Route::get('/content-manager/{workspace}/{view?}', \Website\Hub\View\Modal\Admin\ContentManager::class)->name('content-manager')
->where('view', 'dashboard|kanban|calendar|list|webhooks');
Route::get('/content-editor/{workspace}/new/{contentType?}', \Website\Hub\View\Modal\Admin\ContentEditor::class)->name('content-editor.create');
Route::get('/content-editor/{workspace}/{id}', \Website\Hub\View\Modal\Admin\ContentEditor::class)->name('content-editor.edit')
->where('id', '[0-9]+');
// /hub/workspaces redirects to current workspace settings (workspace switcher handles selection)
Route::get('/workspaces', \Website\Hub\View\Modal\Admin\Sites::class)->name('sites');
Route::redirect('/sites', '/hub/workspaces');
Route::get('/console', \Website\Hub\View\Modal\Admin\Console::class)->name('console');
Route::get('/databases', \Website\Hub\View\Modal\Admin\Databases::class)->name('databases');
// Account section
Route::get('/account', \Website\Hub\View\Modal\Admin\Profile::class)->name('account');
Route::get('/account/settings', \Website\Hub\View\Modal\Admin\Settings::class)->name('account.settings');
Route::get('/account/usage', \Website\Hub\View\Modal\Admin\AccountUsage::class)->name('account.usage');
Route::redirect('/profile', '/hub/account');
Route::redirect('/settings', '/hub/account/settings');
Route::redirect('/usage', '/hub/account/usage');
Route::redirect('/boosts', '/hub/account/usage?tab=boosts');
Route::redirect('/ai-services', '/hub/account/usage?tab=ai');
// Route::get('/config/{path?}', \Core\Config\View\Modal\Admin\WorkspaceConfig::class)
// ->where('path', '.*')
// ->name('workspace.config');
// Route::redirect('/workspace/config', '/hub/config');
Route::get('/workspaces/{workspace}/{tab?}', \Website\Hub\View\Modal\Admin\SiteSettings::class)
->where('tab', 'services|general|deployment|environment|ssl|backups|danger')
->name('sites.settings');
Route::get('/deployments', \Website\Hub\View\Modal\Admin\Deployments::class)->name('deployments');
Route::get('/platform', \Website\Hub\View\Modal\Admin\Platform::class)->name('platform');
Route::get('/platform/user/{id}', \Website\Hub\View\Modal\Admin\PlatformUser::class)->name('platform.user')
->where('id', '[0-9]+');
Route::get('/prompts', \Website\Hub\View\Modal\Admin\PromptManager::class)->name('prompts');
// Entitlement management (admin only)
Route::get('/entitlements', \Website\Hub\View\Modal\Admin\Entitlement\Dashboard::class)->name('entitlements');
Route::get('/entitlements/packages', \Website\Hub\View\Modal\Admin\Entitlement\PackageManager::class)->name('entitlements.packages');
Route::get('/entitlements/features', \Website\Hub\View\Modal\Admin\Entitlement\FeatureManager::class)->name('entitlements.features');
// Waitlist management (admin only - Hades tier)
Route::get('/admin/waitlist', \Website\Hub\View\Modal\Admin\WaitlistManager::class)->name('admin.waitlist');
// Workspace management (admin only - Hades tier)
// Route::get('/admin/workspaces', \Core\Mod\Tenant\View\Modal\Admin\WorkspaceManager::class)->name('admin.workspaces');
// Route::get('/admin/workspaces/{id}', \Core\Mod\Tenant\View\Modal\Admin\WorkspaceDetails::class)->name('admin.workspaces.details')
// ->where('id', '[0-9]+');
// Service management (admin only - Hades tier)
Route::get('/admin/services', \Website\Hub\View\Modal\Admin\ServiceManager::class)->name('admin.services');
// Services - workspace admin for Bio, Social, Analytics, Notify, Trust, Support, Commerce
Route::get('/services/{service?}/{tab?}', \Website\Hub\View\Modal\Admin\ServicesAdmin::class)
->where('service', 'bio|social|analytics|notify|trust|support|commerce')
->where('tab', 'dashboard|pages|channels|projects|accounts|posts|websites|goals|subscribers|campaigns|notifications|inbox|settings|orders|subscriptions|coupons')
->name('services');
// Security - Honeypot monitoring
Route::get('/honeypot', \Website\Hub\View\Modal\Admin\Honeypot::class)->name('honeypot');

View file

@ -0,0 +1,691 @@
<div>
<admin:page-header title="Usage & Billing" description="Monitor your usage, manage boosts, and configure AI services." />
{{-- Card with sidebar --}}
<div class="bg-white dark:bg-gray-800 shadow-xs rounded-xl">
<div class="flex flex-col md:flex-row md:-mr-px">
{{-- Sidebar navigation --}}
<div class="flex flex-nowrap overflow-x-scroll no-scrollbar md:block md:overflow-auto px-3 py-6 border-b md:border-b-0 md:border-r border-gray-200 dark:border-gray-700/60 min-w-60 md:space-y-3">
{{-- Usage group --}}
<div>
<div class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase mb-3 hidden md:block">Usage</div>
<ul class="flex flex-nowrap md:block mr-3 md:mr-0">
<li class="mr-0.5 md:mr-0 md:mb-0.5">
<button
wire:click="$set('activeSection', 'overview')"
@class([
'flex items-center px-2.5 py-2 rounded-lg whitespace-nowrap w-full text-left',
'bg-gradient-to-r from-violet-500/[0.12] dark:from-violet-500/[0.24] to-violet-500/[0.04]' => $activeSection === 'overview',
])
>
<core:icon name="chart-pie" @class(['shrink-0 mr-2', 'text-violet-400' => $activeSection === 'overview', 'text-gray-400 dark:text-gray-500' => $activeSection !== 'overview']) />
<span class="text-sm font-medium {{ $activeSection === 'overview' ? 'text-violet-500 dark:text-violet-400' : 'text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200' }}">Overview</span>
</button>
</li>
<li class="mr-0.5 md:mr-0 md:mb-0.5">
<button
wire:click="$set('activeSection', 'workspaces')"
@class([
'flex items-center px-2.5 py-2 rounded-lg whitespace-nowrap w-full text-left',
'bg-gradient-to-r from-violet-500/[0.12] dark:from-violet-500/[0.24] to-violet-500/[0.04]' => $activeSection === 'workspaces',
])
>
<core:icon name="buildings" @class(['shrink-0 mr-2', 'text-violet-400' => $activeSection === 'workspaces', 'text-gray-400 dark:text-gray-500' => $activeSection !== 'workspaces']) />
<span class="text-sm font-medium {{ $activeSection === 'workspaces' ? 'text-violet-500 dark:text-violet-400' : 'text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200' }}">Workspaces</span>
</button>
</li>
<li class="mr-0.5 md:mr-0 md:mb-0.5">
<button
wire:click="$set('activeSection', 'entitlements')"
@class([
'flex items-center px-2.5 py-2 rounded-lg whitespace-nowrap w-full text-left',
'bg-gradient-to-r from-violet-500/[0.12] dark:from-violet-500/[0.24] to-violet-500/[0.04]' => $activeSection === 'entitlements',
])
>
<core:icon name="key" @class(['shrink-0 mr-2', 'text-violet-400' => $activeSection === 'entitlements', 'text-gray-400 dark:text-gray-500' => $activeSection !== 'entitlements']) />
<span class="text-sm font-medium {{ $activeSection === 'entitlements' ? 'text-violet-500 dark:text-violet-400' : 'text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200' }}">Entitlements</span>
</button>
</li>
<li class="mr-0.5 md:mr-0 md:mb-0.5">
<button
wire:click="$set('activeSection', 'boosts')"
@class([
'flex items-center px-2.5 py-2 rounded-lg whitespace-nowrap w-full text-left',
'bg-gradient-to-r from-violet-500/[0.12] dark:from-violet-500/[0.24] to-violet-500/[0.04]' => $activeSection === 'boosts',
])
>
<core:icon name="bolt" @class(['shrink-0 mr-2', 'text-violet-400' => $activeSection === 'boosts', 'text-gray-400 dark:text-gray-500' => $activeSection !== 'boosts']) />
<span class="text-sm font-medium {{ $activeSection === 'boosts' ? 'text-violet-500 dark:text-violet-400' : 'text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200' }}">Boosts</span>
</button>
</li>
</ul>
</div>
{{-- Integrations group --}}
<div class="md:mt-6">
<div class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase mb-3 hidden md:block">Integrations</div>
<ul class="flex flex-nowrap md:block mr-3 md:mr-0">
<li class="mr-0.5 md:mr-0 md:mb-0.5">
<button
wire:click="$set('activeSection', 'ai')"
@class([
'flex items-center px-2.5 py-2 rounded-lg whitespace-nowrap w-full text-left',
'bg-gradient-to-r from-violet-500/[0.12] dark:from-violet-500/[0.24] to-violet-500/[0.04]' => $activeSection === 'ai',
])
>
<core:icon name="microchip" @class(['shrink-0 mr-2', 'text-violet-400' => $activeSection === 'ai', 'text-gray-400 dark:text-gray-500' => $activeSection !== 'ai']) />
<span class="text-sm font-medium {{ $activeSection === 'ai' ? 'text-violet-500 dark:text-violet-400' : 'text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200' }}">AI Services</span>
</button>
</li>
</ul>
</div>
</div>
{{-- Content panel --}}
<div class="grow p-6">
{{-- Overview Section --}}
@if($activeSection === 'overview')
<div class="space-y-6">
<div>
<h2 class="text-2xl text-gray-800 dark:text-gray-100 font-bold mb-1">Usage Overview</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-5">Monitor your current usage and limits.</p>
</div>
{{-- Active Packages --}}
<div>
<h3 class="font-semibold text-gray-800 dark:text-gray-100 mb-3">Active Packages</h3>
@if(empty($activePackages))
<div class="text-center py-6 text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<core:icon name="box" class="size-6 mx-auto mb-2 opacity-50" />
<p class="text-sm">No active packages</p>
</div>
@else
<div class="grid gap-3 sm:grid-cols-2">
@foreach($activePackages as $workspacePackage)
<div class="flex items-start gap-3 p-3 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
@if($workspacePackage['package']['icon'] ?? null)
<div class="shrink-0 w-8 h-8 rounded-lg bg-{{ $workspacePackage['package']['color'] ?? 'blue' }}-500/10 flex items-center justify-center">
<core:icon :name="$workspacePackage['package']['icon']" class="size-4 text-{{ $workspacePackage['package']['color'] ?? 'blue' }}-500" />
</div>
@endif
<div class="flex-1 min-w-0">
<p class="font-medium text-gray-900 dark:text-gray-100 text-sm">{{ $workspacePackage['package']['name'] ?? 'Unknown' }}</p>
<div class="flex items-center gap-2 mt-1">
@if($workspacePackage['package']['is_base_package'] ?? false)
<flux:badge size="sm" color="purple">Base</flux:badge>
@else
<flux:badge size="sm" color="blue">Addon</flux:badge>
@endif
<flux:badge size="sm" color="green">Active</flux:badge>
</div>
</div>
</div>
@endforeach
</div>
@endif
</div>
{{-- Usage by Category - Accordion --}}
@if(!empty($usageSummary))
<flux:accordion transition class="space-y-2">
@foreach($usageSummary as $category => $features)
@php
$categoryIcon = match($category) {
'social' => 'share-nodes',
'bio', 'biolink' => 'link',
'analytics' => 'chart-line',
'notify' => 'bell',
'trust' => 'shield-check',
'support' => 'headset',
'ai' => 'microchip',
'mcp', 'api' => 'plug',
'host', 'service' => 'server',
default => 'cubes',
};
$categoryColor = match($category) {
'social' => 'pink',
'bio', 'biolink' => 'emerald',
'analytics' => 'blue',
'notify' => 'amber',
'trust' => 'green',
'support' => 'violet',
'ai' => 'purple',
'mcp', 'api' => 'indigo',
'host', 'service' => 'sky',
default => 'gray',
};
$allowedCount = collect($features)->where('allowed', true)->count();
$totalCount = count($features);
@endphp
<flux:accordion.item class="bg-gray-50 dark:bg-gray-700/30 rounded-lg !border-0">
<flux:accordion.heading>
<div class="flex items-center justify-between w-full pr-2">
<div class="flex items-center gap-2">
<span class="w-7 h-7 rounded-md bg-{{ $categoryColor }}-500/10 flex items-center justify-center">
<core:icon :name="$categoryIcon" class="text-{{ $categoryColor }}-500 text-sm" />
</span>
<span class="capitalize">{{ $category ?? 'General' }}</span>
</div>
<flux:badge size="sm" :color="$allowedCount > 0 ? 'green' : 'zinc'">
{{ $allowedCount }}/{{ $totalCount }}
</flux:badge>
</div>
</flux:accordion.heading>
<flux:accordion.content>
<div class="space-y-1 pt-2">
@foreach($features as $feature)
<div class="flex items-center justify-between py-1.5 px-1 rounded hover:bg-gray-100 dark:hover:bg-gray-600/30">
<span class="text-sm {{ $feature['allowed'] ? 'text-gray-700 dark:text-gray-300' : 'text-gray-400 dark:text-gray-500' }}">{{ $feature['name'] }}</span>
@if(!$feature['allowed'])
<flux:badge size="sm" color="zinc">Not included</flux:badge>
@elseif($feature['unlimited'])
<flux:badge size="sm" color="purple">Unlimited</flux:badge>
@elseif($feature['type'] === 'limit' && isset($feature['limit']))
@php
$percentage = min($feature['percentage'] ?? 0, 100);
$badgeColor = match(true) {
$percentage >= 90 => 'red',
$percentage >= 75 => 'amber',
default => 'green',
};
@endphp
<flux:badge size="sm" :color="$badgeColor">{{ $feature['used'] }}/{{ $feature['limit'] }}</flux:badge>
@elseif($feature['type'] === 'boolean')
<flux:badge size="sm" color="green" icon="check">Active</flux:badge>
@endif
</div>
@endforeach
</div>
</flux:accordion.content>
</flux:accordion.item>
@endforeach
</flux:accordion>
@else
<div class="text-center py-6 text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<core:icon name="chart-bar" class="size-6 mx-auto mb-2 opacity-50" />
<p class="text-sm">No usage data available</p>
</div>
@endif
{{-- Active Boosts --}}
@if(!empty($activeBoosts))
<div>
<h3 class="font-semibold text-gray-800 dark:text-gray-100 mb-3">Active Boosts</h3>
<div class="space-y-2">
@foreach($activeBoosts as $boost)
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $boost['feature_code'] }}</span>
<div class="flex items-center gap-2 mt-1">
@switch($boost['boost_type'])
@case('add_limit')
<flux:badge size="sm" color="blue">+{{ number_format($boost['limit_value']) }}</flux:badge>
@break
@case('unlimited')
<flux:badge size="sm" color="purple">Unlimited</flux:badge>
@break
@case('enable')
<flux:badge size="sm" color="green">Enabled</flux:badge>
@break
@endswitch
</div>
</div>
@if($boost['boost_type'] === 'add_limit' && $boost['limit_value'])
<div class="text-right">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">{{ number_format($boost['remaining_limit'] ?? $boost['limit_value']) }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400 block">remaining</span>
</div>
@endif
</div>
@endforeach
</div>
</div>
@endif
</div>
@endif
{{-- Workspaces Section --}}
@if($activeSection === 'workspaces')
<div class="space-y-6">
<div>
<h2 class="text-2xl text-gray-800 dark:text-gray-100 font-bold mb-1">Workspaces</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-5">View all your workspaces and their subscription details.</p>
</div>
@php $workspaces = $this->userWorkspaces; @endphp
@if(count($workspaces) > 0)
{{-- Cost Summary --}}
@php
$totalMonthly = collect($workspaces)->sum('price');
$activeCount = collect($workspaces)->where('status', 'active')->count();
@endphp
<div class="grid gap-4 sm:grid-cols-3">
<div class="bg-gray-50 dark:bg-gray-700/30 rounded-lg p-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-green-500/10 flex items-center justify-center">
<core:icon name="sterling-sign" class="text-green-500" />
</div>
<div>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">£{{ number_format($totalMonthly, 2) }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Monthly total</p>
</div>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700/30 rounded-lg p-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
<core:icon name="buildings" class="text-blue-500" />
</div>
<div>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ count($workspaces) }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Total workspaces</p>
</div>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700/30 rounded-lg p-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-violet-500/10 flex items-center justify-center">
<core:icon name="circle-check" class="text-violet-500" />
</div>
<div>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ $activeCount }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Active subscriptions</p>
</div>
</div>
</div>
</div>
{{-- Workspace List --}}
<div class="space-y-4">
@foreach($workspaces as $ws)
<div class="bg-gray-50 dark:bg-gray-700/30 rounded-lg p-4">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-lg bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center text-white font-bold text-lg">
{{ strtoupper(substr($ws['workspace']->name, 0, 2)) }}
</div>
<div>
<h3 class="font-medium text-gray-900 dark:text-gray-100">{{ $ws['workspace']->name }}</h3>
<div class="flex items-center gap-2 mt-1">
<flux:badge size="sm" :color="$ws['status'] === 'active' ? 'green' : 'zinc'">
{{ ucfirst($ws['status']) }}
</flux:badge>
<span class="text-sm text-gray-500 dark:text-gray-400">{{ $ws['plan'] }}</span>
</div>
</div>
</div>
<div class="flex items-center gap-4 sm:text-right">
<div>
@if($ws['price'] > 0)
<p class="font-semibold text-gray-900 dark:text-gray-100">£{{ number_format($ws['price'], 2) }}/mo</p>
@else
<p class="font-semibold text-gray-500 dark:text-gray-400">Free</p>
@endif
@if($ws['renewsAt'])
<p class="text-xs text-gray-500 dark:text-gray-400">
Renews {{ $ws['renewsAt']->format('j M Y') }}
</p>
@endif
</div>
<div class="flex items-center gap-2">
<a href="{{ route('hub.sites.settings', $ws['workspace']->slug) }}" class="text-violet-500 hover:text-violet-600">
<core:icon name="gear" />
</a>
</div>
</div>
</div>
@if($ws['serviceCount'] > 0)
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-600">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">Active Services</p>
<div class="flex flex-wrap gap-2">
@foreach($ws['services'] as $service)
<a href="{{ $service['href'] ?? '#' }}" class="inline-flex items-center px-2 py-1 bg-white dark:bg-gray-800 rounded text-xs text-gray-600 dark:text-gray-300 hover:text-violet-500 transition-colors">
<core:icon :name="$service['icon']" class="mr-1 text-{{ $service['color'] ?? 'gray' }}-500" size="fa-sm" />
{{ $service['label'] }}
</a>
@endforeach
</div>
</div>
@endif
</div>
@endforeach
</div>
@else
<div class="text-center py-8 text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<core:icon name="buildings" class="size-8 mx-auto mb-2 opacity-50" />
<p>No workspaces found</p>
<p class="text-sm mt-1">Create a workspace to get started.</p>
</div>
@endif
</div>
@endif
{{-- Entitlements Section --}}
@if($activeSection === 'entitlements')
<div class="space-y-6">
<div>
<h2 class="text-2xl text-gray-800 dark:text-gray-100 font-bold mb-1">Entitlements</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-5">View all available features and your current access levels.</p>
</div>
@forelse($this->allFeatures as $category => $features)
<div>
<h3 class="font-semibold text-gray-800 dark:text-gray-100 mb-3 capitalize flex items-center">
@php
$categoryIcon = match($category) {
'social' => 'share-nodes',
'bio' => 'link',
'analytics' => 'chart-line',
'notify' => 'bell',
'trust' => 'shield-check',
'support' => 'headset',
'ai' => 'microchip',
'mcp' => 'plug',
default => 'cubes',
};
$categoryColor = match($category) {
'social' => 'pink',
'bio' => 'emerald',
'analytics' => 'blue',
'notify' => 'amber',
'trust' => 'green',
'support' => 'violet',
'ai' => 'purple',
'mcp' => 'indigo',
default => 'gray',
};
@endphp
<span class="w-6 h-6 rounded bg-{{ $categoryColor }}-500/10 flex items-center justify-center mr-2">
<core:icon :name="$categoryIcon" class="text-{{ $categoryColor }}-500 text-xs" />
</span>
{{ $category ?? 'General' }}
</h3>
<div class="bg-gray-50 dark:bg-gray-700/30 rounded-lg overflow-hidden">
<table class="w-full">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-600">
<th class="text-left text-xs font-medium text-gray-500 dark:text-gray-400 px-4 py-2">Feature</th>
<th class="text-left text-xs font-medium text-gray-500 dark:text-gray-400 px-4 py-2">Code</th>
<th class="text-left text-xs font-medium text-gray-500 dark:text-gray-400 px-4 py-2">Type</th>
<th class="text-right text-xs font-medium text-gray-500 dark:text-gray-400 px-4 py-2">Your Access</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-600">
@foreach($features as $feature)
@php
$workspace = auth()->user()?->defaultHostWorkspace();
$check = $workspace ? app(\Core\Mod\Tenant\Services\EntitlementService::class)->can($workspace, $feature['code']) : null;
$allowed = $check?->isAllowed() ?? false;
$limit = $check?->effectiveLimit ?? null;
$unlimited = $check?->isUnlimited ?? false;
@endphp
<tr class="hover:bg-gray-100 dark:hover:bg-gray-700/50">
<td class="px-4 py-2">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $feature['name'] }}</span>
@if($feature['description'] ?? null)
<p class="text-xs text-gray-500 dark:text-gray-400">{{ Str::limit($feature['description'], 50) }}</p>
@endif
</td>
<td class="px-4 py-2">
<code class="text-xs bg-gray-200 dark:bg-gray-600 px-1.5 py-0.5 rounded">{{ $feature['code'] }}</code>
</td>
<td class="px-4 py-2">
<flux:badge size="sm" :color="$feature['type'] === 'limit' ? 'blue' : 'purple'">
{{ ucfirst($feature['type']) }}
</flux:badge>
</td>
<td class="px-4 py-2 text-right">
@if(!$allowed)
<flux:badge size="sm" color="zinc">Not included</flux:badge>
@elseif($unlimited)
<flux:badge size="sm" color="purple">Unlimited</flux:badge>
@elseif($feature['type'] === 'boolean')
<flux:badge size="sm" color="green">Enabled</flux:badge>
@elseif($limit !== null)
<flux:badge size="sm" color="blue">{{ number_format($limit) }}</flux:badge>
@else
<flux:badge size="sm" color="green">Enabled</flux:badge>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@empty
<div class="text-center py-8 text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<core:icon name="key" class="size-8 mx-auto mb-2 opacity-50" />
<p>No features defined</p>
</div>
@endforelse
{{-- Upgrade prompt --}}
@if(!auth()->user()?->isHades())
<div class="bg-gradient-to-r from-violet-500/10 to-purple-500/10 border border-violet-500/20 rounded-lg p-4">
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-gray-800 dark:text-gray-100">Need more access?</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Upgrade your plan to unlock additional features and higher limits.</p>
</div>
<a href="{{ route('pricing') }}" class="inline-flex items-center px-4 py-2 bg-violet-600 hover:bg-violet-700 text-white rounded-lg transition-colors text-sm font-medium">
View Plans
</a>
</div>
</div>
@endif
</div>
@endif
{{-- Boosts Section --}}
@if($activeSection === 'boosts')
<div class="space-y-6">
<div>
<h2 class="text-2xl text-gray-800 dark:text-gray-100 font-bold mb-1">Purchase Boosts</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-5">Add extra capacity to your account.</p>
</div>
@if(count($boostOptions) > 0)
<div class="grid gap-4 sm:grid-cols-2">
@foreach($boostOptions as $boost)
<div class="bg-gray-50 dark:bg-gray-700/30 rounded-lg p-4">
<div class="flex items-start justify-between mb-3">
<div>
<h3 class="font-medium text-gray-900 dark:text-gray-100">{{ $boost['feature_name'] }}</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ $boost['description'] }}</p>
</div>
@switch($boost['boost_type'])
@case('add_limit')
<flux:badge color="blue">+{{ number_format($boost['limit_value']) }}</flux:badge>
@break
@case('unlimited')
<flux:badge color="purple">Unlimited</flux:badge>
@break
@case('enable')
<flux:badge color="green">Enable</flux:badge>
@break
@endswitch
</div>
<div class="flex items-center justify-between pt-3 border-t border-gray-200 dark:border-gray-600">
<div class="flex items-center text-xs text-gray-500 dark:text-gray-400">
@switch($boost['duration_type'])
@case('cycle_bound')
<core:icon name="clock" class="size-3 mr-1" /> Billing cycle
@break
@case('duration')
<core:icon name="calendar" class="size-3 mr-1" /> Limited time
@break
@case('permanent')
<core:icon name="infinity" class="size-3 mr-1" /> Permanent
@break
@endswitch
</div>
<flux:button wire:click="purchaseBoost('{{ $boost['blesta_id'] }}')" size="sm" variant="primary">
Purchase
</flux:button>
</div>
</div>
@endforeach
</div>
@else
<div class="text-center py-8 text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<core:icon name="rocket" class="size-8 mx-auto mb-2 opacity-50" />
<p>No boosts available</p>
<p class="text-sm mt-1">Check back later for available boosts.</p>
</div>
@endif
{{-- Info box --}}
<div class="bg-blue-500/10 dark:bg-blue-500/20 rounded-lg p-4">
<h4 class="font-medium text-blue-900 dark:text-blue-100 mb-2 flex items-center">
<core:icon name="circle-info" class="size-4 mr-2" /> About Boosts
</h4>
<ul class="text-sm text-blue-800 dark:text-blue-200 space-y-1 ml-6">
<li><strong>Billing cycle:</strong> Resets with your billing period</li>
<li><strong>Limited time:</strong> Expires after a set duration</li>
<li><strong>Permanent:</strong> Never expires</li>
</ul>
</div>
</div>
@endif
{{-- AI Services Section --}}
@if($activeSection === 'ai')
<div class="space-y-6">
<div>
<h2 class="text-2xl text-gray-800 dark:text-gray-100 font-bold mb-1">AI Services</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-5">Configure your AI provider API keys.</p>
</div>
{{-- AI Provider Tabs --}}
<div class="border-b border-gray-200 dark:border-gray-700">
<nav class="flex space-x-4">
<button
wire:click="$set('activeAiTab', 'claude')"
class="pb-3 px-1 border-b-2 font-medium text-sm transition-colors {{ $activeAiTab === 'claude' ? 'border-violet-500 text-violet-600 dark:text-violet-400' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300' }}"
>
<span class="flex items-center">
<svg class="w-4 h-4 mr-2 text-[#D97757]" viewBox="0 0 24 24" fill="currentColor">
<path d="M13.827 3.52c-.592-1.476-2.672-1.476-3.264 0L5.347 16.756c-.464 1.16.464 2.404 1.632 2.404h3.264l1.632-4.068h.25l1.632 4.068h3.264c1.168 0 2.096-1.244 1.632-2.404L13.827 3.52zM12 11.636l-1.224 3.048h2.448L12 11.636z"/>
</svg>
Claude
</span>
</button>
<button
wire:click="$set('activeAiTab', 'gemini')"
class="pb-3 px-1 border-b-2 font-medium text-sm transition-colors {{ $activeAiTab === 'gemini' ? 'border-violet-500 text-violet-600 dark:text-violet-400' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300' }}"
>
<span class="flex items-center">
<svg class="w-4 h-4 mr-2" viewBox="0 0 24 24">
<defs>
<linearGradient id="gemini-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#4285F4"/>
<stop offset="50%" style="stop-color:#9B72CB"/>
<stop offset="100%" style="stop-color:#D96570"/>
</linearGradient>
</defs>
<path fill="url(#gemini-grad)" d="M12 2C12 2 12.5 7 15.5 10C18.5 13 24 12 24 12C24 12 18.5 13 15.5 16C12.5 19 12 24 12 24C12 24 11.5 19 8.5 16C5.5 13 0 12 0 12C0 12 5.5 11 8.5 8C11.5 5 12 2 12 2Z"/>
</svg>
Gemini
</span>
</button>
<button
wire:click="$set('activeAiTab', 'openai')"
class="pb-3 px-1 border-b-2 font-medium text-sm transition-colors {{ $activeAiTab === 'openai' ? 'border-violet-500 text-violet-600 dark:text-violet-400' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300' }}"
>
<span class="flex items-center">
<svg class="w-4 h-4 mr-2 text-[#10A37F]" viewBox="0 0 24 24" fill="currentColor">
<path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494z"/>
</svg>
OpenAI
</span>
</button>
</nav>
</div>
{{-- Claude Panel --}}
@if($activeAiTab === 'claude')
<form wire:submit="saveClaude" class="space-y-4">
<flux:field>
<flux:label>API Key</flux:label>
<flux:input wire:model="claudeApiKey" type="password" placeholder="sk-ant-..." />
<flux:description>
<a href="https://console.anthropic.com/settings/keys" target="_blank" class="text-violet-500 hover:text-violet-600">Get your API key from Anthropic</a>
</flux:description>
<flux:error name="claudeApiKey" />
</flux:field>
<flux:field>
<flux:label>Model</flux:label>
<flux:select wire:model="claudeModel">
@foreach($this->claudeModelsComputed as $value => $label)
<flux:select.option value="{{ $value }}">{{ $label }}</flux:select.option>
@endforeach
</flux:select>
</flux:field>
<flux:checkbox wire:model="claudeActive" label="Enable Claude" />
<div class="flex justify-end pt-2">
<flux:button type="submit" variant="primary">Save Claude Settings</flux:button>
</div>
</form>
@endif
{{-- Gemini Panel --}}
@if($activeAiTab === 'gemini')
<form wire:submit="saveGemini" class="space-y-4">
<flux:field>
<flux:label>API Key</flux:label>
<flux:input wire:model="geminiApiKey" type="password" placeholder="AIza..." />
<flux:description>
<a href="https://aistudio.google.com/app/apikey" target="_blank" class="text-violet-500 hover:text-violet-600">Get your API key from Google AI Studio</a>
</flux:description>
<flux:error name="geminiApiKey" />
</flux:field>
<flux:field>
<flux:label>Model</flux:label>
<flux:select wire:model="geminiModel">
@foreach($this->geminiModelsComputed as $value => $label)
<flux:select.option value="{{ $value }}">{{ $label }}</flux:select.option>
@endforeach
</flux:select>
</flux:field>
<flux:checkbox wire:model="geminiActive" label="Enable Gemini" />
<div class="flex justify-end pt-2">
<flux:button type="submit" variant="primary">Save Gemini Settings</flux:button>
</div>
</form>
@endif
{{-- OpenAI Panel --}}
@if($activeAiTab === 'openai')
<form wire:submit="saveOpenAI" class="space-y-4">
<flux:field>
<flux:label>Secret Key</flux:label>
<flux:input wire:model="openaiSecretKey" type="password" placeholder="sk-..." />
<flux:description>
<a href="https://platform.openai.com/api-keys" target="_blank" class="text-violet-500 hover:text-violet-600">Get your API key from OpenAI</a>
</flux:description>
<flux:error name="openaiSecretKey" />
</flux:field>
<flux:checkbox wire:model="openaiActive" label="Enable OpenAI" />
<div class="flex justify-end pt-2">
<flux:button type="submit" variant="primary">Save OpenAI Settings</flux:button>
</div>
</form>
@endif
</div>
@endif
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,19 @@
<admin:module title="Activity log" subtitle="View recent activity in your workspace">
<admin:filter-bar cols="4">
<admin:search model="search" placeholder="Search activities..." />
@if(count($this->logNames) > 0)
<admin:filter model="logName" :options="$this->logNameOptions" />
@endif
@if(count($this->events) > 0)
<admin:filter model="event" :options="$this->eventOptions" />
@endif
<admin:clear-filters :show="$search || $logName || $event" />
</admin:filter-bar>
<admin:activity-log
:items="$this->activityItems"
:pagination="$this->activities"
empty="No activity recorded yet."
emptyIcon="clock"
/>
</admin:module>

View file

@ -0,0 +1,316 @@
<div>
<!-- Page header -->
<div class="sm:flex sm:justify-between sm:items-center mb-8">
<div class="mb-4 sm:mb-0">
<h1 class="text-2xl md:text-3xl text-gray-800 dark:text-gray-100 font-bold">{{ __('hub::hub.ai_services.title') }}</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ __('hub::hub.ai_services.subtitle') }}</p>
</div>
</div>
<!-- Success message -->
@if($savedMessage)
<div
x-data="{ show: true }"
x-show="show"
x-init="setTimeout(() => show = false, 3000)"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="mb-6 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg"
>
<div class="flex items-center">
<core:icon name="check-circle" class="text-green-500 mr-2" />
<span class="text-green-700 dark:text-green-400 text-sm font-medium">{{ $savedMessage }}</span>
</div>
</div>
@endif
<!-- Tabs -->
<div class="mb-6">
<nav class="flex space-x-4 border-b border-gray-200 dark:border-gray-700">
<button
wire:click="$set('activeTab', 'claude')"
class="pb-4 px-1 border-b-2 font-medium text-sm transition-colors {{ $activeTab === 'claude' ? 'border-violet-500 text-violet-600 dark:text-violet-400' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300' }}"
>
<span class="flex items-center">
<svg class="w-5 h-5 mr-2 text-[#D97757]" viewBox="0 0 24 24" fill="currentColor">
<path d="M13.827 3.52c-.592-1.476-2.672-1.476-3.264 0L5.347 16.756c-.464 1.16.464 2.404 1.632 2.404h3.264l1.632-4.068h.25l1.632 4.068h3.264c1.168 0 2.096-1.244 1.632-2.404L13.827 3.52zM12 11.636l-1.224 3.048h2.448L12 11.636z"/>
</svg>
{{ __('hub::hub.ai_services.providers.claude.name') }}
</span>
</button>
<button
wire:click="$set('activeTab', 'gemini')"
class="pb-4 px-1 border-b-2 font-medium text-sm transition-colors {{ $activeTab === 'gemini' ? 'border-violet-500 text-violet-600 dark:text-violet-400' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300' }}"
>
<span class="flex items-center">
<svg class="w-5 h-5 mr-2" viewBox="0 0 24 24">
<defs>
<linearGradient id="gemini-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#4285F4"/>
<stop offset="50%" style="stop-color:#9B72CB"/>
<stop offset="100%" style="stop-color:#D96570"/>
</linearGradient>
</defs>
<path fill="url(#gemini-gradient)" d="M12 2C12 2 12.5 7 15.5 10C18.5 13 24 12 24 12C24 12 18.5 13 15.5 16C12.5 19 12 24 12 24C12 24 11.5 19 8.5 16C5.5 13 0 12 0 12C0 12 5.5 11 8.5 8C11.5 5 12 2 12 2Z"/>
</svg>
{{ __('hub::hub.ai_services.providers.gemini.name') }}
</span>
</button>
<button
wire:click="$set('activeTab', 'openai')"
class="pb-4 px-1 border-b-2 font-medium text-sm transition-colors {{ $activeTab === 'openai' ? 'border-violet-500 text-violet-600 dark:text-violet-400' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300' }}"
>
<span class="flex items-center">
<svg class="w-5 h-5 mr-2 text-[#10A37F]" viewBox="0 0 24 24" fill="currentColor">
<path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855l-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.795.795 0 0 0-.393.681zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z"/>
</svg>
{{ __('hub::hub.ai_services.providers.openai.name') }}
</span>
</button>
</nav>
</div>
<!-- Claude Panel -->
@if($activeTab === 'claude')
<div class="bg-white dark:bg-gray-800 shadow-xs rounded-xl p-6">
<div class="flex items-center mb-4">
<svg class="w-8 h-8 mr-3 text-[#D97757]" viewBox="0 0 24 24" fill="currentColor">
<path d="M13.827 3.52c-.592-1.476-2.672-1.476-3.264 0L5.347 16.756c-.464 1.16.464 2.404 1.632 2.404h3.264l1.632-4.068h.25l1.632 4.068h3.264c1.168 0 2.096-1.244 1.632-2.404L13.827 3.52zM12 11.636l-1.224 3.048h2.448L12 11.636z"/>
</svg>
<div>
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">{{ __('hub::hub.ai_services.providers.claude.title') }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
<a href="https://console.anthropic.com/settings/keys" target="_blank" class="text-violet-500 hover:text-violet-600">
{{ __('hub::hub.ai_services.providers.claude.api_key_link') }}
</a>
</p>
</div>
</div>
<form wire:submit="saveClaude" class="space-y-6">
<!-- API Key -->
<div>
<label for="claude-api-key" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ __('hub::hub.ai_services.labels.api_key') }} <span class="text-red-500">*</span>
</label>
<input
wire:model="claudeApiKey"
type="password"
id="claude-api-key"
placeholder="sk-ant-..."
autocomplete="new-password"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-violet-500 focus:border-violet-500"
/>
@error('claudeApiKey')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<!-- Model -->
<div>
<label for="claude-model" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ __('hub::hub.ai_services.labels.model') }}
</label>
<select
wire:model="claudeModel"
id="claude-model"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-violet-500 focus:border-violet-500"
>
@foreach($this->claudeModels as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
@error('claudeModel')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<!-- Active -->
<div class="flex items-center">
<input
wire:model="claudeActive"
type="checkbox"
id="claude-active"
class="w-4 h-4 text-violet-600 bg-gray-100 border-gray-300 rounded focus:ring-violet-500 dark:focus:ring-violet-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
/>
<label for="claude-active" class="ml-2 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ __('hub::hub.ai_services.labels.active') }}
</label>
</div>
<button
type="submit"
class="inline-flex items-center px-4 py-2 bg-violet-600 hover:bg-violet-700 text-white font-medium rounded-lg transition-colors"
>
<span wire:loading.remove wire:target="saveClaude">{{ __('hub::hub.ai_services.labels.save') }}</span>
<span wire:loading wire:target="saveClaude" class="flex items-center">
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" 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>
{{ __('hub::hub.ai_services.labels.saving') }}
</span>
</button>
</form>
</div>
@endif
<!-- Gemini Panel -->
@if($activeTab === 'gemini')
<div class="bg-white dark:bg-gray-800 shadow-xs rounded-xl p-6">
<div class="flex items-center mb-4">
<svg class="w-8 h-8 mr-3" viewBox="0 0 24 24">
<defs>
<linearGradient id="gemini-gradient-panel" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#4285F4"/>
<stop offset="50%" style="stop-color:#9B72CB"/>
<stop offset="100%" style="stop-color:#D96570"/>
</linearGradient>
</defs>
<path fill="url(#gemini-gradient-panel)" d="M12 2C12 2 12.5 7 15.5 10C18.5 13 24 12 24 12C24 12 18.5 13 15.5 16C12.5 19 12 24 12 24C12 24 11.5 19 8.5 16C5.5 13 0 12 0 12C0 12 5.5 11 8.5 8C11.5 5 12 2 12 2Z"/>
</svg>
<div>
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">{{ __('hub::hub.ai_services.providers.gemini.title') }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
<a href="https://aistudio.google.com/app/apikey" target="_blank" class="text-violet-500 hover:text-violet-600">
{{ __('hub::hub.ai_services.providers.gemini.api_key_link') }}
</a>
</p>
</div>
</div>
<form wire:submit="saveGemini" class="space-y-6">
<!-- API Key -->
<div>
<label for="gemini-api-key" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ __('hub::hub.ai_services.labels.api_key') }} <span class="text-red-500">*</span>
</label>
<input
wire:model="geminiApiKey"
type="password"
id="gemini-api-key"
placeholder="AIza..."
autocomplete="new-password"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-violet-500 focus:border-violet-500"
/>
@error('geminiApiKey')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<!-- Model -->
<div>
<label for="gemini-model" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ __('hub::hub.ai_services.labels.model') }}
</label>
<select
wire:model="geminiModel"
id="gemini-model"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-violet-500 focus:border-violet-500"
>
@foreach($this->geminiModels as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
@error('geminiModel')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<!-- Active -->
<div class="flex items-center">
<input
wire:model="geminiActive"
type="checkbox"
id="gemini-active"
class="w-4 h-4 text-violet-600 bg-gray-100 border-gray-300 rounded focus:ring-violet-500 dark:focus:ring-violet-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
/>
<label for="gemini-active" class="ml-2 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ __('hub::hub.ai_services.labels.active') }}
</label>
</div>
<button
type="submit"
class="inline-flex items-center px-4 py-2 bg-violet-600 hover:bg-violet-700 text-white font-medium rounded-lg transition-colors"
>
<span wire:loading.remove wire:target="saveGemini">{{ __('hub::hub.ai_services.labels.save') }}</span>
<span wire:loading wire:target="saveGemini" class="flex items-center">
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" 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>
{{ __('hub::hub.ai_services.labels.saving') }}
</span>
</button>
</form>
</div>
@endif
<!-- OpenAI Panel -->
@if($activeTab === 'openai')
<div class="bg-white dark:bg-gray-800 shadow-xs rounded-xl p-6">
<div class="flex items-center mb-4">
<svg class="w-8 h-8 mr-3 text-[#10A37F]" viewBox="0 0 24 24" fill="currentColor">
<path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855l-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.795.795 0 0 0-.393.681zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z"/>
</svg>
<div>
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">{{ __('hub::hub.ai_services.providers.openai.title') }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
<a href="https://platform.openai.com/api-keys" target="_blank" class="text-violet-500 hover:text-violet-600">
{{ __('hub::hub.ai_services.providers.openai.api_key_link') }}
</a>
</p>
</div>
</div>
<form wire:submit="saveOpenAI" class="space-y-6">
<!-- Secret Key -->
<div>
<label for="openai-secret-key" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ __('hub::hub.ai_services.labels.secret_key') }} <span class="text-red-500">*</span>
</label>
<input
wire:model="openaiSecretKey"
type="password"
id="openai-secret-key"
placeholder="sk-..."
autocomplete="new-password"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-violet-500 focus:border-violet-500"
/>
@error('openaiSecretKey')
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
@enderror
</div>
<!-- Active -->
<div class="flex items-center">
<input
wire:model="openaiActive"
type="checkbox"
id="openai-active"
class="w-4 h-4 text-violet-600 bg-gray-100 border-gray-300 rounded focus:ring-violet-500 dark:focus:ring-violet-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
/>
<label for="openai-active" class="ml-2 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ __('hub::hub.ai_services.labels.active') }}
</label>
</div>
<button
type="submit"
class="inline-flex items-center px-4 py-2 bg-violet-600 hover:bg-violet-700 text-white font-medium rounded-lg transition-colors"
>
<span wire:loading.remove wire:target="saveOpenAI">{{ __('hub::hub.ai_services.labels.save') }}</span>
<span wire:loading wire:target="saveOpenAI" class="flex items-center">
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" 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>
{{ __('hub::hub.ai_services.labels.saving') }}
</span>
</button>
</form>
</div>
@endif
</div>

View file

@ -0,0 +1,62 @@
<div>
<!-- Page header -->
<div class="sm:flex sm:justify-between sm:items-center mb-8">
<div class="mb-4 sm:mb-0">
<h1 class="text-2xl md:text-3xl text-gray-800 dark:text-gray-100 font-bold">Analytics</h1>
<p class="text-gray-500 dark:text-gray-400 mt-1">Privacy-first insights across all your sites</p>
</div>
<div class="grid grid-flow-col sm:auto-cols-max justify-start sm:justify-end gap-2">
<div class="px-4 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg text-sm text-gray-500 dark:text-gray-400">
Last 30 days
</div>
</div>
</div>
<!-- Coming Soon Notice -->
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl p-6 mb-8">
<div class="flex items-start">
<div class="flex-shrink-0">
<core:icon name="chart-line" class="text-green-500 w-6 h-6" />
</div>
<div class="ml-4">
<h3 class="text-lg font-medium text-green-800 dark:text-green-200">Coming Soon</h3>
<p class="mt-1 text-green-700 dark:text-green-300">
Analytics integration is on the roadmap. This dashboard will display real-time visitor data, page views, traffic sources, and conversion metrics—all without cookies.
</p>
</div>
</div>
</div>
<!-- Metrics Grid -->
<div class="grid grid-cols-12 gap-6 mb-8">
@foreach($metrics as $metric)
<div class="col-span-6 sm:col-span-3 bg-white dark:bg-gray-800 shadow-xs rounded-xl p-5">
<div class="flex items-center mb-2">
<core:icon name="{{ $metric['icon'] }}" class="text-gray-400 mr-2" />
<span class="text-sm text-gray-500 dark:text-gray-400">{{ $metric['label'] }}</span>
</div>
<div class="text-2xl font-bold text-gray-800 dark:text-gray-100">{{ $metric['value'] }}</div>
</div>
@endforeach
</div>
<!-- Charts Grid -->
<div class="grid grid-cols-12 gap-6">
@foreach($chartData as $key => $chart)
<div class="col-span-full {{ $loop->first ? '' : 'lg:col-span-6' }} bg-white dark:bg-gray-800 shadow-xs rounded-xl overflow-hidden">
<div class="px-5 py-4 border-b border-gray-100 dark:border-gray-700/60">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-100">{{ $chart['title'] }}</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $chart['description'] }}</p>
</div>
<div class="p-5">
<div class="h-48 bg-gray-50 dark:bg-gray-700/50 rounded-lg flex items-center justify-center">
<div class="text-center">
<core:icon name="chart-bar" class="w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto mb-2" />
<span class="text-sm text-gray-400 dark:text-gray-500">Chart placeholder</span>
</div>
</div>
</div>
</div>
@endforeach
</div>
</div>

View file

@ -0,0 +1,90 @@
<div>
<!-- Page header -->
<div class="mb-8">
<h1 class="text-2xl md:text-3xl text-gray-800 dark:text-gray-100 font-bold">{{ __('hub::hub.boosts.title') }}</h1>
<p class="text-gray-500 dark:text-gray-400 mt-1">{{ __('hub::hub.boosts.subtitle') }}</p>
</div>
<div class="space-y-6">
@if(count($boostOptions) > 0)
<div class="grid gap-4 sm:grid-cols-2">
@foreach($boostOptions as $boost)
<div class="bg-white dark:bg-gray-800 shadow-xs rounded-xl p-6">
<div class="flex items-start justify-between mb-4">
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100">
{{ $boost['feature_name'] }}
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
{{ $boost['description'] }}
</p>
</div>
@switch($boost['boost_type'])
@case('add_limit')
<core:badge color="blue">+{{ number_format($boost['limit_value']) }}</core:badge>
@break
@case('unlimited')
<core:badge color="purple">{{ __('hub::hub.boosts.types.unlimited') }}</core:badge>
@break
@case('enable')
<core:badge color="green">{{ __('hub::hub.boosts.types.enable') }}</core:badge>
@break
@endswitch
</div>
<div class="flex items-center justify-between pt-4 border-t border-gray-100 dark:border-gray-700/60">
<div class="text-sm text-gray-500 dark:text-gray-400">
@switch($boost['duration_type'])
@case('cycle_bound')
<core:icon name="clock" class="size-4 mr-1" />
{{ __('hub::hub.boosts.duration.cycle_bound') }}
@break
@case('duration')
<core:icon name="calendar" class="size-4 mr-1" />
{{ __('hub::hub.boosts.duration.limited') }}
@break
@case('permanent')
<core:icon name="infinity" class="size-4 mr-1" />
{{ __('hub::hub.boosts.duration.permanent') }}
@break
@endswitch
</div>
<core:button wire:click="purchaseBoost('{{ $boost['blesta_id'] }}')" size="sm" variant="primary">
{{ __('hub::hub.boosts.actions.purchase') }}
</core:button>
</div>
</div>
@endforeach
</div>
@else
<div class="bg-white dark:bg-gray-800 shadow-xs rounded-xl">
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
<core:icon name="rocket" class="size-8 mx-auto mb-2 opacity-50" />
<p>{{ __('hub::hub.boosts.empty.title') }}</p>
<p class="text-sm mt-1">{{ __('hub::hub.boosts.empty.hint') }}</p>
</div>
</div>
@endif
<!-- Info Section -->
<div class="bg-blue-500/10 dark:bg-blue-500/20 rounded-xl p-6">
<h3 class="font-semibold text-blue-900 dark:text-blue-100 mb-2">
<core:icon name="circle-info" class="size-5 mr-2" />
{{ __('hub::hub.boosts.info.title') }}
</h3>
<ul class="text-sm text-blue-800 dark:text-blue-200 space-y-2 ml-7">
<li><strong>{{ __('hub::hub.boosts.labels.cycle_bound') }}</strong> {{ __('hub::hub.boosts.info.cycle_bound') }}</li>
<li><strong>{{ __('hub::hub.boosts.labels.duration_based') }}</strong> {{ __('hub::hub.boosts.info.duration_based') }}</li>
<li><strong>{{ __('hub::hub.boosts.labels.permanent') }}</strong> {{ __('hub::hub.boosts.info.permanent') }}</li>
</ul>
</div>
<!-- Back Link -->
<div class="flex justify-start">
<core:button href="{{ route('hub.usage') }}" variant="ghost">
<core:icon name="arrow-left" class="mr-2" />
{{ __('hub::hub.boosts.actions.back') }}
</core:button>
</div>
</div>
</div>

View file

@ -0,0 +1,505 @@
@php
$user = auth()->user();
$showDevBar = $user && method_exists($user, 'isHades') && $user->isHades();
// Performance metrics
$queryCount = count(DB::getQueryLog());
$startTime = defined('LARAVEL_START') ? LARAVEL_START : microtime(true);
$loadTime = number_format((microtime(true) - $startTime) * 1000, 2);
$memoryUsage = number_format(memory_get_peak_usage(true) / 1024 / 1024, 1);
// Check available dev tools
$hasHorizon = class_exists(\Laravel\Horizon\Horizon::class);
$hasPulse = class_exists(\Laravel\Pulse\Pulse::class);
$hasTelescope = class_exists(\Laravel\Telescope\Telescope::class) && config('telescope.enabled', false);
@endphp
@if($showDevBar)
<div
x-data="{
expanded: false,
activePanel: null,
logs: [],
routes: [],
routeFilter: '',
session: {},
loadingLogs: false,
loadingRoutes: false,
togglePanel(panel) {
if (this.activePanel === panel) {
this.activePanel = null;
} else {
this.activePanel = panel;
if (panel === 'logs' && this.logs.length === 0) this.loadLogs();
if (panel === 'routes' && this.routes.length === 0) this.loadRoutes();
if (panel === 'session') this.loadSession();
}
},
async loadLogs() {
this.loadingLogs = true;
try {
const res = await fetch('/hub/api/dev/logs');
this.logs = await res.json();
} catch (e) {
this.logs = [{ level: 'error', message: 'Failed to load logs', time: new Date().toISOString() }];
}
this.loadingLogs = false;
},
async loadRoutes() {
this.loadingRoutes = true;
try {
const res = await fetch('/hub/api/dev/routes');
this.routes = await res.json();
} catch (e) {
this.routes = [];
}
this.loadingRoutes = false;
},
async loadSession() {
try {
const res = await fetch('/hub/api/dev/session');
this.session = await res.json();
} catch (e) {
this.session = { error: 'Failed to load session' };
}
},
async clearCache(type) {
try {
const res = await fetch('/hub/api/dev/clear/' + type, { method: 'POST', headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' }});
const data = await res.json();
alert(data.message || 'Done!');
} catch (e) {
alert('Failed: ' + e.message);
}
}
}"
class="fixed bottom-0 left-0 right-0 z-50"
>
<!-- Expandable Panel Area -->
<div
x-show="activePanel"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 translate-y-4"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-4"
class="border-t border-violet-500/50 shadow-2xl"
style="background-color: #0a0a0f; max-height: 53vh; overflow-y: auto;"
>
<!-- Logs Panel -->
<div x-show="activePanel === 'logs'" class="p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-violet-400 font-semibold text-sm">Recent Logs</h3>
<button @click="loadLogs()" class="text-xs text-gray-400 hover:text-white">
<i class="fa-solid fa-refresh" :class="{ 'animate-spin': loadingLogs }"></i> Refresh
</button>
</div>
<div class="space-y-1 font-mono text-xs">
<template x-if="loadingLogs">
<div class="text-gray-500">Loading...</div>
</template>
<template x-if="!loadingLogs && logs.length === 0">
<div class="text-gray-500">No recent logs</div>
</template>
<template x-for="log in logs" :key="log.time">
<div class="flex items-start gap-2 py-1 border-b border-gray-800">
<span
class="px-1.5 py-0.5 rounded text-[10px] uppercase font-bold"
:class="{
'bg-red-500/20 text-red-400': log.level === 'error',
'bg-yellow-500/20 text-yellow-400': log.level === 'warning',
'bg-blue-500/20 text-blue-400': log.level === 'info',
'bg-gray-500/20 text-gray-400': !['error', 'warning', 'info'].includes(log.level)
}"
x-text="log.level"
></span>
<span class="text-gray-500" x-text="log.time"></span>
<span class="text-gray-300 flex-1 truncate" x-text="log.message"></span>
</div>
</template>
</div>
</div>
<!-- Routes Panel -->
<div x-show="activePanel === 'routes'" class="p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-violet-400 font-semibold text-sm">Routes</h3>
<input
type="text"
placeholder="Filter routes..."
class="bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-white w-48"
x-model="routeFilter"
>
</div>
<div class="space-y-1 font-mono text-xs max-h-48 overflow-y-auto">
<template x-if="loadingRoutes">
<div class="text-gray-500">Loading...</div>
</template>
<template x-for="route in routes.filter(r => !routeFilter || r.uri.includes(routeFilter) || (r.name && r.name.includes(routeFilter)))" :key="route.uri + route.method">
<div class="flex items-center gap-2 py-1 border-b border-gray-800">
<span
class="px-1.5 py-0.5 rounded text-[10px] uppercase font-bold w-14 text-center"
:class="{
'bg-green-500/20 text-green-400': route.method === 'GET',
'bg-blue-500/20 text-blue-400': route.method === 'POST',
'bg-yellow-500/20 text-yellow-400': route.method === 'PUT' || route.method === 'PATCH',
'bg-red-500/20 text-red-400': route.method === 'DELETE',
}"
x-text="route.method"
></span>
<span class="text-gray-300" x-text="route.uri"></span>
<span class="text-gray-500 text-[10px]" x-text="route.name || ''"></span>
</div>
</template>
</div>
</div>
<!-- Session Panel -->
<div x-show="activePanel === 'session'" class="p-4">
<h3 class="text-violet-400 font-semibold text-sm mb-3">Session & Request</h3>
<div class="grid grid-cols-2 gap-4 text-xs font-mono">
<div>
<div class="text-gray-500 mb-1">Session ID</div>
<div class="text-gray-300 truncate" x-text="session.id || '-'"></div>
</div>
<div>
<div class="text-gray-500 mb-1">User Agent</div>
<div class="text-gray-300 truncate" x-text="session.user_agent || '-'"></div>
</div>
<div>
<div class="text-gray-500 mb-1">IP Address</div>
<div class="text-gray-300" x-text="session.ip || '-'"></div>
</div>
<div>
<div class="text-gray-500 mb-1">PHP Version</div>
<div class="text-gray-300">{{ PHP_VERSION }}</div>
</div>
<div>
<div class="text-gray-500 mb-1">Laravel Version</div>
<div class="text-gray-300">{{ app()->version() }}</div>
</div>
<div>
<div class="text-gray-500 mb-1">Environment</div>
<div class="text-gray-300">{{ app()->environment() }}</div>
</div>
</div>
</div>
<!-- Cache Panel -->
<div x-show="activePanel === 'cache'" class="p-4">
<h3 class="text-violet-400 font-semibold text-sm mb-3">Cache Management</h3>
<div class="flex flex-wrap gap-2">
<button @click="clearCache('cache')" class="px-3 py-1.5 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded text-xs transition-colors">
<i class="fa-solid fa-trash mr-1"></i> Clear Cache
</button>
<button @click="clearCache('config')" class="px-3 py-1.5 bg-yellow-500/20 hover:bg-yellow-500/30 text-yellow-400 rounded text-xs transition-colors">
<i class="fa-solid fa-gear mr-1"></i> Clear Config
</button>
<button @click="clearCache('view')" class="px-3 py-1.5 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 rounded text-xs transition-colors">
<i class="fa-solid fa-eye mr-1"></i> Clear Views
</button>
<button @click="clearCache('route')" class="px-3 py-1.5 bg-green-500/20 hover:bg-green-500/30 text-green-400 rounded text-xs transition-colors">
<i class="fa-solid fa-route mr-1"></i> Clear Routes
</button>
<button @click="clearCache('all')" class="px-3 py-1.5 bg-violet-500/20 hover:bg-violet-500/30 text-violet-400 rounded text-xs transition-colors">
<i class="fa-solid fa-bomb mr-1"></i> Clear All
</button>
</div>
<p class="text-gray-500 text-xs mt-3">
<i class="fa-solid fa-info-circle mr-1"></i>
These actions run artisan cache commands on the server.
</p>
</div>
<!-- Appearance Panel -->
<div x-show="activePanel === 'appearance'" class="p-4" x-data="{
iconStyle: localStorage.getItem('icon-style') || 'fa-notdog fa-solid',
iconSize: localStorage.getItem('icon-size') || 'fa-lg',
setStyle(style) {
this.iconStyle = style;
localStorage.setItem('icon-style', style);
document.cookie = 'icon-style=' + style + '; path=/; SameSite=Lax';
location.reload();
},
setSize(size) {
this.iconSize = size;
localStorage.setItem('icon-size', size);
document.cookie = 'icon-size=' + size + '; path=/; SameSite=Lax';
location.reload();
}
}">
<!-- Classic Families -->
<h3 class="text-violet-400 font-semibold text-sm mb-2">Classic</h3>
<div class="grid grid-cols-4 md:grid-cols-5 lg:grid-cols-10 gap-2 mb-4">
<button @click="setStyle('fa-solid')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-solid' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
<i class="fa-solid fa-house text-xl text-gray-300"></i>
<span class="text-[10px] text-gray-400">Solid</span>
</button>
<button @click="setStyle('fa-regular')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-regular' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
<i class="fa-regular fa-house text-xl text-gray-300"></i>
<span class="text-[10px] text-gray-400">Regular</span>
</button>
<button @click="setStyle('fa-light')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-light' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
<i class="fa-light fa-house text-xl text-gray-300"></i>
<span class="text-[10px] text-gray-400">Light</span>
</button>
<button @click="setStyle('fa-thin')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-thin' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
<i class="fa-thin fa-house text-xl text-gray-300"></i>
<span class="text-[10px] text-gray-400">Thin</span>
</button>
<button @click="setStyle('fa-duotone')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-duotone' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
<i class="fa-duotone fa-house text-xl text-gray-300"></i>
<span class="text-[10px] text-gray-400">Duotone</span>
</button>
</div>
<!-- Sharp Families -->
<h3 class="text-violet-400 font-semibold text-sm mb-2">Sharp</h3>
<div class="grid grid-cols-4 md:grid-cols-5 lg:grid-cols-10 gap-2 mb-4">
<button @click="setStyle('fa-sharp fa-solid')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-sharp fa-solid' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
<i class="fa-sharp fa-solid fa-house text-xl text-gray-300"></i>
<span class="text-[10px] text-gray-400">Solid</span>
</button>
<button @click="setStyle('fa-sharp fa-regular')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-sharp fa-regular' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
<i class="fa-sharp fa-regular fa-house text-xl text-gray-300"></i>
<span class="text-[10px] text-gray-400">Regular</span>
</button>
<button @click="setStyle('fa-sharp fa-light')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-sharp fa-light' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
<i class="fa-sharp fa-light fa-house text-xl text-gray-300"></i>
<span class="text-[10px] text-gray-400">Light</span>
</button>
<button @click="setStyle('fa-sharp fa-thin')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-sharp fa-thin' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
<i class="fa-sharp fa-thin fa-house text-xl text-gray-300"></i>
<span class="text-[10px] text-gray-400">Thin</span>
</button>
<button @click="setStyle('fa-sharp-duotone-solid')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-sharp-duotone-solid' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
<i class="fa-sharp-duotone-solid fa-house text-xl text-gray-300"></i>
<span class="text-[10px] text-gray-400">Duo Solid</span>
</button>
</div>
<!-- Specialty Families -->
<h3 class="text-violet-400 font-semibold text-sm mb-2">Specialty</h3>
<div class="grid grid-cols-4 md:grid-cols-5 lg:grid-cols-10 gap-2 mb-4">
<button @click="setStyle('fa-jelly fa-regular')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-jelly fa-regular' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
<i class="fa-jelly fa-regular fa-house text-xl text-gray-300"></i>
<span class="text-[10px] text-gray-400">Jelly</span>
</button>
<button @click="setStyle('fa-jelly-fill fa-regular')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-jelly-fill fa-regular' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
<i class="fa-jelly-fill fa-regular fa-house text-xl text-gray-300"></i>
<span class="text-[10px] text-gray-400">Jelly Fill</span>
</button>
<button @click="setStyle('fa-jelly-duo fa-regular')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-jelly-duo fa-regular' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
<i class="fa-jelly-duo fa-regular fa-house text-xl text-gray-300"></i>
<span class="text-[10px] text-gray-400">Jelly Duo</span>
</button>
<button @click="setStyle('fa-notdog fa-solid')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-notdog fa-solid' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
<i class="fa-notdog fa-solid fa-house text-xl text-gray-300"></i>
<span class="text-[10px] text-gray-400">Notdog</span>
</button>
<button @click="setStyle('fa-notdog-duo fa-solid')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-notdog-duo fa-solid' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
<i class="fa-notdog-duo fa-solid fa-house text-xl text-gray-300"></i>
<span class="text-[10px] text-gray-400">Notdog Duo</span>
</button>
<button @click="setStyle('fa-slab fa-regular')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-slab fa-regular' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
<i class="fa-slab fa-regular fa-house text-xl text-gray-300"></i>
<span class="text-[10px] text-gray-400">Slab</span>
</button>
<button @click="setStyle('fa-slab-press fa-regular')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-slab-press fa-regular' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
<i class="fa-slab-press fa-regular fa-house text-xl text-gray-300"></i>
<span class="text-[10px] text-gray-400">Slab Press</span>
</button>
<button @click="setStyle('fa-utility fa-semibold')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-utility fa-semibold' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
<i class="fa-utility fa-semibold fa-house text-xl text-gray-300"></i>
<span class="text-[10px] text-gray-400">Utility</span>
</button>
<button @click="setStyle('fa-utility-fill fa-semibold')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-utility-fill fa-semibold' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
<i class="fa-utility-fill fa-semibold fa-house text-xl text-gray-300"></i>
<span class="text-[10px] text-gray-400">Utility Fill</span>
</button>
<button @click="setStyle('fa-utility-duo fa-semibold')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-utility-duo fa-semibold' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
<i class="fa-utility-duo fa-semibold fa-house text-xl text-gray-300"></i>
<span class="text-[10px] text-gray-400">Utility Duo</span>
</button>
<button @click="setStyle('fa-whiteboard fa-semibold')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-whiteboard fa-semibold' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
<i class="fa-whiteboard fa-semibold fa-house text-xl text-gray-300"></i>
<span class="text-[10px] text-gray-400">Whiteboard</span>
</button>
<button @click="setStyle('fa-chisel fa-regular')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-chisel fa-regular' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
<i class="fa-chisel fa-regular fa-house text-xl text-gray-300"></i>
<span class="text-[10px] text-gray-400">Chisel</span>
</button>
<button @click="setStyle('fa-etch fa-solid')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-etch fa-solid' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
<i class="fa-etch fa-solid fa-house text-xl text-gray-300"></i>
<span class="text-[10px] text-gray-400">Etch</span>
</button>
<button @click="setStyle('fa-thumbprint fa-light')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-thumbprint fa-light' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
<i class="fa-thumbprint fa-light fa-house text-xl text-gray-300"></i>
<span class="text-[10px] text-gray-400">Thumbprint</span>
</button>
</div>
<!-- Icon Size -->
<h3 class="text-violet-400 font-semibold text-sm mb-2">Size</h3>
<div class="flex flex-wrap gap-2 mb-3">
<button @click="setSize('')" class="px-3 py-1.5 rounded-lg border text-xs transition-colors" :class="iconSize === '' ? 'border-violet-500 bg-violet-500/10 text-violet-300' : 'border-gray-700 text-gray-400 hover:border-gray-600'">
Default
</button>
<button @click="setSize('fa-2xs')" class="px-3 py-1.5 rounded-lg border text-xs transition-colors" :class="iconSize === 'fa-2xs' ? 'border-violet-500 bg-violet-500/10 text-violet-300' : 'border-gray-700 text-gray-400 hover:border-gray-600'">
<i class="fa-solid fa-house fa-2xs mr-1"></i> 2XS
</button>
<button @click="setSize('fa-xs')" class="px-3 py-1.5 rounded-lg border text-xs transition-colors" :class="iconSize === 'fa-xs' ? 'border-violet-500 bg-violet-500/10 text-violet-300' : 'border-gray-700 text-gray-400 hover:border-gray-600'">
<i class="fa-solid fa-house fa-xs mr-1"></i> XS
</button>
<button @click="setSize('fa-sm')" class="px-3 py-1.5 rounded-lg border text-xs transition-colors" :class="iconSize === 'fa-sm' ? 'border-violet-500 bg-violet-500/10 text-violet-300' : 'border-gray-700 text-gray-400 hover:border-gray-600'">
<i class="fa-solid fa-house fa-sm mr-1"></i> SM
</button>
<button @click="setSize('fa-lg')" class="px-3 py-1.5 rounded-lg border text-xs transition-colors" :class="iconSize === 'fa-lg' ? 'border-violet-500 bg-violet-500/10 text-violet-300' : 'border-gray-700 text-gray-400 hover:border-gray-600'">
<i class="fa-solid fa-house fa-lg mr-1"></i> LG
</button>
<button @click="setSize('fa-xl')" class="px-3 py-1.5 rounded-lg border text-xs transition-colors" :class="iconSize === 'fa-xl' ? 'border-violet-500 bg-violet-500/10 text-violet-300' : 'border-gray-700 text-gray-400 hover:border-gray-600'">
<i class="fa-solid fa-house fa-xl mr-1"></i> XL
</button>
<button @click="setSize('fa-2xl')" class="px-3 py-1.5 rounded-lg border text-xs transition-colors" :class="iconSize === 'fa-2xl' ? 'border-violet-500 bg-violet-500/10 text-violet-300' : 'border-gray-700 text-gray-400 hover:border-gray-600'">
<i class="fa-solid fa-house fa-2xl mr-1"></i> 2XL
</button>
</div>
<p class="text-gray-500 text-xs">
<i class="fa-solid fa-info-circle mr-1"></i>
Current: <code class="text-violet-400" x-text="iconStyle"></code>
<span x-show="iconSize"> + <code class="text-violet-400" x-text="iconSize"></code></span>
</p>
</div>
</div>
<!-- Main Bar -->
<div class="border-t border-violet-500/50 text-white text-xs font-mono shadow-lg" style="background-color: #0a0a0f;">
<div class="flex items-center justify-between px-4 py-2">
<!-- Left: Environment & User Info -->
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<span class="px-2 py-0.5 bg-red-500/20 text-red-400 rounded text-[10px] font-semibold uppercase">
{{ app()->environment() }}
</span>
<span class="text-gray-600">|</span>
<span class="text-violet-300">
<i class="fa-solid fa-bolt mr-1"></i>Hades
</span>
</div>
<div class="hidden sm:flex items-center gap-2 text-gray-400">
<i class="fa-solid fa-user text-xs"></i>
<span>{{ $user->name }}</span>
</div>
</div>
<!-- Panel Toggle Buttons (positioned left of center) -->
<div class="flex items-center gap-2 ml-8">
<button
@click="togglePanel('logs')"
class="flex items-center justify-center w-9 h-9 rounded-lg transition-colors"
:class="activePanel === 'logs' ? 'bg-violet-500/30 text-violet-300' : 'hover:bg-gray-800 text-gray-400 hover:text-white'"
title="View Logs"
>
<i class="fa-solid fa-scroll text-lg"></i>
</button>
<button
@click="togglePanel('routes')"
class="flex items-center justify-center w-9 h-9 rounded-lg transition-colors"
:class="activePanel === 'routes' ? 'bg-violet-500/30 text-violet-300' : 'hover:bg-gray-800 text-gray-400 hover:text-white'"
title="View Routes"
>
<i class="fa-solid fa-route text-lg"></i>
</button>
<button
@click="togglePanel('session')"
class="flex items-center justify-center w-9 h-9 rounded-lg transition-colors"
:class="activePanel === 'session' ? 'bg-violet-500/30 text-violet-300' : 'hover:bg-gray-800 text-gray-400 hover:text-white'"
title="Session Info"
>
<i class="fa-solid fa-fingerprint text-lg"></i>
</button>
<button
@click="togglePanel('cache')"
class="flex items-center justify-center w-9 h-9 rounded-lg transition-colors"
:class="activePanel === 'cache' ? 'bg-violet-500/30 text-violet-300' : 'hover:bg-gray-800 text-gray-400 hover:text-white'"
title="Cache Management"
>
<i class="fa-solid fa-database text-lg"></i>
</button>
<button
@click="togglePanel('appearance')"
class="flex items-center justify-center w-9 h-9 rounded-lg transition-colors"
:class="activePanel === 'appearance' ? 'bg-violet-500/30 text-violet-300' : 'hover:bg-gray-800 text-gray-400 hover:text-white'"
title="Appearance & Icons"
>
<i class="fa-solid fa-palette text-lg"></i>
</button>
<span class="text-gray-700 mx-2">|</span>
<!-- External Dev Tools -->
@if($hasHorizon)
<a href="/horizon" target="_blank" class="flex items-center justify-center w-9 h-9 rounded-lg hover:bg-green-500/20 text-gray-400 hover:text-green-400 transition-colors" title="Laravel Horizon">
<i class="fa-solid fa-chart-line text-lg"></i>
</a>
@endif
@if($hasPulse)
<a href="/pulse" target="_blank" class="flex items-center justify-center w-9 h-9 rounded-lg hover:bg-pink-500/20 text-gray-400 hover:text-pink-400 transition-colors" title="Laravel Pulse">
<i class="fa-solid fa-heart-pulse text-lg"></i>
</a>
@endif
@if($hasTelescope)
<a href="/telescope" target="_blank" class="flex items-center justify-center w-9 h-9 rounded-lg hover:bg-indigo-500/20 text-gray-400 hover:text-indigo-400 transition-colors" title="Laravel Telescope">
<i class="fa-solid fa-satellite-dish text-lg"></i>
</a>
@endif
</div>
<!-- Right: Performance Stats & Close -->
<div class="flex items-center gap-4">
<div class="hidden md:flex items-center gap-3 text-gray-400">
<span title="Database queries">
<i class="fa-solid fa-database text-violet-400"></i>
{{ $queryCount }}q
</span>
<span title="Page load time">
<i class="fa-solid fa-clock text-violet-400"></i>
{{ $loadTime }}ms
</span>
<span title="Peak memory usage">
<i class="fa-solid fa-memory text-violet-400"></i>
{{ $memoryUsage }}MB
</span>
</div>
<button
@click="$el.closest('.fixed').classList.add('hidden')"
class="flex items-center justify-center w-6 h-6 bg-gray-700/50 hover:bg-red-500/30 hover:text-red-400 rounded transition-colors"
title="Hide dev bar (refresh to restore)"
>
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</div>
</div>
</div>
<!-- Add bottom padding to content when dev bar is visible -->
<style>
body { padding-bottom: 2.75rem; }
[x-cloak] { display: none !important; }
</style>
@endif

View file

@ -0,0 +1,183 @@
<header class="sticky top-0 before:absolute before:inset-0 before:backdrop-blur-md max-sm:before:bg-white/90 dark:max-sm:before:bg-gray-800/90 before:-z-10 z-30 before:bg-white after:absolute after:h-px after:inset-x-0 after:top-full after:bg-gray-200 dark:after:bg-gray-700/60 after:-z-10 dark:before:bg-gray-800">
<div class="px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<!-- Header: Left side -->
<div class="flex items-center gap-4">
<!-- Hamburger button -->
<button
class="text-gray-500 hover:text-gray-600 dark:hover:text-gray-400 sm:hidden"
@click.stop="sidebarOpen = !sidebarOpen"
aria-controls="sidebar"
:aria-expanded="sidebarOpen"
>
<span class="sr-only">Open sidebar</span>
<svg class="w-6 h-6 fill-current" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="5" width="16" height="2" />
<rect x="4" y="11" width="16" height="2" />
<rect x="4" y="17" width="16" height="2" />
</svg>
</button>
<!-- Workspace Switcher -->
<livewire:hub.admin.workspace-switcher />
</div>
<!-- Header: Right side -->
<div class="flex items-center space-x-1">
<!-- Search button -->
<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 -->
<div class="relative inline-flex" x-data="{ open: false }">
<button
class="relative 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"
:class="{ 'bg-gray-200 dark:bg-gray-700': open }"
aria-haspopup="true"
@click.prevent="open = !open"
:aria-expanded="open"
>
<span class="sr-only">Notifications</span>
<core:icon name="bell" size="fa-lg" class="text-gray-500 dark:text-gray-400" />
<flux:badge color="red" size="sm" class="absolute -top-0.5 -right-0.5 min-w-5 h-5 flex items-center justify-center">2</flux:badge>
</button>
<div
class="origin-top-right z-10 absolute top-full -mr-48 sm:mr-0 min-w-80 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700/60 py-1.5 rounded-lg shadow-lg overflow-hidden mt-1 right-0"
@click.outside="open = false"
@keydown.escape.window="open = false"
x-show="open"
x-transition:enter="transition ease-out duration-200 transform"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-out duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
x-cloak
>
<div class="flex items-center justify-between pt-1.5 pb-2 px-4">
<span class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase">Notifications</span>
<button class="text-xs text-violet-500 hover:text-violet-600 dark:hover:text-violet-400">Mark all read</button>
</div>
<ul>
<li class="border-b border-gray-200 dark:border-gray-700/60 last:border-0">
<a class="block py-2 px-4 hover:bg-gray-50 dark:hover:bg-gray-700/20" href="{{ route('hub.deployments') }}" @click="open = false">
<span class="block text-sm mb-2">New deployment completed for <span class="font-medium text-gray-800 dark:text-gray-100">Bio</span></span>
<span class="block text-xs font-medium text-gray-400 dark:text-gray-500">2 hours ago</span>
</a>
</li>
<li class="border-b border-gray-200 dark:border-gray-700/60 last:border-0">
<a class="block py-2 px-4 hover:bg-gray-50 dark:hover:bg-gray-700/20" href="{{ route('hub.databases') }}" @click="open = false">
<span class="block text-sm mb-2">Database backup successful for <span class="font-medium text-gray-800 dark:text-gray-100">Social</span></span>
<span class="block text-xs font-medium text-gray-400 dark:text-gray-500">5 hours ago</span>
</a>
</li>
</ul>
</div>
</div>
<!-- Dark mode toggle -->
<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"
x-data="{ isDark: document.documentElement.classList.contains('dark') }"
@click="
isDark = !isDark;
document.documentElement.classList.toggle('dark', isDark);
document.documentElement.style.colorScheme = isDark ? 'dark' : 'light';
localStorage.setItem('dark-mode', isDark);
localStorage.setItem('flux.appearance', isDark ? 'dark' : 'light');
document.cookie = 'dark-mode=' + isDark + '; path=/; SameSite=Lax';
"
>
<core:icon name="sun-bright" size="fa-lg" class="text-gray-500" x-show="!isDark" />
<core:icon name="moon-stars" size="fa-lg" class="text-gray-400" x-show="isDark" x-cloak />
<span class="sr-only">Toggle dark mode</span>
</button>
<!-- Divider -->
<hr class="w-px h-6 bg-gray-200 dark:bg-gray-700/60 border-none" />
<!-- User button -->
@php
$user = auth()->user();
$userName = $user?->name ?? 'Guest';
$userEmail = $user?->email ?? '';
$userTier = ($user && method_exists($user, 'getTier')) ? ($user->getTier()?->label() ?? 'Free') : 'Free';
$userInitials = collect(explode(' ', $userName))->map(fn($n) => strtoupper(substr($n, 0, 1)))->take(2)->join('');
@endphp
<div class="relative inline-flex" x-data="{ open: false }">
<button
class="inline-flex justify-center items-center group"
aria-haspopup="true"
@click.prevent="open = !open"
:aria-expanded="open"
>
<div class="w-8 h-8 rounded-full bg-violet-500 flex items-center justify-center text-white text-xs font-semibold">
{{ $userInitials }}
</div>
<div class="flex items-center truncate">
<span class="truncate ml-2 text-sm font-medium text-gray-600 dark:text-gray-100 group-hover:text-gray-800 dark:group-hover:text-white">{{ $userName }}</span>
<svg class="w-3 h-3 shrink-0 ml-1 fill-current text-gray-400 dark:text-gray-500" viewBox="0 0 12 12">
<path d="M5.9 11.4L.5 6l1.4-1.4 4 4 4-4L11.3 6z" />
</svg>
</div>
</button>
<div
class="origin-top-right z-10 absolute top-full min-w-44 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700/60 py-1.5 rounded-lg shadow-lg overflow-hidden mt-1 right-0"
@click.outside="open = false"
@keydown.escape.window="open = false"
x-show="open"
x-transition:enter="transition ease-out duration-200 transform"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-out duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
x-cloak
>
<div class="pt-0.5 pb-2 px-3 mb-1 border-b border-gray-200 dark:border-gray-700/60">
<div class="font-medium text-gray-800 dark:text-gray-100">{{ $userName }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">{{ $userEmail }}</div>
</div>
<ul>
<li>
<a class="font-medium text-sm text-violet-500 hover:text-violet-600 dark:hover:text-violet-400 flex items-center py-1.5 px-3" href="{{ route('hub.account') }}" @click="open = false">
<core:icon name="user" class="w-5 mr-2" /> Profile
</a>
</li>
<li>
<a class="font-medium text-sm text-violet-500 hover:text-violet-600 dark:hover:text-violet-400 flex items-center py-1.5 px-3" href="{{ route('hub.account.settings') }}" @click="open = false">
<core:icon name="gear" class="w-5 mr-2" /> Settings
</a>
</li>
<li>
<a class="font-medium text-sm text-violet-500 hover:text-violet-600 dark:hover:text-violet-400 flex items-center py-1.5 px-3" href="/" @click="open = false">
<core:icon name="arrow-left" class="w-5 mr-2" /> Back to Site
</a>
</li>
<li class="border-t border-gray-200 dark:border-gray-700/60 mt-1 pt-1">
<a class="font-medium text-sm text-violet-500 hover:text-violet-600 dark:hover:text-violet-400 flex items-center py-1.5 px-3" href="/logout">
<core:icon name="right-from-bracket" class="w-5 mr-2" /> Sign Out
</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</header>

View file

@ -0,0 +1,4 @@
<admin:sidebar logo="/images/host-uk-raven.svg" logoText="Host Hub" :logoRoute="route('hub.dashboard')">
<admin:sidemenu />
</admin:sidebar>

View file

@ -0,0 +1,132 @@
<div>
<!-- Page header -->
<div class="sm:flex sm:justify-between sm:items-center mb-8">
<div class="mb-4 sm:mb-0">
<h1 class="text-2xl md:text-3xl text-gray-800 dark:text-gray-100 font-bold">{{ __('hub::hub.console.title') }}</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ __('hub::hub.console.subtitle') }}</p>
</div>
</div>
<div class="grid grid-cols-12 gap-6">
<!-- Server list -->
<div class="col-span-full lg:col-span-4 xl:col-span-3">
<div class="bg-white dark:bg-gray-800 shadow-xs rounded-xl">
<header class="px-5 py-4 border-b border-gray-100 dark:border-gray-700/60">
<h2 class="font-semibold text-gray-800 dark:text-gray-100">{{ __('hub::hub.console.labels.select_server') }}</h2>
</header>
<div class="p-3">
<ul class="space-y-2">
@foreach($servers as $server)
<li>
<button
wire:click="selectServer({{ $server['id'] }})"
class="w-full flex items-center p-3 rounded-lg transition {{ $selectedServer === $server['id'] ? 'bg-violet-500/10 border border-violet-500/50' : 'bg-gray-50 dark:bg-gray-700/30 hover:bg-gray-100 dark:hover:bg-gray-700/50 border border-transparent' }}"
>
<div class="w-8 h-8 rounded-lg {{ $selectedServer === $server['id'] ? 'bg-violet-500/20' : 'bg-gray-200 dark:bg-gray-600' }} flex items-center justify-center mr-3">
@switch($server['type'])
@case('WordPress')
<i class="fa-brands fa-wordpress {{ $selectedServer === $server['id'] ? 'text-violet-500' : 'text-gray-500 dark:text-gray-400' }} text-sm"></i>
@break
@case('Laravel')
<i class="fa-brands fa-laravel {{ $selectedServer === $server['id'] ? 'text-violet-500' : 'text-gray-500 dark:text-gray-400' }} text-sm"></i>
@break
@case('Node.js')
<i class="fa-brands fa-node-js {{ $selectedServer === $server['id'] ? 'text-violet-500' : 'text-gray-500 dark:text-gray-400' }} text-sm"></i>
@break
@default
<core:icon name="server" class="{{ $selectedServer === $server['id'] ? 'text-violet-500' : 'text-gray-500 dark:text-gray-400' }} text-sm" />
@endswitch
</div>
<div class="text-left">
<div class="text-sm font-medium {{ $selectedServer === $server['id'] ? 'text-violet-600 dark:text-violet-400' : 'text-gray-800 dark:text-gray-100' }}">{{ $server['name'] }}</div>
<div class="flex items-center text-xs text-gray-500 dark:text-gray-400">
<div class="w-1.5 h-1.5 rounded-full {{ $server['status'] === 'online' ? 'bg-green-500' : 'bg-red-500' }} mr-1"></div>
{{ ucfirst($server['status']) }}
</div>
</div>
</button>
</li>
@endforeach
</ul>
</div>
</div>
<!-- Coolify Integration Notice -->
<div class="bg-violet-500/10 border border-violet-500/20 rounded-xl p-4 mt-6">
<div class="flex items-start">
<div class="w-8 h-8 rounded-lg bg-violet-500/20 flex items-center justify-center mr-3 shrink-0">
<core:icon name="plug" class="text-violet-500" />
</div>
<div>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-1">{{ __('hub::hub.console.coolify.title') }}</h3>
<p class="text-xs text-gray-600 dark:text-gray-400">{{ __('hub::hub.console.coolify.description') }}</p>
</div>
</div>
</div>
</div>
<!-- Terminal -->
<div class="col-span-full lg:col-span-8 xl:col-span-9">
<div class="bg-gray-900 rounded-xl overflow-hidden shadow-xl h-[600px] flex flex-col">
<!-- Terminal header -->
<div class="flex items-center justify-between px-4 py-3 bg-gray-800 border-b border-gray-700">
<div class="flex items-center space-x-2">
<div class="w-3 h-3 rounded-full bg-red-500"></div>
<div class="w-3 h-3 rounded-full bg-yellow-500"></div>
<div class="w-3 h-3 rounded-full bg-green-500"></div>
</div>
@if($selectedServer)
@php $selectedServerData = collect($servers)->firstWhere('id', $selectedServer); @endphp
<span class="text-sm text-gray-400">{{ $selectedServerData['name'] ?? __('hub::hub.console.labels.terminal') }}</span>
@else
<span class="text-sm text-gray-400">{{ __('hub::hub.console.labels.terminal') }}</span>
@endif
<div class="flex items-center space-x-2">
<core:button variant="ghost" size="sm" icon="arrows-pointing-out" class="text-gray-400 hover:text-white" />
<core:button variant="ghost" size="sm" icon="cog-6-tooth" class="text-gray-400 hover:text-white" />
</div>
</div>
<!-- Terminal body -->
<div class="flex-1 p-4 font-mono text-sm overflow-auto">
@if($selectedServer)
<div class="text-green-400">
<div class="mb-2">{{ __('hub::hub.console.labels.connecting', ['name' => $selectedServerData['name'] ?? 'server']) }}</div>
<div class="mb-2 text-gray-500">{{ __('hub::hub.console.labels.establishing_connection') }}</div>
<div class="mb-4 text-green-400">{{ __('hub::hub.console.labels.connected') }}</div>
<div class="text-gray-300">
<span class="text-violet-400">root@{{ strtolower(str_replace(' ', '-', $selectedServerData['name'] ?? 'server')) }}</span>:<span class="text-blue-400">~</span>$
<span class="animate-pulse">_</span>
</div>
</div>
@else
<div class="flex flex-col items-center justify-center h-full text-gray-500">
<core:icon name="terminal" class="text-4xl mb-4 opacity-50" />
<p class="text-center">{{ __('hub::hub.console.labels.select_server_prompt') }}</p>
</div>
@endif
</div>
<!-- Terminal input -->
@if($selectedServer)
<div class="border-t border-gray-700 p-2">
<div class="flex items-center bg-gray-800 rounded px-3 py-2">
<span class="text-gray-400 mr-2">$</span>
<input
type="text"
class="flex-1 bg-transparent text-gray-100 focus:outline-none font-mono text-sm"
placeholder="{{ __('hub::hub.console.labels.enter_command') }}"
disabled
>
<core:button variant="ghost" size="sm" icon="paper-airplane" class="text-gray-400 hover:text-white ml-2" />
</div>
<p class="text-xs text-gray-500 mt-2 px-1">
<core:icon name="info-circle" class="mr-1" />
{{ __('hub::hub.console.labels.terminal_disabled') }}
</p>
</div>
@endif
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,654 @@
<div
x-data="{
showCommand: @entangle('showCommand'),
activeSidebar: @entangle('activeSidebar'),
init() {
// Ctrl+Space to open AI command palette
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.code === 'Space') {
e.preventDefault();
$wire.openCommand();
}
// Escape to close
if (e.key === 'Escape' && this.showCommand) {
$wire.closeCommand();
}
// Ctrl+S to save
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
$wire.save();
}
});
// Autosave every 60 seconds
setInterval(() => {
if ($wire.isDirty) {
$wire.autosave();
}
}, 60000);
}
}"
class="min-h-screen flex flex-col"
>
{{-- Header --}}
<div class="sticky top-0 z-30 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between px-6 py-3">
<div class="flex items-center gap-4">
<a href="{{ route('hub.content-manager', ['workspace' => $workspaceId ? \Core\Mod\Tenant\Models\Workspace::find($workspaceId)?->slug : 'main']) }}"
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<core:icon name="arrow-left" class="w-5 h-5"/>
</a>
<div>
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ $contentId ? __('hub::hub.content_editor.title.edit') : __('hub::hub.content_editor.title.new') }}
</h1>
<p class="text-xs text-gray-500 dark:text-gray-400">
@if($lastSaved)
{{ __('hub::hub.content_editor.save_status.last_saved', ['time' => $lastSaved]) }}
@else
{{ __('hub::hub.content_editor.save_status.not_saved') }}
@endif
@if($isDirty)
<span class="text-amber-500"> {{ __('hub::hub.content_editor.save_status.unsaved_changes') }}</span>
@endif
@if($revisionCount > 0)
<span class="text-gray-400"> {{ trans_choice('hub::hub.content_editor.save_status.revisions', $revisionCount, ['count' => $revisionCount]) }}</span>
@endif
</p>
</div>
</div>
<div class="flex items-center gap-3">
{{-- AI Command Button --}}
<core:button
wire:click="openCommand"
variant="ghost"
size="sm"
icon="sparkles"
kbd="Ctrl+Space"
>
{{ __('hub::hub.content_editor.actions.ai_assist') }}
</core:button>
{{-- Status --}}
<core:select wire:model.live="status" size="sm" class="w-32">
<core:select.option value="draft">{{ __('hub::hub.content_editor.status.draft') }}</core:select.option>
<core:select.option value="pending">{{ __('hub::hub.content_editor.status.pending') }}</core:select.option>
<core:select.option value="publish">{{ __('hub::hub.content_editor.status.publish') }}</core:select.option>
<core:select.option value="future">{{ __('hub::hub.content_editor.status.future') }}</core:select.option>
<core:select.option value="private">{{ __('hub::hub.content_editor.status.private') }}</core:select.option>
</core:select>
{{-- Save --}}
<core:button wire:click="save" variant="ghost" size="sm" kbd="Ctrl+S">
{{ __('hub::hub.content_editor.actions.save_draft') }}
</core:button>
{{-- Schedule/Publish --}}
@if($isScheduled)
<core:button wire:click="schedule" variant="primary" size="sm" icon="calendar">
{{ __('hub::hub.content_editor.actions.schedule') }}
</core:button>
@else
<core:button wire:click="publish" variant="primary" size="sm">
{{ __('hub::hub.content_editor.actions.publish') }}
</core:button>
@endif
</div>
</div>
</div>
{{-- Main Content Area --}}
<div class="flex-1 flex">
{{-- Editor Panel --}}
<div class="flex-1 overflow-y-auto">
<div class="max-w-4xl mx-auto px-6 py-8">
<div class="space-y-6">
{{-- Title --}}
<div>
<core:input
wire:model.live.debounce.500ms="title"
placeholder="{{ __('hub::hub.content_editor.fields.title_placeholder') }}"
class="text-3xl font-bold border-none shadow-none focus:ring-0 px-0"
/>
</div>
{{-- Slug & Type Row --}}
<div class="flex gap-4">
<div class="flex-1">
<core:input
wire:model="slug"
label="{{ __('hub::hub.content_editor.fields.url_slug') }}"
prefix="/"
size="sm"
/>
</div>
<div class="w-32">
<core:select wire:model="type" label="{{ __('hub::hub.content_editor.fields.type') }}" size="sm">
<core:select.option value="page">{{ __('hub::hub.content_editor.fields.type_page') }}</core:select.option>
<core:select.option value="post">{{ __('hub::hub.content_editor.fields.type_post') }}</core:select.option>
</core:select>
</div>
</div>
{{-- Excerpt --}}
<div>
<core:textarea
wire:model="excerpt"
label="{{ __('hub::hub.content_editor.fields.excerpt') }}"
description="{{ __('hub::hub.content_editor.fields.excerpt_description') }}"
rows="2"
/>
</div>
{{-- Main Editor (AC7 - Rich Text) --}}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ __('hub::hub.content_editor.fields.content') }}
</label>
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<core:editor
wire:model="content"
toolbar="heading | bold italic underline strike | bullet ordered blockquote | link image code | align ~ undo redo"
placeholder="{{ __('hub::hub.content_editor.fields.content_placeholder') }}"
class="min-h-[400px]"
/>
</div>
</div>
</div>
</div>
</div>
{{-- Sidebar --}}
<div class="w-80 border-l border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 overflow-y-auto">
{{-- Sidebar Tabs --}}
<div class="sticky top-0 z-10 bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700">
<div class="flex">
<button
@click="activeSidebar = 'settings'"
:class="activeSidebar === 'settings' ? 'border-violet-500 text-violet-600' : 'border-transparent text-gray-500 hover:text-gray-700'"
class="flex-1 py-3 text-sm font-medium border-b-2 transition"
>
{{ __('hub::hub.content_editor.sidebar.settings') }}
</button>
<button
@click="activeSidebar = 'seo'"
:class="activeSidebar === 'seo' ? 'border-violet-500 text-violet-600' : 'border-transparent text-gray-500 hover:text-gray-700'"
class="flex-1 py-3 text-sm font-medium border-b-2 transition"
>
{{ __('hub::hub.content_editor.sidebar.seo') }}
</button>
<button
@click="activeSidebar = 'media'"
:class="activeSidebar === 'media' ? 'border-violet-500 text-violet-600' : 'border-transparent text-gray-500 hover:text-gray-700'"
class="flex-1 py-3 text-sm font-medium border-b-2 transition"
>
{{ __('hub::hub.content_editor.sidebar.media') }}
</button>
<button
@click="activeSidebar = 'revisions'; $wire.loadRevisions()"
:class="activeSidebar === 'revisions' ? 'border-violet-500 text-violet-600' : 'border-transparent text-gray-500 hover:text-gray-700'"
class="flex-1 py-3 text-sm font-medium border-b-2 transition"
>
{{ __('hub::hub.content_editor.sidebar.history') }}
</button>
</div>
</div>
<div class="p-4 space-y-6">
{{-- Settings Panel --}}
<div x-show="activeSidebar === 'settings'" x-cloak>
{{-- Scheduling (AC11) --}}
<div class="space-y-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">{{ __('hub::hub.content_editor.scheduling.title') }}</h3>
<core:checkbox
wire:model.live="isScheduled"
label="{{ __('hub::hub.content_editor.scheduling.schedule_later') }}"
description="{{ __('hub::hub.content_editor.scheduling.schedule_description') }}"
/>
@if($isScheduled)
<core:input
wire:model="publishAt"
type="datetime-local"
label="{{ __('hub::hub.content_editor.scheduling.publish_date') }}"
/>
@endif
</div>
<hr class="my-6 border-gray-200 dark:border-gray-700">
{{-- Categories (AC9) --}}
<div class="space-y-3">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">{{ __('hub::hub.content_editor.categories.title') }}</h3>
@if(count($this->categories) > 0)
<div class="space-y-2 max-h-40 overflow-y-auto">
@foreach($this->categories as $category)
<core:checkbox
wire:click="toggleCategory({{ $category['id'] }})"
:checked="in_array($category['id'], $selectedCategories)"
:label="$category['name']"
/>
@endforeach
</div>
@else
<p class="text-sm text-gray-500">{{ __('hub::hub.content_editor.categories.none') }}</p>
@endif
</div>
<hr class="my-6 border-gray-200 dark:border-gray-700">
{{-- Tags (AC9) --}}
<div class="space-y-3">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">{{ __('hub::hub.content_editor.tags.title') }}</h3>
{{-- Selected Tags --}}
@if(count($selectedTags) > 0)
<div class="flex flex-wrap gap-2">
@foreach($this->tags as $tag)
@if(in_array($tag['id'], $selectedTags))
<core:badge
color="violet"
size="sm"
removable
wire:click="removeTag({{ $tag['id'] }})"
>
{{ $tag['name'] }}
</core:badge>
@endif
@endforeach
</div>
@endif
{{-- Add New Tag --}}
<div class="flex gap-2">
<core:input
wire:model="newTag"
wire:keydown.enter="addTag"
placeholder="{{ __('hub::hub.content_editor.tags.add_placeholder') }}"
size="sm"
class="flex-1"
/>
<core:button wire:click="addTag" size="sm" variant="ghost" icon="plus"/>
</div>
{{-- Existing Tags to Select --}}
@if(count($this->tags) > 0)
<div class="flex flex-wrap gap-1">
@foreach($this->tags as $tag)
@if(!in_array($tag['id'], $selectedTags))
<button
wire:click="$set('selectedTags', [...$selectedTags, {{ $tag['id'] }}])"
class="text-xs text-gray-500 hover:text-violet-600 hover:bg-violet-50 px-2 py-1 rounded transition"
>
+ {{ $tag['name'] }}
</button>
@endif
@endforeach
</div>
@endif
</div>
</div>
{{-- SEO Panel (AC10) --}}
<div x-show="activeSidebar === 'seo'" x-cloak class="space-y-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">{{ __('hub::hub.content_editor.seo.title') }}</h3>
<core:input
wire:model="seoTitle"
label="{{ __('hub::hub.content_editor.seo.meta_title') }}"
description="{{ __('hub::hub.content_editor.seo.meta_title_description') }}"
placeholder="{{ $title ?: __('hub::hub.content_editor.seo.meta_title_placeholder') }}"
/>
<div class="text-xs text-gray-500">
{{ __('hub::hub.content_editor.seo.characters', ['count' => strlen($seoTitle), 'max' => 70]) }}
</div>
<core:textarea
wire:model="seoDescription"
label="{{ __('hub::hub.content_editor.seo.meta_description') }}"
description="{{ __('hub::hub.content_editor.seo.meta_description_description') }}"
rows="3"
placeholder="{{ __('hub::hub.content_editor.seo.meta_description_placeholder') }}"
/>
<div class="text-xs text-gray-500">
{{ __('hub::hub.content_editor.seo.characters', ['count' => strlen($seoDescription), 'max' => 160]) }}
</div>
<core:input
wire:model="seoKeywords"
label="{{ __('hub::hub.content_editor.seo.focus_keywords') }}"
placeholder="{{ __('hub::hub.content_editor.seo.focus_keywords_placeholder') }}"
/>
{{-- SEO Preview --}}
<div class="mt-6 p-4 bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700">
<p class="text-xs text-gray-500 mb-2">{{ __('hub::hub.content_editor.seo.preview_title') }}</p>
<div class="text-blue-600 text-lg truncate">
{{ $seoTitle ?: $title ?: __('hub::hub.content_editor.seo.meta_title_placeholder') }}
</div>
<div class="text-green-700 text-sm truncate">
example.com/{{ $slug ?: 'page-url' }}
</div>
<div class="text-gray-600 text-sm line-clamp-2">
{{ $seoDescription ?: $excerpt ?: __('hub::hub.content_editor.seo.preview_description_fallback') }}
</div>
</div>
</div>
{{-- Media Panel (AC8) --}}
<div x-show="activeSidebar === 'media'" x-cloak class="space-y-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">{{ __('hub::hub.content_editor.media.featured_image') }}</h3>
{{-- Current Featured Image --}}
@if($this->featuredMedia)
<div class="relative">
<img
src="{{ $this->featuredMedia->cdn_url ?? $this->featuredMedia->source_url }}"
alt="{{ $this->featuredMedia->alt_text }}"
class="w-full aspect-video object-cover rounded-lg"
>
<button
wire:click="removeFeaturedMedia"
class="absolute top-2 right-2 p-1.5 bg-red-500 text-white rounded-full hover:bg-red-600 transition"
>
<core:icon name="x-mark" class="w-4 h-4"/>
</button>
</div>
@else
{{-- Upload Zone --}}
<div
x-data="{ isDragging: false }"
@dragover.prevent="isDragging = true"
@dragleave="isDragging = false"
@drop.prevent="isDragging = false; $wire.uploadFeaturedImage($event.dataTransfer.files[0])"
:class="isDragging ? 'border-violet-500 bg-violet-50' : 'border-gray-300'"
class="border-2 border-dashed rounded-lg p-6 text-center transition"
>
<core:icon name="photo" class="w-8 h-8 mx-auto text-gray-400 mb-2"/>
<p class="text-sm text-gray-600 mb-2">
{{ __('hub::hub.content_editor.media.drag_drop') }}
</p>
<label class="cursor-pointer">
<span class="text-violet-600 hover:text-violet-700 font-medium">{{ __('hub::hub.content_editor.media.browse') }}</span>
<input
type="file"
wire:model="featuredImageUpload"
accept="image/*"
class="hidden"
>
</label>
</div>
@if($featuredImageUpload)
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600 flex-1 truncate">
{{ $featuredImageUpload->getClientOriginalName() }}
</span>
<core:button wire:click="uploadFeaturedImage" size="sm" variant="primary">
{{ __('hub::hub.content_editor.media.upload') }}
</core:button>
</div>
@endif
@endif
{{-- Media Library --}}
@if(count($this->mediaLibrary) > 0)
<div class="mt-6">
<h4 class="text-xs font-medium text-gray-500 mb-2">{{ __('hub::hub.content_editor.media.select_from_library') }}</h4>
<div class="grid grid-cols-3 gap-2 max-h-48 overflow-y-auto">
@foreach($this->mediaLibrary as $media)
<button
wire:click="setFeaturedMedia({{ $media['id'] }})"
class="aspect-square rounded overflow-hidden border-2 transition {{ $featuredMediaId === $media['id'] ? 'border-violet-500' : 'border-transparent hover:border-gray-300' }}"
>
<img
src="{{ $media['cdn_url'] ?? $media['source_url'] }}"
alt="{{ $media['alt_text'] ?? '' }}"
class="w-full h-full object-cover"
>
</button>
@endforeach
</div>
</div>
@endif
</div>
{{-- Revisions Panel (AC12) --}}
<div x-show="activeSidebar === 'revisions'" x-cloak class="space-y-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">{{ __('hub::hub.content_editor.revisions.title') }}</h3>
@if($contentId)
@if(count($revisions) > 0)
<div class="space-y-2">
@foreach($revisions as $revision)
<div class="p-3 bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between mb-1">
<core:badge
:color="match($revision['change_type']) {
'publish' => 'green',
'edit' => 'blue',
'restore' => 'orange',
'schedule' => 'violet',
default => 'zinc'
}"
size="sm"
>
{{ ucfirst($revision['change_type']) }}
</core:badge>
<span class="text-xs text-gray-500">
#{{ $revision['revision_number'] }}
</span>
</div>
<p class="text-sm text-gray-900 dark:text-white truncate">
{{ $revision['title'] }}
</p>
<div class="flex items-center justify-between mt-2">
<span class="text-xs text-gray-500">
{{ \Carbon\Carbon::parse($revision['created_at'])->diffForHumans() }}
</span>
<core:button
wire:click="restoreRevision({{ $revision['id'] }})"
size="xs"
variant="ghost"
>
{{ __('hub::hub.content_editor.revisions.restore') }}
</core:button>
</div>
@if($revision['word_count'])
<p class="text-xs text-gray-400 mt-1">
{{ number_format($revision['word_count']) }} words
</p>
@endif
</div>
@endforeach
</div>
@else
<p class="text-sm text-gray-500">{{ __('hub::hub.content_editor.revisions.no_revisions') }}</p>
@endif
@else
<p class="text-sm text-gray-500">{{ __('hub::hub.content_editor.revisions.save_first') }}</p>
@endif
</div>
</div>
</div>
</div>
{{-- AI Command Palette Modal --}}
<core:modal wire:model.self="showCommand" variant="bare" class="w-full max-w-2xl">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-2xl overflow-hidden">
{{-- Search Input --}}
<div class="border-b border-gray-200 dark:border-gray-700">
<core:command>
<core:command.input
wire:model.live.debounce.300ms="commandSearch"
placeholder="{{ __('hub::hub.content_editor.ai.command_placeholder') }}"
autofocus
/>
</core:command>
</div>
{{-- Quick Actions --}}
@if(empty($commandSearch) && !$selectedPromptId)
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">
{{ __('hub::hub.content_editor.ai.quick_actions') }}
</h3>
<div class="grid grid-cols-2 gap-2">
@foreach($this->quickActions as $action)
<button
wire:click="executeQuickAction('{{ $action['prompt'] }}', {{ json_encode($action['variables']) }})"
class="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 text-left transition"
>
<div class="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-lg bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400">
<core:icon :name="$action['icon']" class="w-4 h-4"/>
</div>
<div>
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ $action['name'] }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ $action['description'] }}
</div>
</div>
</button>
@endforeach
</div>
</div>
@endif
{{-- Prompt List --}}
@if(!$selectedPromptId)
<div class="max-h-80 overflow-y-auto">
@foreach($this->prompts as $category => $categoryPrompts)
<div class="p-2">
<h3 class="px-3 py-2 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
{{ ucfirst($category) }}
</h3>
@foreach($categoryPrompts as $prompt)
<core:command.item
wire:click="selectPrompt({{ $prompt['id'] }})"
icon="sparkles"
>
<div class="flex-1">
<div class="font-medium">{{ $prompt['name'] }}</div>
<div class="text-xs text-gray-500">{{ $prompt['description'] }}</div>
</div>
<core:badge size="sm"
color="{{ $prompt['model'] === 'claude' ? 'orange' : 'blue' }}">
{{ $prompt['model'] }}
</core:badge>
</core:command.item>
@endforeach
</div>
@endforeach
</div>
@endif
{{-- Prompt Variables Form --}}
@if($selectedPromptId)
@php $selectedPrompt = \App\Models\Prompt::find($selectedPromptId); @endphp
<div class="p-4 space-y-4">
<div class="flex items-center gap-3 mb-4">
<button wire:click="$set('selectedPromptId', null)" class="text-gray-400 hover:text-gray-600">
<core:icon name="arrow-left" class="w-5 h-5"/>
</button>
<div>
<h3 class="font-medium text-gray-900 dark:text-white">
{{ $selectedPrompt->name }}
</h3>
<p class="text-sm text-gray-500">{{ $selectedPrompt->description }}</p>
</div>
</div>
@if($selectedPrompt->variables)
@foreach($selectedPrompt->variables as $name => $config)
@if($name !== 'content')
<div>
@if(($config['type'] ?? 'string') === 'string')
<core:input
wire:model="promptVariables.{{ $name }}"
label="{{ ucfirst(str_replace('_', ' ', $name)) }}"
description="{{ $config['description'] ?? '' }}"
/>
@elseif(($config['type'] ?? 'string') === 'boolean')
<core:checkbox
wire:model="promptVariables.{{ $name }}"
label="{{ ucfirst(str_replace('_', ' ', $name)) }}"
description="{{ $config['description'] ?? '' }}"
/>
@endif
</div>
@endif
@endforeach
@endif
<div class="flex justify-end gap-2 pt-4">
<core:button wire:click="closeCommand" variant="ghost">
{{ __('hub::hub.content_editor.ai.cancel') }}
</core:button>
<core:button
wire:click="executePrompt"
variant="primary"
wire:loading.attr="disabled"
>
<span wire:loading.remove wire:target="executePrompt">{{ __('hub::hub.content_editor.ai.run') }}</span>
<span wire:loading wire:target="executePrompt">{{ __('hub::hub.content_editor.ai.processing') }}</span>
</core:button>
</div>
</div>
@endif
{{-- AI Result --}}
@if($aiResult)
<div class="p-4 border-t border-gray-200 dark:border-gray-700">
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-2">
{{ __('hub::hub.content_editor.ai.result_title') }}
</h3>
<div class="p-4 bg-gray-50 dark:bg-gray-900 rounded-lg max-h-60 overflow-y-auto">
<div class="prose prose-sm dark:prose-invert max-w-none">
{!! nl2br(e($aiResult)) !!}
</div>
</div>
<div class="flex justify-end gap-2 mt-4">
<core:button wire:click="$set('aiResult', null)" variant="ghost" size="sm">
{{ __('hub::hub.content_editor.ai.discard') }}
</core:button>
<core:button wire:click="insertAiResult" variant="ghost" size="sm">
{{ __('hub::hub.content_editor.ai.insert') }}
</core:button>
<core:button wire:click="applyAiResult" variant="primary" size="sm">
{{ __('hub::hub.content_editor.ai.replace_content') }}
</core:button>
</div>
</div>
@endif
{{-- Processing Indicator --}}
@if($aiProcessing)
<div class="p-8 text-center">
<div class="inline-flex items-center gap-3">
<svg class="animate-spin h-5 w-5 text-violet-600" 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>
<span class="text-gray-600 dark:text-gray-300">{{ __('hub::hub.content_editor.ai.thinking') }}</span>
</div>
</div>
@endif
{{-- Footer --}}
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 text-xs text-gray-500 dark:text-gray-400">
<div class="flex items-center justify-between">
<span>{!! __('hub::hub.content_editor.ai.footer_close', ['key' => '<kbd class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-700 rounded">Esc</kbd>']) !!}</span>
<span>{{ __('hub::hub.content_editor.ai.footer_powered') }}</span>
</div>
</div>
</div>
</core:modal>
</div>

View file

@ -0,0 +1,161 @@
<div>
<!-- Page Header -->
<div class="sm:flex sm:justify-between sm:items-center mb-8">
<div class="mb-4 sm:mb-0">
<div class="flex items-center gap-3">
<core:heading size="xl">{{ __('hub::hub.content_manager.title') }}</core:heading>
@if($currentWorkspace)
<core:badge color="violet" icon="server">
{{ $currentWorkspace->name }}
</core:badge>
@endif
</div>
<core:subheading>{{ __('hub::hub.content_manager.subtitle') }}</core:subheading>
</div>
<!-- Actions -->
<div class="flex items-center gap-3">
@if($syncMessage)
<core:text size="sm" class="{{ str_contains($syncMessage, 'failed') ? 'text-red-500' : 'text-green-500' }}">
{{ $syncMessage }}
</core:text>
@endif
<core:button href="{{ route('hub.content-editor.create', ['workspace' => $workspaceSlug, 'contentType' => 'hostuk']) }}" variant="primary" icon="plus">
{{ __('hub::hub.content_manager.actions.new_content') }}
</core:button>
<core:button wire:click="syncAll" wire:loading.attr="disabled" icon="arrow-path" :loading="$syncing">
{{ __('hub::hub.content_manager.actions.sync_all') }}
</core:button>
<core:button wire:click="purgeCache" variant="ghost" icon="trash">
{{ __('hub::hub.content_manager.actions.purge_cdn') }}
</core:button>
</div>
</div>
<!-- View Tabs -->
<admin:tabs :tabs="$this->tabs" :selected="$view" />
<!-- Tab Content -->
@if($view === 'dashboard')
@include('hub::admin.content-manager.dashboard')
@elseif($view === 'kanban')
@include('hub::admin.content-manager.kanban')
@elseif($view === 'calendar')
@include('hub::admin.content-manager.calendar')
@elseif($view === 'list')
@include('hub::admin.content-manager.list')
@elseif($view === 'webhooks')
@include('hub::admin.content-manager.webhooks')
@endif
<!-- Command Palette (Cmd+K) -->
<core:command class="hidden">
<core:command.input placeholder="{{ __('hub::hub.content_manager.command.placeholder') }}" />
<core:command.items>
<core:command.item icon="arrow-path" wire:click="syncAll">{{ __('hub::hub.content_manager.command.sync_all') }}</core:command.item>
<core:command.item icon="trash" wire:click="purgeCache">{{ __('hub::hub.content_manager.command.purge_cache') }}</core:command.item>
<core:command.item icon="arrow-top-right-on-square" href="{{ route('hub.content', ['workspace' => $workspaceSlug, 'type' => 'posts']) }}">{{ __('hub::hub.content_manager.command.open_wordpress') }}</core:command.item>
</core:command.items>
<core:command.empty>{{ __('hub::hub.content_manager.command.no_results') }}</core:command.empty>
</core:command>
<!-- Preview Slide-over -->
<core:modal name="content-preview" variant="flyout" class="max-w-2xl">
@if($this->selectedItem)
<!-- Header -->
<div class="mb-6">
<core:heading size="lg">{{ $this->selectedItem->title }}</core:heading>
</div>
<!-- Body -->
<div class="space-y-6">
<!-- Meta Badges -->
<div class="flex flex-wrap gap-2">
<x-content.status-badge :status="$this->selectedItem->status" />
<x-content.type-badge :type="$this->selectedItem->type" />
<x-content.sync-badge :status="$this->selectedItem->sync_status">
{{ __('hub::hub.content_manager.preview.sync_label') }}: {{ ucfirst($this->selectedItem->sync_status) }}
</x-content.sync-badge>
</div>
<!-- Author -->
@if($this->selectedItem->author)
<div class="flex items-center gap-3 p-3 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg">
@if($this->selectedItem->author->avatar_url)
<core:avatar src="{{ $this->selectedItem->author->avatar_url }}" />
@else
<core:avatar>{{ substr($this->selectedItem->author->name, 0, 1) }}</core:avatar>
@endif
<div>
<core:heading size="sm">{{ $this->selectedItem->author->name }}</core:heading>
<core:subheading size="xs">{{ __('hub::hub.content_manager.preview.author') }}</core:subheading>
</div>
</div>
@endif
<!-- Excerpt -->
@if($this->selectedItem->excerpt)
<div>
<core:label>{{ __('hub::hub.content_manager.preview.excerpt') }}</core:label>
<core:text class="mt-1">{{ $this->selectedItem->excerpt }}</core:text>
</div>
@endif
<!-- Content Preview -->
<div>
<core:label>{{ __('hub::hub.content_manager.preview.content_clean_html') }}</core:label>
<div class="mt-2 prose dark:prose-invert prose-sm max-w-none p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg max-h-96 overflow-y-auto">
{!! $this->selectedItem->content_html_clean ?: $this->selectedItem->content_html_original !!}
</div>
</div>
<!-- Categories & Tags -->
@if($this->selectedItem->categories->isNotEmpty() || $this->selectedItem->tags->isNotEmpty())
<div>
<core:label>{{ __('hub::hub.content_manager.preview.taxonomies') }}</core:label>
<div class="flex flex-wrap gap-2 mt-2">
@foreach($this->selectedItem->categories as $category)
<core:badge color="violet">{{ $category->name }}</core:badge>
@endforeach
@foreach($this->selectedItem->tags as $tag)
<core:badge color="zinc">#{{ $tag->name }}</core:badge>
@endforeach
</div>
</div>
@endif
<!-- Structured JSON -->
@if($this->selectedItem->content_json)
<div>
<core:label>{{ __('hub::hub.content_manager.preview.structured_content') }}</core:label>
<div class="mt-2 text-xs font-mono p-4 bg-zinc-900 text-zinc-100 rounded-lg max-h-64 overflow-y-auto">
<pre>{{ json_encode($this->selectedItem->content_json, JSON_PRETTY_PRINT) }}</pre>
</div>
</div>
@endif
<!-- Timestamps -->
<core:separator />
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<core:text class="text-zinc-500">{{ __('hub::hub.content_manager.preview.created') }}:</core:text>
<core:text>{{ $this->selectedItem->wp_created_at?->format('M j, Y H:i') ?? '-' }}</core:text>
</div>
<div>
<core:text class="text-zinc-500">{{ __('hub::hub.content_manager.preview.modified') }}:</core:text>
<core:text>{{ $this->selectedItem->wp_modified_at?->format('M j, Y H:i') ?? '-' }}</core:text>
</div>
<div>
<core:text class="text-zinc-500">{{ __('hub::hub.content_manager.preview.last_synced') }}:</core:text>
<core:text>{{ $this->selectedItem->synced_at?->diffForHumans() ?? __('hub::hub.content_manager.preview.never') }}</core:text>
</div>
<div>
<core:text class="text-zinc-500">{{ __('hub::hub.content_manager.preview.wordpress_id') }}:</core:text>
<core:text>#{{ $this->selectedItem->wp_id }}</core:text>
</div>
</div>
</div>
@endif
</core:modal>
</div>

View file

@ -0,0 +1,100 @@
<!-- Calendar View -->
<core:card class="p-6">
@php
$now = now();
$startOfMonth = $now->copy()->startOfMonth();
$endOfMonth = $now->copy()->endOfMonth();
$startDay = $startOfMonth->dayOfWeek;
$daysInMonth = $now->daysInMonth;
// Group events by date
$eventsByDate = collect($this->calendarEvents)->groupBy('date');
@endphp
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<core:heading>{{ $now->format('F Y') }}</core:heading>
<core:subheading>{{ __('hub::hub.content_manager.calendar.content_schedule') }}</core:subheading>
</div>
<div class="flex items-center gap-4">
<div class="flex items-center gap-1.5">
<div class="w-2 h-2 rounded-full bg-green-500"></div>
<core:text size="xs">{{ __('hub::hub.content_manager.calendar.legend.published') }}</core:text>
</div>
<div class="flex items-center gap-1.5">
<div class="w-2 h-2 rounded-full bg-yellow-500"></div>
<core:text size="xs">{{ __('hub::hub.content_manager.calendar.legend.draft') }}</core:text>
</div>
<div class="flex items-center gap-1.5">
<div class="w-2 h-2 rounded-full bg-blue-500"></div>
<core:text size="xs">{{ __('hub::hub.content_manager.calendar.legend.scheduled') }}</core:text>
</div>
</div>
</div>
<!-- Body -->
<!-- Weekday Headers -->
<div class="grid grid-cols-7 gap-1 mb-2">
@foreach([
__('hub::hub.content_manager.calendar.days.sun'),
__('hub::hub.content_manager.calendar.days.mon'),
__('hub::hub.content_manager.calendar.days.tue'),
__('hub::hub.content_manager.calendar.days.wed'),
__('hub::hub.content_manager.calendar.days.thu'),
__('hub::hub.content_manager.calendar.days.fri'),
__('hub::hub.content_manager.calendar.days.sat')
] as $day)
<div class="text-center text-xs font-medium text-zinc-500 dark:text-zinc-400 py-2">
{{ $day }}
</div>
@endforeach
</div>
<!-- Calendar Grid -->
<div class="grid grid-cols-7 gap-1">
{{-- Empty cells for days before start of month --}}
@for($i = 0; $i < $startDay; $i++)
<div class="aspect-square p-1 bg-zinc-50 dark:bg-zinc-800/30 rounded-lg"></div>
@endfor
{{-- Days of the month --}}
@for($day = 1; $day <= $daysInMonth; $day++)
@php
$dateStr = $now->copy()->setDay($day)->format('Y-m-d');
$dayEvents = $eventsByDate->get($dateStr, collect());
$isToday = $now->copy()->setDay($day)->isToday();
@endphp
<div class="aspect-square p-1 {{ $isToday ? 'bg-violet-50 dark:bg-violet-500/10 ring-2 ring-violet-500' : 'bg-zinc-50 dark:bg-zinc-800/30' }} rounded-lg overflow-hidden">
<div class="text-xs font-medium {{ $isToday ? 'text-violet-600 dark:text-violet-400' : 'text-zinc-600 dark:text-zinc-400' }} mb-1">
{{ $day }}
</div>
<div class="space-y-0.5 max-h-16 overflow-hidden">
@foreach($dayEvents->take(3) as $event)
<button wire:click="selectItem({{ $event['id'] }})"
class="w-full text-left text-xs px-1 py-0.5 rounded truncate cursor-pointer
{{ $event['status'] === 'publish' ? 'bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400' :
($event['status'] === 'future' ? 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400' :
'bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400') }}">
{{ Str::limit($event['title'], 15) }}
</button>
@endforeach
@if($dayEvents->count() > 3)
<div class="text-xs text-zinc-400 dark:text-zinc-500 px-1">
{{ __('hub::hub.content_manager.calendar.more', ['count' => $dayEvents->count() - 3]) }}
</div>
@endif
</div>
</div>
@endfor
{{-- Empty cells for days after end of month --}}
@php
$remainingCells = 7 - (($startDay + $daysInMonth) % 7);
if ($remainingCells == 7) $remainingCells = 0;
@endphp
@for($i = 0; $i < $remainingCells; $i++)
<div class="aspect-square p-1 bg-zinc-50 dark:bg-zinc-800/30 rounded-lg"></div>
@endfor
</div>
</core:card>

View file

@ -0,0 +1,240 @@
<!-- Stats Grid -->
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 mb-8">
<core:card class="p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-violet-100 dark:bg-violet-500/20">
<core:icon name="document-text" class="text-violet-600 dark:text-violet-400" />
</div>
<div>
<core:heading size="xl">{{ $this->stats['total'] }}</core:heading>
<core:subheading size="sm">{{ __('hub::hub.content_manager.dashboard.total_content') }}</core:subheading>
</div>
</div>
</core:card>
<core:card class="p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-blue-100 dark:bg-blue-500/20">
<core:icon name="newspaper" class="text-blue-600 dark:text-blue-400" />
</div>
<div>
<core:heading size="xl">{{ $this->stats['posts'] }}</core:heading>
<core:subheading size="sm">{{ __('hub::hub.content_manager.dashboard.posts') }}</core:subheading>
</div>
</div>
</core:card>
<core:card class="p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-green-100 dark:bg-green-500/20">
<core:icon name="check-circle" class="text-green-600 dark:text-green-400" />
</div>
<div>
<core:heading size="xl">{{ $this->stats['published'] }}</core:heading>
<core:subheading size="sm">{{ __('hub::hub.content_manager.dashboard.published') }}</core:subheading>
</div>
</div>
</core:card>
<core:card class="p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-yellow-100 dark:bg-yellow-500/20">
<core:icon name="pencil" class="text-yellow-600 dark:text-yellow-400" />
</div>
<div>
<core:heading size="xl">{{ $this->stats['drafts'] }}</core:heading>
<core:subheading size="sm">{{ __('hub::hub.content_manager.dashboard.drafts') }}</core:subheading>
</div>
</div>
</core:card>
<core:card class="p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-cyan-100 dark:bg-cyan-500/20">
<core:icon name="arrow-path" class="text-cyan-600 dark:text-cyan-400" />
</div>
<div>
<core:heading size="xl">{{ $this->stats['synced'] }}</core:heading>
<core:subheading size="sm">{{ __('hub::hub.content_manager.dashboard.synced') }}</core:subheading>
</div>
</div>
</core:card>
<core:card class="p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-red-100 dark:bg-red-500/20">
<core:icon name="exclamation-circle" class="text-red-600 dark:text-red-400" />
</div>
<div>
<core:heading size="xl">{{ $this->stats['failed'] }}</core:heading>
<core:subheading size="sm">{{ __('hub::hub.content_manager.dashboard.failed') }}</core:subheading>
</div>
</div>
</core:card>
</div>
<!-- Charts Row -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<!-- Content Over Time Chart -->
<core:card class="p-6">
<div class="mb-4">
<core:heading>{{ __('hub::hub.content_manager.dashboard.content_created') }}</core:heading>
</div>
<div class="h-64">
<core:chart :value="$this->chartData" class="h-full">
<core:chart.viewport class="h-48">
<core:chart.svg>
<core:chart.line field="count" class="text-violet-500" />
<core:chart.area field="count" class="text-violet-500/20" />
</core:chart.svg>
<core:chart.cursor>
<core:chart.tooltip>
<core:chart.tooltip.heading field="date" :format="['month' => 'short', 'day' => 'numeric']" />
<core:chart.tooltip.value field="count" label="{{ __('hub::hub.content_manager.dashboard.tooltip_posts') }}" />
</core:chart.tooltip>
</core:chart.cursor>
</core:chart.viewport>
<core:chart.axis axis="x" field="date" :format="['month' => 'short', 'day' => 'numeric']" />
</core:chart>
</div>
</core:card>
<!-- Content by Type Chart -->
<core:card class="p-6">
<div class="mb-4">
<core:heading>{{ __('hub::hub.content_manager.dashboard.content_by_type') }}</core:heading>
</div>
<div>
<div class="space-y-4">
@php
$total = $this->stats['posts'] + $this->stats['pages'];
$postsPercent = $total > 0 ? round(($this->stats['posts'] / $total) * 100) : 0;
$pagesPercent = $total > 0 ? round(($this->stats['pages'] / $total) * 100) : 0;
@endphp
<div>
<div class="flex justify-between text-sm mb-1">
<span class="font-medium text-zinc-700 dark:text-zinc-300">{{ __('hub::hub.content_manager.dashboard.posts') }}</span>
<span class="text-zinc-500">{{ $this->stats['posts'] }} ({{ $postsPercent }}%)</span>
</div>
<div class="w-full bg-zinc-200 dark:bg-zinc-700 rounded-full h-2">
<div class="bg-violet-500 h-2 rounded-full transition-all duration-300" style="width: {{ $postsPercent }}%"></div>
</div>
</div>
<div>
<div class="flex justify-between text-sm mb-1">
<span class="font-medium text-zinc-700 dark:text-zinc-300">{{ __('hub::hub.content_manager.dashboard.pages') }}</span>
<span class="text-zinc-500">{{ $this->stats['pages'] }} ({{ $pagesPercent }}%)</span>
</div>
<div class="w-full bg-zinc-200 dark:bg-zinc-700 rounded-full h-2">
<div class="bg-cyan-500 h-2 rounded-full transition-all duration-300" style="width: {{ $pagesPercent }}%"></div>
</div>
</div>
</div>
<core:separator class="my-6" />
<div class="grid grid-cols-2 gap-4 text-center">
<div>
<core:heading size="xl">{{ $this->stats['categories'] }}</core:heading>
<core:subheading size="sm">{{ __('hub::hub.content_manager.dashboard.categories') }}</core:subheading>
</div>
<div>
<core:heading size="xl">{{ $this->stats['tags'] }}</core:heading>
<core:subheading size="sm">{{ __('hub::hub.content_manager.dashboard.tags') }}</core:subheading>
</div>
</div>
</div>
</core:card>
</div>
<!-- Sync Status Overview -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
<core:card class="p-6">
<div class="mb-4">
<core:heading>{{ __('hub::hub.content_manager.dashboard.sync_status') }}</core:heading>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full bg-green-500"></div>
<core:text>{{ __('hub::hub.content_manager.dashboard.synced') }}</core:text>
</div>
<core:badge color="green">{{ $this->stats['synced'] }}</core:badge>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full bg-yellow-500"></div>
<core:text>{{ __('hub::hub.content_manager.dashboard.pending') }}</core:text>
</div>
<core:badge color="yellow">{{ $this->stats['pending'] }}</core:badge>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full bg-orange-500"></div>
<core:text>{{ __('hub::hub.content_manager.dashboard.stale') }}</core:text>
</div>
<core:badge color="orange">{{ $this->stats['stale'] }}</core:badge>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full bg-red-500"></div>
<core:text>{{ __('hub::hub.content_manager.dashboard.failed') }}</core:text>
</div>
<core:badge color="red">{{ $this->stats['failed'] }}</core:badge>
</div>
</div>
</core:card>
<core:card class="p-6">
<div class="mb-4">
<core:heading>{{ __('hub::hub.content_manager.dashboard.taxonomies') }}</core:heading>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<core:icon name="folder" class="text-violet-500" />
<core:text>{{ __('hub::hub.content_manager.dashboard.categories') }}</core:text>
</div>
<core:badge>{{ $this->stats['categories'] }}</core:badge>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<core:icon name="hashtag" class="text-blue-500" />
<core:text>{{ __('hub::hub.content_manager.dashboard.tags') }}</core:text>
</div>
<core:badge>{{ $this->stats['tags'] }}</core:badge>
</div>
</div>
</core:card>
<core:card class="p-6">
<div class="mb-4">
<core:heading>{{ __('hub::hub.content_manager.dashboard.webhooks_today') }}</core:heading>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<core:icon name="bolt" class="text-cyan-500" />
<core:text>{{ __('hub::hub.content_manager.dashboard.received') }}</core:text>
</div>
<core:badge color="cyan">{{ $this->stats['webhooks_today'] }}</core:badge>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<core:icon name="exclamation-circle" class="text-red-500" />
<core:text>{{ __('hub::hub.content_manager.dashboard.failed') }}</core:text>
</div>
<core:badge color="red">{{ $this->stats['webhooks_failed'] }}</core:badge>
</div>
</div>
</core:card>
</div>

View file

@ -0,0 +1,58 @@
<!-- Kanban Board -->
<core:kanban class="overflow-x-auto pb-4" style="min-height: 600px;">
@foreach($this->kanbanColumns as $column)
<core:kanban.column>
<core:kanban.column.header
:heading="$column['name']"
:count="$column['items']->count()"
:badge="$column['items']->count()"
badge:color="{{ $column['color'] }}"
>
<x-slot:actions>
@if($column['status'] === 'draft')
<core:button size="sm" variant="ghost" icon="plus" />
@endif
</x-slot:actions>
</core:kanban.column.header>
<core:kanban.column.cards class="max-h-[calc(100vh-300px)] overflow-y-auto">
@forelse($column['items'] as $item)
<core:kanban.card as="button" wire:click="selectItem({{ $item->id }})">
<x-slot:header>
<x-content.type-badge :type="$item->type" />
<x-content.sync-badge :status="$item->sync_status" />
</x-slot:header>
<core:heading size="sm" class="line-clamp-2">{{ $item->title }}</core:heading>
@if($item->excerpt)
<core:text size="sm" class="line-clamp-2 mt-1">
{{ Str::limit($item->excerpt, 80) }}
</core:text>
@endif
<x-slot:footer>
@if($item->categories && $item->categories->isNotEmpty())
@foreach($item->categories->take(2) as $category)
<core:badge size="sm" color="zinc">{{ $category->name }}</core:badge>
@endforeach
@if($item->categories->count() > 2)
<core:badge size="sm" color="zinc">+{{ $item->categories->count() - 2 }}</core:badge>
@endif
@endif
<div class="flex-1"></div>
<core:text size="xs" class="text-zinc-400">
{{ $item->wp_created_at?->format('M j') ?? '-' }}
</core:text>
</x-slot:footer>
</core:kanban.card>
@empty
<div class="text-center py-8 text-zinc-400 dark:text-zinc-500">
<core:icon name="inbox" class="size-8 mx-auto mb-2 opacity-50" />
<core:text size="sm">{{ __('hub::hub.content_manager.kanban.no_items') }}</core:text>
</div>
@endforelse
</core:kanban.column.cards>
</core:kanban.column>
@endforeach
</core:kanban>

View file

@ -0,0 +1,176 @@
<!-- Filters Bar -->
<core:card class="mb-6">
<div class="flex flex-wrap items-center justify-between gap-4 p-4">
<div class="flex flex-wrap items-center gap-3">
<!-- Search -->
<core:input
wire:model.live.debounce.300ms="search"
placeholder="{{ __('hub::hub.content_manager.list.search_placeholder') }}"
icon="magnifying-glass"
class="w-64"
/>
<!-- Type Filter -->
<core:select wire:model.live="type" placeholder="{{ __('hub::hub.content_manager.list.filters.all_types') }}" class="w-32">
<core:select.option value="">{{ __('hub::hub.content_manager.list.filters.all_types') }}</core:select.option>
<core:select.option value="post">{{ __('hub::hub.content_manager.list.filters.posts') }}</core:select.option>
<core:select.option value="page">{{ __('hub::hub.content_manager.list.filters.pages') }}</core:select.option>
</core:select>
<!-- Status Filter -->
<core:select wire:model.live="status" placeholder="{{ __('hub::hub.content_manager.list.filters.all_status') }}" class="w-36">
<core:select.option value="">{{ __('hub::hub.content_manager.list.filters.all_status') }}</core:select.option>
<core:select.option value="publish">{{ __('hub::hub.content_manager.list.filters.published') }}</core:select.option>
<core:select.option value="draft">{{ __('hub::hub.content_manager.list.filters.draft') }}</core:select.option>
<core:select.option value="pending">{{ __('hub::hub.content_manager.list.filters.pending') }}</core:select.option>
<core:select.option value="future">{{ __('hub::hub.content_manager.list.filters.scheduled') }}</core:select.option>
<core:select.option value="private">{{ __('hub::hub.content_manager.list.filters.private') }}</core:select.option>
</core:select>
<!-- Sync Status Filter -->
<core:select wire:model.live="syncStatus" placeholder="{{ __('hub::hub.content_manager.list.filters.all_sync') }}" class="w-36">
<core:select.option value="">{{ __('hub::hub.content_manager.list.filters.all_sync') }}</core:select.option>
<core:select.option value="synced">{{ __('hub::hub.content_manager.list.filters.synced') }}</core:select.option>
<core:select.option value="pending">{{ __('hub::hub.content_manager.list.filters.pending') }}</core:select.option>
<core:select.option value="stale">{{ __('hub::hub.content_manager.list.filters.stale') }}</core:select.option>
<core:select.option value="failed">{{ __('hub::hub.content_manager.list.filters.failed') }}</core:select.option>
</core:select>
<!-- Content Type Filter -->
<core:select wire:model.live="contentType" placeholder="{{ __('hub::hub.content_manager.list.filters.all_sources') }}" class="w-36">
<core:select.option value="">{{ __('hub::hub.content_manager.list.filters.all_sources') }}</core:select.option>
<core:select.option value="native">{{ __('hub::hub.content_manager.list.filters.native') }}</core:select.option>
<core:select.option value="hostuk">{{ __('hub::hub.content_manager.list.filters.host_uk') }}</core:select.option>
<core:select.option value="satellite">{{ __('hub::hub.content_manager.list.filters.satellite') }}</core:select.option>
@if(config('services.content.wordpress_enabled'))
<core:select.option value="wordpress">{{ __('hub::hub.content_manager.list.filters.wordpress_legacy') }}</core:select.option>
@endif
</core:select>
<!-- Category Filter -->
@if(count($this->categories) > 0)
<core:select wire:model.live="category" placeholder="{{ __('hub::hub.content_manager.list.filters.all_categories') }}" class="w-40">
<core:select.option value="">{{ __('hub::hub.content_manager.list.filters.all_categories') }}</core:select.option>
@foreach($this->categories as $slug => $name)
<core:select.option value="{{ $slug }}">{{ $name }}</core:select.option>
@endforeach
</core:select>
@endif
<!-- Clear Filters -->
@if($search || $type || $status || $syncStatus || $category || $contentType)
<core:button wire:click="clearFilters" variant="ghost" size="sm" icon="x-mark">
{{ __('hub::hub.content_manager.list.filters.clear') }}
</core:button>
@endif
</div>
</div>
</core:card>
<!-- Content Table -->
<core:card>
<core:table :paginate="$this->content">
<core:table.columns>
<core:table.column
:sortable="true"
:sorted="$sort === 'title'"
:direction="$sort === 'title' ? $dir : null"
wire:click="setSort('title')"
>
{{ __('hub::hub.content_manager.list.columns.title') }}
</core:table.column>
<core:table.column class="hidden md:table-cell">{{ __('hub::hub.content_manager.list.columns.type') }}</core:table.column>
<core:table.column class="hidden md:table-cell">{{ __('hub::hub.content_manager.list.columns.status') }}</core:table.column>
<core:table.column class="hidden lg:table-cell">{{ __('hub::hub.content_manager.list.columns.sync') }}</core:table.column>
<core:table.column class="hidden lg:table-cell">{{ __('hub::hub.content_manager.list.columns.categories') }}</core:table.column>
<core:table.column
class="hidden xl:table-cell"
:sortable="true"
:sorted="$sort === 'wp_created_at'"
:direction="$sort === 'wp_created_at' ? $dir : null"
wire:click="setSort('wp_created_at')"
>
{{ __('hub::hub.content_manager.list.columns.created') }}
</core:table.column>
<core:table.column
class="hidden xl:table-cell"
:sortable="true"
:sorted="$sort === 'synced_at'"
:direction="$sort === 'synced_at' ? $dir : null"
wire:click="setSort('synced_at')"
>
{{ __('hub::hub.content_manager.list.columns.last_synced') }}
</core:table.column>
<core:table.column align="end"></core:table.column>
</core:table.columns>
<core:table.rows>
@forelse($this->content as $item)
<core:table.row :key="$item->id">
<core:table.cell variant="strong">
<div class="min-w-0">
<button wire:click="selectItem({{ $item->id }})" class="font-medium hover:text-violet-600 dark:hover:text-violet-400 truncate text-left">
{{ $item->title }}
</button>
<core:text size="xs" class="truncate">{{ $item->slug }}</core:text>
</div>
</core:table.cell>
<core:table.cell class="hidden md:table-cell">
<x-content.type-badge :type="$item->type" />
</core:table.cell>
<core:table.cell class="hidden md:table-cell">
<x-content.status-badge :status="$item->status" />
</core:table.cell>
<core:table.cell class="hidden lg:table-cell">
<x-content.sync-badge :status="$item->sync_status" />
</core:table.cell>
<core:table.cell class="hidden lg:table-cell">
<div class="flex flex-wrap gap-1">
@foreach($item->categories->take(2) as $category)
<core:badge color="violet" size="sm">{{ $category->name }}</core:badge>
@endforeach
@if($item->categories->count() > 2)
<core:badge color="zinc" size="sm">+{{ $item->categories->count() - 2 }}</core:badge>
@endif
</div>
</core:table.cell>
<core:table.cell class="hidden xl:table-cell">
<core:text size="sm">{{ $item->wp_created_at?->format('M j, Y') ?? '-' }}</core:text>
</core:table.cell>
<core:table.cell class="hidden xl:table-cell">
<core:text size="sm">{{ $item->synced_at?->diffForHumans() ?? __('hub::hub.content_manager.list.never') }}</core:text>
</core:table.cell>
<core:table.cell align="end">
<div class="flex items-center gap-1">
@if($item->usesFluxEditor())
<core:button href="{{ route('hub.content-editor.edit', ['workspace' => $workspaceSlug, 'id' => $item->id]) }}" variant="ghost" size="sm" icon="pencil" title="{{ __('hub::hub.content_manager.list.edit') }}" />
@endif
<core:button wire:click="selectItem({{ $item->id }})" variant="ghost" size="sm" icon="eye" title="{{ __('hub::hub.content_manager.list.preview') }}" />
</div>
</core:table.cell>
</core:table.row>
@empty
<core:table.row>
<core:table.cell colspan="8" class="text-center py-12">
<div class="flex flex-col items-center">
<core:icon name="inbox" class="size-12 text-zinc-300 dark:text-zinc-600 mb-3" />
<core:text>{{ __('hub::hub.content_manager.list.no_content') }}</core:text>
@if($search || $type || $status || $syncStatus || $category)
<core:button wire:click="clearFilters" variant="ghost" size="sm" class="mt-2">
{{ __('hub::hub.content_manager.list.filters.clear_filters') }}
</core:button>
@endif
</div>
</core:table.cell>
</core:table.row>
@endforelse
</core:table.rows>
</core:table>
</core:card>

View file

@ -0,0 +1,165 @@
<!-- Webhooks Overview -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<core:card class="p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-cyan-100 dark:bg-cyan-500/20">
<core:icon name="bolt" class="text-cyan-600 dark:text-cyan-400" />
</div>
<div>
<core:heading size="xl">{{ $this->stats['webhooks_today'] }}</core:heading>
<core:subheading size="sm">{{ __('hub::hub.content_manager.webhooks.today') }}</core:subheading>
</div>
</div>
</core:card>
<core:card class="p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-green-100 dark:bg-green-500/20">
<core:icon name="check" class="text-green-600 dark:text-green-400" />
</div>
<div>
<core:heading size="xl">{{ $this->webhookLogs->where('status', 'completed')->count() }}</core:heading>
<core:subheading size="sm">{{ __('hub::hub.content_manager.webhooks.completed') }}</core:subheading>
</div>
</div>
</core:card>
<core:card class="p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-yellow-100 dark:bg-yellow-500/20">
<core:icon name="clock" class="text-yellow-600 dark:text-yellow-400" />
</div>
<div>
<core:heading size="xl">{{ $this->webhookLogs->where('status', 'pending')->count() }}</core:heading>
<core:subheading size="sm">{{ __('hub::hub.content_manager.webhooks.pending') }}</core:subheading>
</div>
</div>
</core:card>
<core:card class="p-4">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-red-100 dark:bg-red-500/20">
<core:icon name="exclamation-circle" class="text-red-600 dark:text-red-400" />
</div>
<div>
<core:heading size="xl">{{ $this->stats['webhooks_failed'] }}</core:heading>
<core:subheading size="sm">{{ __('hub::hub.content_manager.webhooks.failed') }}</core:subheading>
</div>
</div>
</core:card>
</div>
<!-- Webhook Logs Table -->
<core:card>
<core:table :paginate="$this->webhookLogs">
<core:table.columns>
<core:table.column>{{ __('hub::hub.content_manager.webhooks.columns.id') }}</core:table.column>
<core:table.column>{{ __('hub::hub.content_manager.webhooks.columns.event') }}</core:table.column>
<core:table.column class="hidden md:table-cell">{{ __('hub::hub.content_manager.webhooks.columns.content') }}</core:table.column>
<core:table.column>{{ __('hub::hub.content_manager.webhooks.columns.status') }}</core:table.column>
<core:table.column class="hidden lg:table-cell">{{ __('hub::hub.content_manager.webhooks.columns.source_ip') }}</core:table.column>
<core:table.column class="hidden lg:table-cell">{{ __('hub::hub.content_manager.webhooks.columns.received') }}</core:table.column>
<core:table.column class="hidden xl:table-cell">{{ __('hub::hub.content_manager.webhooks.columns.processed') }}</core:table.column>
<core:table.column align="end"></core:table.column>
</core:table.columns>
<core:table.rows>
@forelse($this->webhookLogs as $log)
<core:table.row :key="$log->id">
<core:table.cell>
<core:text class="text-zinc-500">#{{ $log->id }}</core:text>
</core:table.cell>
<core:table.cell variant="strong">
{{ $log->event_type }}
</core:table.cell>
<core:table.cell class="hidden md:table-cell">
<div class="flex items-center gap-2">
<core:badge color="blue" size="sm">{{ $log->content_type }}</core:badge>
<core:text size="sm" class="text-zinc-500">#{{ $log->wp_id }}</core:text>
</div>
</core:table.cell>
<core:table.cell>
<x-content.webhook-badge :status="$log->status" />
</core:table.cell>
<core:table.cell class="hidden lg:table-cell">
<core:text size="sm" class="font-mono text-zinc-500">{{ $log->source_ip }}</core:text>
</core:table.cell>
<core:table.cell class="hidden lg:table-cell">
<core:text size="sm">{{ $log->created_at->diffForHumans() }}</core:text>
</core:table.cell>
<core:table.cell class="hidden xl:table-cell">
<core:text size="sm">{{ $log->processed_at?->diffForHumans() ?? '-' }}</core:text>
</core:table.cell>
<core:table.cell align="end">
<core:dropdown>
<core:button variant="ghost" size="sm" icon="ellipsis-horizontal" />
<core:menu>
@if($log->status === 'failed')
<core:menu.item wire:click="retryWebhook({{ $log->id }})" icon="arrow-path">
{{ __('hub::hub.content_manager.webhooks.actions.retry') }}
</core:menu.item>
@endif
<core:menu.item x-on:click="$dispatch('show-payload', { payload: {{ json_encode($log->payload) }} })" icon="code-bracket">
{{ __('hub::hub.content_manager.webhooks.actions.view_payload') }}
</core:menu.item>
@if($log->error_message)
<core:menu.separator />
<div class="px-3 py-2 text-xs text-red-600 dark:text-red-400">
<strong>{{ __('hub::hub.content_manager.webhooks.error') }}:</strong> {{ Str::limit($log->error_message, 80) }}
</div>
@endif
</core:menu>
</core:dropdown>
</core:table.cell>
</core:table.row>
@empty
<core:table.row>
<core:table.cell colspan="8" class="text-center py-12">
<div class="flex flex-col items-center">
<core:icon name="bolt" class="size-12 text-zinc-300 dark:text-zinc-600 mb-3" />
<core:text>{{ __('hub::hub.content_manager.webhooks.no_logs') }}</core:text>
<core:text size="sm" class="text-zinc-500 mt-1">
{{ __('hub::hub.content_manager.webhooks.no_logs_description') }}
</core:text>
</div>
</core:table.cell>
</core:table.row>
@endforelse
</core:table.rows>
</core:table>
</core:card>
<!-- Webhook Endpoint Info -->
<core:card class="mt-6 p-6">
<core:heading size="sm" class="mb-2">{{ __('hub::hub.content_manager.webhooks.endpoint.title') }}</core:heading>
<div class="bg-zinc-50 dark:bg-zinc-800 px-4 py-3 rounded-lg font-mono text-sm text-violet-600 dark:text-violet-400 overflow-x-auto">
POST {{ url('/api/v1/webhook/content') }}
</div>
<core:text size="sm" class="text-zinc-500 mt-3">
{{ __('hub::hub.content_manager.webhooks.endpoint.description', ['header' => 'X-WP-Signature']) }}
</core:text>
</core:card>
<!-- Payload Modal -->
<div x-data="{ payload: null }"
x-on:show-payload.window="payload = $event.detail.payload; $dispatch('modal-show', { name: 'webhook-payload' })">
<core:modal name="webhook-payload" class="max-w-2xl">
<div class="mb-4">
<core:heading>{{ __('hub::hub.content_manager.webhooks.payload_modal.title') }}</core:heading>
</div>
<div class="font-mono text-xs bg-zinc-900 text-zinc-100 p-4 rounded-lg overflow-auto max-h-96">
<pre x-text="JSON.stringify(payload, null, 2)"></pre>
</div>
</core:modal>
</div>

View file

@ -0,0 +1,298 @@
<div>
<!-- Page Header -->
<div class="sm:flex sm:justify-between sm:items-center mb-8">
<div class="mb-4 sm:mb-0">
<div class="flex items-center gap-3">
<h1 class="text-2xl md:text-3xl text-gray-800 dark:text-gray-100 font-bold">{{ __('hub::hub.content.title') }}</h1>
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-{{ $currentWorkspace['color'] ?? 'violet' }}-500/20 text-{{ $currentWorkspace['color'] ?? 'violet' }}-600 dark:text-{{ $currentWorkspace['color'] ?? 'violet' }}-400">
<core:icon :name="$currentWorkspace['icon'] ?? 'globe'" class="mr-1.5" />
{{ $currentWorkspace['name'] ?? 'Hestia Main' }}
</span>
</div>
<p class="text-gray-500 dark:text-gray-400">{{ __('hub::hub.content.subtitle') }}</p>
</div>
@if($tab !== 'media')
<div class="grid grid-flow-col sm:auto-cols-max justify-start sm:justify-end gap-2">
<button wire:click="createNew" class="btn bg-violet-500 text-white hover:bg-violet-600">
<core:icon name="plus" class="mr-2" />
<span>{{ $tab === 'posts' ? __('hub::hub.content.new_post') : __('hub::hub.content.new_page') }}</span>
</button>
</div>
@endif
</div>
<!-- Tabs -->
<div class="mb-6">
<div class="border-b border-gray-200 dark:border-gray-700">
<nav class="flex gap-x-4" aria-label="Tabs">
<a href="{{ route('hub.content', ['workspace' => $currentWorkspace['slug'] ?? 'main', 'type' => 'posts']) }}"
class="px-3 py-2.5 text-sm font-medium border-b-2 {{ $tab === 'posts' ? 'border-violet-500 text-violet-600 dark:text-violet-400' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300' }}">
<core:icon name="newspaper" class="mr-2" />{{ __('hub::hub.content.tabs.posts') }}
</a>
<a href="{{ route('hub.content', ['workspace' => $currentWorkspace['slug'] ?? 'main', 'type' => 'pages']) }}"
class="px-3 py-2.5 text-sm font-medium border-b-2 {{ $tab === 'pages' ? 'border-violet-500 text-violet-600 dark:text-violet-400' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300' }}">
<core:icon name="file-lines" class="mr-2" />{{ __('hub::hub.content.tabs.pages') }}
</a>
<a href="{{ route('hub.content', ['workspace' => $currentWorkspace['slug'] ?? 'main', 'type' => 'media']) }}"
class="px-3 py-2.5 text-sm font-medium border-b-2 {{ $tab === 'media' ? 'border-violet-500 text-violet-600 dark:text-violet-400' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300' }}">
<core:icon name="images" class="mr-2" />{{ __('hub::hub.content.tabs.media') }}
</a>
</nav>
</div>
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
@foreach ($this->stats as $stat)
<div class="bg-white dark:bg-gray-800 shadow-xs rounded-xl p-4">
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-1">{{ $stat['title'] }}</div>
<div class="text-2xl font-bold text-gray-800 dark:text-gray-100">{{ $stat['value'] }}</div>
<div class="flex items-center gap-1 text-xs font-medium mt-1 {{ $stat['trendUp'] ? 'text-green-500' : 'text-red-500' }}">
<core:icon :name="$stat['trendUp'] ? 'arrow-trend-up' : 'arrow-trend-down'" />
{{ $stat['trend'] }}
</div>
</div>
@endforeach
</div>
<!-- Filters Bar -->
<div class="bg-white dark:bg-gray-800 shadow-xs rounded-xl mb-6">
<div class="flex flex-wrap items-center justify-between gap-4 p-4">
<div class="flex items-center gap-3">
<!-- Status Filter -->
<select wire:model.live="status" class="form-select text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="">{{ __('hub::hub.content.filters.all_status') }}</option>
<option value="publish">{{ __('hub::hub.content.filters.published') }}</option>
<option value="draft">{{ __('hub::hub.content.filters.draft') }}</option>
<option value="pending">{{ __('hub::hub.content.filters.pending') }}</option>
<option value="private">{{ __('hub::hub.content.filters.private') }}</option>
</select>
<!-- Sort Pills -->
<div class="hidden md:flex items-center gap-2">
<span class="text-sm text-gray-500 dark:text-gray-400">{{ __('hub::hub.content.filters.sort') }}:</span>
<button wire:click="setSort('date')" class="px-3 py-1 text-sm rounded-full transition {{ $sort === 'date' ? 'bg-violet-500 text-white' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600' }}">
{{ __('hub::hub.content.filters.date') }} @if($sort === 'date')<core:icon :name="$dir === 'desc' ? 'chevron-down' : 'chevron-up'" class="ml-1 text-xs" />@endif
</button>
<button wire:click="setSort('title')" class="px-3 py-1 text-sm rounded-full transition {{ $sort === 'title' ? 'bg-violet-500 text-white' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600' }}">
{{ __('hub::hub.content.filters.title') }} @if($sort === 'title')<core:icon :name="$dir === 'desc' ? 'chevron-down' : 'chevron-up'" class="ml-1 text-xs" />@endif
</button>
<button wire:click="setSort('status')" class="px-3 py-1 text-sm rounded-full transition {{ $sort === 'status' ? 'bg-violet-500 text-white' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600' }}">
{{ __('hub::hub.content.filters.status') }} @if($sort === 'status')<core:icon :name="$dir === 'desc' ? 'chevron-down' : 'chevron-up'" class="ml-1 text-xs" />@endif
</button>
</div>
</div>
<!-- View Toggle -->
<div class="flex items-center bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
<button wire:click="setView('list')" class="p-2 rounded {{ $view === 'list' ? 'bg-white dark:bg-gray-600 shadow-sm' : '' }}">
<core:icon name="list" class="text-gray-600 dark:text-gray-300" />
</button>
<button wire:click="setView('grid')" class="p-2 rounded {{ $view === 'grid' ? 'bg-white dark:bg-gray-600 shadow-sm' : '' }}">
<core:icon name="grid-2" class="text-gray-600 dark:text-gray-300" />
</button>
</div>
</div>
</div>
<!-- Content Area -->
@if($tab === 'media' && $view === 'grid')
<!-- Media Grid View -->
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">
@forelse($this->rows as $item)
<div class="group relative aspect-square bg-gray-100 dark:bg-gray-700 rounded-xl overflow-hidden cursor-pointer">
@if(($item['media_type'] ?? 'image') === 'image')
<img src="{{ $item['source_url'] ?? '/images/placeholder.svg' }}" alt="{{ $item['title']['rendered'] ?? '' }}" class="w-full h-full object-cover">
@else
<div class="w-full h-full flex items-center justify-center">
<core:icon name="file" class="text-3xl text-gray-400" />
</div>
@endif
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition flex items-center justify-center">
<span class="text-white text-xs px-2 py-1 bg-black/50 rounded truncate max-w-full">{{ $item['title']['rendered'] ?? __('hub::hub.content.untitled') }}</span>
</div>
</div>
@empty
<div class="col-span-full py-12 text-center text-gray-500 dark:text-gray-400">
<core:icon name="image" class="text-4xl mb-3 opacity-50" />
<p>{{ __('hub::hub.content.no_media') }}</p>
</div>
@endforelse
</div>
@else
<!-- Table View -->
<div class="bg-white dark:bg-gray-800 shadow-xs rounded-xl overflow-hidden">
<div class="overflow-x-auto">
<table class="table-auto w-full dark:text-gray-300">
<thead class="text-xs uppercase text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-100 dark:border-gray-700">
<tr>
<th class="px-4 py-3 w-10">
<input type="checkbox" class="form-checkbox rounded text-violet-500">
</th>
<th class="px-4 py-3 text-left hidden md:table-cell">{{ __('hub::hub.content.columns.id') }}</th>
<th class="px-4 py-3 text-left">{{ __('hub::hub.content.columns.title') }}</th>
<th class="px-4 py-3 text-left hidden md:table-cell">{{ __('hub::hub.content.columns.status') }}</th>
<th class="px-4 py-3 text-left hidden lg:table-cell">{{ __('hub::hub.content.columns.date') }}</th>
<th class="px-4 py-3 text-left hidden lg:table-cell">{{ __('hub::hub.content.columns.modified') }}</th>
<th class="px-4 py-3 w-10"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
@forelse ($this->rows as $row)
<tr wire:key="row-{{ $row['id'] }}" class="hover:bg-gray-50 dark:hover:bg-gray-700/25">
<td class="px-4 py-3">
<input type="checkbox" class="form-checkbox rounded text-violet-500">
</td>
<td class="px-4 py-3 hidden md:table-cell">
<span class="text-gray-500">#{{ $row['id'] }}</span>
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-3">
@if($tab === 'media')
<div class="w-10 h-10 rounded-lg bg-gray-100 dark:bg-gray-700 overflow-hidden flex-shrink-0">
@if(($row['media_type'] ?? 'image') === 'image')
<img src="{{ $row['source_url'] ?? '/images/placeholder.svg' }}" alt="" class="w-full h-full object-cover">
@else
<div class="w-full h-full flex items-center justify-center">
<core:icon name="file" class="text-gray-400" />
</div>
@endif
</div>
@endif
<div class="min-w-0">
<div class="font-medium text-gray-800 dark:text-gray-100 truncate">{{ $row['title']['rendered'] ?? __('hub::hub.content.untitled') }}</div>
@if($tab !== 'media' && !empty($row['excerpt']['rendered']))
<div class="text-xs text-gray-500 truncate max-w-xs">{{ Str::limit(strip_tags($row['excerpt']['rendered']), 50) }}</div>
@endif
</div>
</div>
</td>
<td class="px-4 py-3 hidden md:table-cell">
@php
$status = $row['status'] ?? 'draft';
$statusColors = [
'publish' => 'green',
'draft' => 'yellow',
'pending' => 'blue',
'private' => 'gray',
];
$color = $statusColors[$status] ?? 'gray';
@endphp
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-{{ $color }}-100 dark:bg-{{ $color }}-500/20 text-{{ $color }}-800 dark:text-{{ $color }}-400">
{{ ucfirst($status) }}
</span>
</td>
<td class="px-4 py-3 hidden lg:table-cell">
<span class="text-gray-500 text-sm">
{{ isset($row['date']) ? \Carbon\Carbon::parse($row['date'])->format('M j, Y') : '-' }}
</span>
</td>
<td class="px-4 py-3 hidden lg:table-cell">
<span class="text-gray-500 text-sm">
{{ isset($row['modified']) ? \Carbon\Carbon::parse($row['modified'])->diffForHumans() : '-' }}
</span>
</td>
<td class="px-4 py-3">
<div x-data="{ open: false }" class="relative">
<button @click="open = !open" class="p-1.5 rounded hover:bg-gray-100 dark:hover:bg-gray-700">
<core:icon name="ellipsis" class="text-gray-500" />
</button>
<div x-show="open" @click.away="open = false" x-transition class="absolute right-0 mt-1 w-40 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-100 dark:border-gray-700 py-1 z-10">
@if($tab !== 'media')
<button wire:click="edit({{ $row['id'] }})" @click="open = false" class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
<core:icon name="pen-to-square" class="mr-2 text-gray-400" />{{ __('hub::hub.content.actions.edit') }}
</button>
@endif
<button disabled title="Preview coming soon" class="w-full text-left px-4 py-2 text-sm text-gray-400 dark:text-gray-500 cursor-not-allowed">
<core:icon name="eye" class="mr-2" />{{ __('hub::hub.content.actions.view') }}
</button>
<button disabled title="Duplicate coming soon" class="w-full text-left px-4 py-2 text-sm text-gray-400 dark:text-gray-500 cursor-not-allowed">
<core:icon name="copy" class="mr-2" />{{ __('hub::hub.content.actions.duplicate') }}
</button>
<hr class="my-1 border-gray-100 dark:border-gray-700">
<button wire:click="delete({{ $row['id'] }})" wire:confirm="{{ __('hub::hub.content.actions.delete_confirm') }}" @click="open = false" class="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10">
<core:icon name="trash" class="mr-2" />{{ __('hub::hub.content.actions.delete') }}
</button>
</div>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-4 py-12 text-center">
<div class="text-gray-500">
<core:icon :name="$tab === 'posts' ? 'newspaper' : ($tab === 'pages' ? 'file-lines' : 'image')" class="text-4xl mb-3 opacity-50" />
<p>{{ $tab === 'posts' ? __('hub::hub.content.no_posts') : ($tab === 'pages' ? __('hub::hub.content.no_pages') : __('hub::hub.content.no_media')) }}</p>
</div>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
@endif
<!-- Pagination -->
@if($this->paginator->hasPages())
<div class="mt-6">
{{ $this->paginator->links() }}
</div>
@endif
<!-- Editor Modal -->
@if($showEditor)
<div class="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0">
<!-- Backdrop -->
<div wire:click="closeEditor" class="fixed inset-0 bg-gray-500/75 dark:bg-gray-900/75 transition-opacity"></div>
<!-- Modal Panel -->
<div class="relative bg-white dark:bg-gray-800 rounded-xl shadow-xl transform transition-all sm:my-8 sm:w-full sm:max-w-2xl">
<div class="px-6 py-4 border-b border-gray-100 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-100">
{{ $isCreating ? __('hub::hub.content.editor.new') : __('hub::hub.content.editor.edit') }} {{ $tab === 'posts' ? __('hub::hub.content.tabs.posts') : __('hub::hub.content.tabs.pages') }}
</h3>
</div>
<div class="px-6 py-4 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ __('hub::hub.content.editor.title_label') }}</label>
<input type="text" wire:model="editTitle" class="form-input w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300" placeholder="{{ __('hub::hub.content.editor.title_placeholder') }}">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ __('hub::hub.content.editor.status_label') }}</label>
<select wire:model="editStatus" class="form-select w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="draft">{{ __('hub::hub.content.editor.status.draft') }}</option>
<option value="publish">{{ __('hub::hub.content.editor.status.publish') }}</option>
<option value="pending">{{ __('hub::hub.content.editor.status.pending') }}</option>
<option value="private">{{ __('hub::hub.content.editor.status.private') }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ __('hub::hub.content.editor.excerpt_label') }}</label>
<textarea wire:model="editExcerpt" rows="2" class="form-textarea w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300" placeholder="{{ __('hub::hub.content.editor.excerpt_placeholder') }}"></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ __('hub::hub.content.editor.content_label') }}</label>
<textarea wire:model="editContent" rows="10" class="form-textarea w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 font-mono text-sm" placeholder="{{ __('hub::hub.content.editor.content_placeholder') }}"></textarea>
</div>
</div>
<div class="px-6 py-4 border-t border-gray-100 dark:border-gray-700 flex justify-end gap-3">
<button wire:click="closeEditor" class="btn border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 text-gray-600 dark:text-gray-300">
{{ __('hub::hub.content.editor.cancel') }}
</button>
<button wire:click="save" class="btn bg-violet-500 text-white hover:bg-violet-600">
<core:icon name="check" class="mr-2" />
{{ $isCreating ? __('hub::hub.content.editor.create') : __('hub::hub.content.editor.update') }}
</button>
</div>
</div>
</div>
</div>
@endif
</div>

View file

@ -0,0 +1,96 @@
<div class="px-4 sm:px-6 lg:px-8 py-8 w-full max-w-5xl mx-auto">
{{-- Welcome Header --}}
<div class="mb-8">
<h1 class="text-2xl font-bold text-zinc-100">Welcome to {{ config('app.name', 'Core PHP') }}</h1>
<p class="text-zinc-400 mt-1">Your application is ready to use.</p>
</div>
{{-- Quick Stats --}}
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div class="bg-zinc-800/50 rounded-xl p-6">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-violet-500/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-violet-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z"/>
</svg>
</div>
<div>
<p class="text-sm text-zinc-400">Users</p>
<p class="text-2xl font-semibold text-zinc-100">{{ \Core\Mod\Tenant\Models\User::count() }}</p>
</div>
</div>
</div>
<div class="bg-zinc-800/50 rounded-xl p-6">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-green-500/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div>
<p class="text-sm text-zinc-400">Status</p>
<p class="text-2xl font-semibold text-green-400">Active</p>
</div>
</div>
</div>
<div class="bg-zinc-800/50 rounded-xl p-6">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
</div>
<div>
<p class="text-sm text-zinc-400">Laravel</p>
<p class="text-2xl font-semibold text-zinc-100">{{ app()->version() }}</p>
</div>
</div>
</div>
</div>
{{-- Quick Actions --}}
<div class="bg-zinc-800/50 rounded-xl p-6 mb-8">
<h2 class="text-lg font-semibold text-zinc-100 mb-4">Quick Actions</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<a href="{{ route('hub.account') }}" class="flex items-center gap-4 p-4 bg-zinc-900/50 rounded-lg hover:bg-zinc-700/50 transition">
<div class="w-10 h-10 bg-violet-500/20 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-violet-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
</div>
<div>
<p class="font-medium text-zinc-100">Your Profile</p>
<p class="text-sm text-zinc-400">Manage your account</p>
</div>
</a>
<a href="/" class="flex items-center gap-4 p-4 bg-zinc-900/50 rounded-lg hover:bg-zinc-700/50 transition">
<div class="w-10 h-10 bg-green-500/20 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
</svg>
</div>
<div>
<p class="font-medium text-zinc-100">View Site</p>
<p class="text-sm text-zinc-400">Go to homepage</p>
</div>
</a>
</div>
</div>
{{-- User Info --}}
<div class="bg-zinc-800/50 rounded-xl p-6">
<h2 class="text-lg font-semibold text-zinc-100 mb-4">Logged in as</h2>
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-violet-600 rounded-full flex items-center justify-center text-white font-semibold">
{{ substr(auth()->user()->name ?? 'U', 0, 1) }}
</div>
<div>
<p class="font-medium text-zinc-100">{{ auth()->user()->name ?? 'User' }}</p>
<p class="text-sm text-zinc-400">{{ auth()->user()->email ?? '' }}</p>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,233 @@
<div x-data="{
copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => {
$wire.dispatch('copy-to-clipboard', { text });
});
}
}" @copy-to-clipboard.window="copyToClipboard($event.detail.text)">
<core:heading size="xl" class="mb-6">Databases & Integrations</core:heading>
<div class="space-y-6">
{{-- Internal WordPress (hestia.host.uk.com) --}}
<core:card class="p-6">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
<core:icon name="fab fa-wordpress" class="w-5 h-5 text-blue-500" />
</div>
<div>
<core:heading size="lg">Host UK WordPress</core:heading>
<core:subheading>Internal content management system</core:subheading>
</div>
</div>
<core:badge color="{{ ($internalWpHealth['status'] ?? 'unknown') === 'healthy' ? 'green' : (($internalWpHealth['status'] ?? 'unknown') === 'degraded' ? 'amber' : 'red') }}">
{{ ucfirst($internalWpHealth['status'] ?? 'Unknown') }}
</core:badge>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
{{-- API Status --}}
<div class="p-4 rounded-lg bg-zinc-50 dark:bg-zinc-800/50">
<core:text size="sm" class="text-zinc-500 mb-1">REST API</core:text>
<div class="flex items-center gap-2">
@if($internalWpHealth['api_available'] ?? false)
<div class="w-2 h-2 rounded-full bg-green-500"></div>
<core:text class="font-medium text-green-600 dark:text-green-400">Available</core:text>
@else
<div class="w-2 h-2 rounded-full bg-red-500"></div>
<core:text class="font-medium text-red-600 dark:text-red-400">Unavailable</core:text>
@endif
</div>
</div>
{{-- Post Count --}}
<div class="p-4 rounded-lg bg-zinc-50 dark:bg-zinc-800/50">
<core:text size="sm" class="text-zinc-500 mb-1">Posts</core:text>
<core:text class="text-2xl font-semibold">
{{ number_format($internalWpHealth['post_count'] ?? 0) }}
</core:text>
</div>
{{-- Page Count --}}
<div class="p-4 rounded-lg bg-zinc-50 dark:bg-zinc-800/50">
<core:text size="sm" class="text-zinc-500 mb-1">Pages</core:text>
<core:text class="text-2xl font-semibold">
{{ number_format($internalWpHealth['page_count'] ?? 0) }}
</core:text>
</div>
</div>
<div class="flex items-center justify-between text-sm text-zinc-500 mb-4">
<span>{{ $internalWpHealth['url'] ?? 'Not configured' }}</span>
<span>Last checked: {{ isset($internalWpHealth['last_check']) ? \Carbon\Carbon::parse($internalWpHealth['last_check'])->diffForHumans() : 'Never' }}</span>
</div>
<div class="flex items-center gap-3 pt-4 border-t border-zinc-200 dark:border-zinc-700">
<core:button wire:click="refreshInternalHealth" variant="ghost" icon="arrow-path" size="sm">
Refresh
</core:button>
<core:button href="/hub/content/host-uk/posts" variant="subtle" icon="arrow-right" size="sm">
Manage Content
</core:button>
</div>
</core:card>
{{-- External WordPress Connector --}}
<core:card class="p-6">
<div class="flex items-center gap-3 mb-6">
<div class="w-10 h-10 rounded-lg bg-violet-500/10 flex items-center justify-center">
<core:icon name="link" class="w-5 h-5 text-violet-500" />
</div>
<div>
<core:heading size="lg">WordPress Connector</core:heading>
<core:subheading>Connect your self-hosted WordPress site to sync content</core:subheading>
</div>
</div>
<div class="space-y-6">
{{-- Enable Toggle --}}
<core:switch
wire:model.live="wpConnectorEnabled"
label="Enable WordPress Connector"
description="Allow your WordPress site to send content updates to Host Hub"
/>
@if($wpConnectorEnabled)
{{-- WordPress URL --}}
<core:input
wire:model="wpConnectorUrl"
label="WordPress Site URL"
placeholder="https://your-site.com"
type="url"
/>
{{-- Webhook Configuration --}}
<div class="p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg space-y-4">
<core:heading size="sm">Plugin Configuration</core:heading>
<core:text size="sm" class="text-zinc-600 dark:text-zinc-400">
Install the Host Hub Connector plugin on your WordPress site and enter these settings:
</core:text>
{{-- Webhook URL --}}
<div>
<core:label>Webhook URL</core:label>
<div class="flex gap-2 mt-1">
<core:input
:value="$this->webhookUrl"
readonly
class="flex-1 font-mono text-sm"
/>
<core:button
wire:click="copyToClipboard('{{ $this->webhookUrl }}')"
variant="ghost"
icon="clipboard"
/>
</div>
</div>
{{-- Webhook Secret --}}
<div>
<core:label>Webhook Secret</core:label>
<div class="flex gap-2 mt-1">
<core:input
:value="$this->webhookSecret"
readonly
type="password"
class="flex-1 font-mono text-sm"
/>
<core:button
wire:click="copyToClipboard('{{ $this->webhookSecret }}')"
variant="ghost"
icon="clipboard"
/>
<core:button
wire:click="regenerateSecret"
wire:confirm="This will invalidate the current secret. You'll need to update your WordPress plugin settings."
variant="ghost"
icon="arrow-path"
/>
</div>
<core:text size="xs" class="text-zinc-500 mt-1">
Keep this secret safe. It's used to verify webhooks are from your WordPress site.
</core:text>
</div>
</div>
{{-- Connection Status --}}
<div class="flex items-center justify-between p-4 border border-zinc-200 dark:border-zinc-700 rounded-lg">
<div class="flex items-center gap-3">
@if($this->isWpConnectorVerified)
<div class="w-3 h-3 bg-green-500 rounded-full"></div>
<div>
<core:text class="font-medium text-green-600 dark:text-green-400">Connected</core:text>
@if($this->wpConnectorLastSync)
<core:text size="sm" class="text-zinc-500">Last sync: {{ $this->wpConnectorLastSync }}</core:text>
@endif
</div>
@else
<div class="w-3 h-3 bg-amber-500 rounded-full"></div>
<div>
<core:text class="font-medium text-amber-600 dark:text-amber-400">Not verified</core:text>
<core:text size="sm" class="text-zinc-500">Test the connection to verify</core:text>
</div>
@endif
</div>
<core:button
wire:click="testWpConnection"
wire:loading.attr="disabled"
variant="ghost"
icon="signal"
>
Test Connection
</core:button>
</div>
@if($testResult)
<core:callout :variant="$testSuccess ? 'success' : 'danger'" icon="{{ $testSuccess ? 'check-circle' : 'exclamation-circle' }}">
{{ $testResult }}
</core:callout>
@endif
{{-- Plugin Download --}}
<div class="p-4 border border-dashed border-zinc-300 dark:border-zinc-600 rounded-lg">
<div class="flex items-start gap-3">
<core:icon name="puzzle-piece" class="w-5 h-5 text-violet-500 mt-0.5" />
<div>
<core:heading size="sm">WordPress Plugin</core:heading>
<core:text size="sm" class="text-zinc-600 dark:text-zinc-400 mt-1">
Download and install the Host Hub Connector plugin on your WordPress site to enable content syncing.
</core:text>
<core:button variant="subtle" size="sm" class="mt-2" icon="arrow-down-tray">
Download Plugin
</core:button>
</div>
</div>
</div>
@endif
</div>
<div class="flex justify-end gap-3 pt-6 mt-6 border-t border-zinc-200 dark:border-zinc-700">
<core:button wire:click="saveWpConnector" variant="primary">
Save Settings
</core:button>
</div>
</core:card>
{{-- Future Integrations Placeholder --}}
<core:card class="p-6 border-dashed">
<div class="text-center py-6">
<div class="w-12 h-12 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center mx-auto mb-4">
<core:icon name="plus" class="w-6 h-6 text-zinc-400" />
</div>
<core:heading size="sm" class="text-zinc-600 dark:text-zinc-400">More Integrations Coming Soon</core:heading>
<core:text size="sm" class="text-zinc-500 mt-1">
Connect additional databases and external systems
</core:text>
</div>
</core:card>
</div>
</div>

View file

@ -0,0 +1,160 @@
<div>
<core:heading size="xl" class="mb-2">Deployments & System Status</core:heading>
<core:subheading class="mb-6">Monitor system health and recent deployments</core:subheading>
{{-- Current Deployment Info --}}
<core:card class="p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-violet-500/10 flex items-center justify-center">
<core:icon name="rocket-launch" class="w-5 h-5 text-violet-500" />
</div>
<div>
<core:heading size="lg">Current Deployment</core:heading>
<core:subheading>Branch: <code class="text-violet-600 dark:text-violet-400">{{ $this->gitInfo['branch'] }}</code></core:subheading>
</div>
</div>
<div class="flex items-center gap-2">
<core:button wire:click="refresh" wire:loading.attr="disabled" variant="ghost" icon="arrow-path" size="sm">
Refresh
</core:button>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="p-4 rounded-lg bg-zinc-50 dark:bg-zinc-800/50">
<core:text size="sm" class="text-zinc-500 mb-1">Commit</core:text>
<code class="text-sm font-mono text-zinc-800 dark:text-zinc-200">{{ $this->gitInfo['commit'] }}</code>
</div>
<div class="p-4 rounded-lg bg-zinc-50 dark:bg-zinc-800/50">
<core:text size="sm" class="text-zinc-500 mb-1">Message</core:text>
<core:text class="font-medium truncate" title="{{ $this->gitInfo['message'] }}">{{ \Illuminate\Support\Str::limit($this->gitInfo['message'], 30) }}</core:text>
</div>
<div class="p-4 rounded-lg bg-zinc-50 dark:bg-zinc-800/50">
<core:text size="sm" class="text-zinc-500 mb-1">Author</core:text>
<core:text class="font-medium">{{ $this->gitInfo['author'] }}</core:text>
</div>
<div class="p-4 rounded-lg bg-zinc-50 dark:bg-zinc-800/50">
<core:text size="sm" class="text-zinc-500 mb-1">Deployed</core:text>
<core:text class="font-medium">{{ $this->gitInfo['date'] ?? 'Unknown' }}</core:text>
</div>
</div>
</core:card>
{{-- Stats Grid --}}
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
@foreach($this->stats as $stat)
<core:card class="p-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-{{ $stat['color'] }}-500/10 flex items-center justify-center">
<core:icon name="{{ $stat['icon'] }}" class="w-5 h-5 text-{{ $stat['color'] }}-500" />
</div>
<div>
<core:text size="sm" class="text-zinc-500">{{ $stat['label'] }}</core:text>
<core:text class="text-lg font-semibold">{{ $stat['value'] }}</core:text>
</div>
</div>
</core:card>
@endforeach
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
{{-- Service Health --}}
<core:card class="p-6">
<core:heading size="lg" class="mb-4">Service Health</core:heading>
<div class="space-y-3">
@foreach($this->services as $service)
<div class="flex items-center justify-between p-3 rounded-lg bg-zinc-50 dark:bg-zinc-800/50">
<div class="flex items-center gap-3">
<core:icon name="{{ $service['icon'] }}" class="w-5 h-5 text-zinc-500" />
<div>
<core:text class="font-medium">{{ $service['name'] }}</core:text>
@if(isset($service['details']))
<core:text size="sm" class="text-zinc-500">
@if(isset($service['details']['version']))
v{{ $service['details']['version'] }}
@endif
@if(isset($service['details']['memory']))
&middot; {{ $service['details']['memory'] }}
@endif
@if(isset($service['details']['pending']))
&middot; {{ $service['details']['pending'] }} pending
@endif
@if(isset($service['details']['used_percent']))
&middot; {{ $service['details']['used_percent'] }} used
@endif
</core:text>
@endif
@if(isset($service['error']))
<core:text size="sm" class="text-red-500">{{ $service['error'] }}</core:text>
@endif
</div>
</div>
<div class="flex items-center gap-2">
@if($service['status'] === 'healthy')
<span class="w-2 h-2 rounded-full bg-green-500"></span>
<core:text size="sm" class="text-green-600 dark:text-green-400">Healthy</core:text>
@elseif($service['status'] === 'warning')
<span class="w-2 h-2 rounded-full bg-amber-500"></span>
<core:text size="sm" class="text-amber-600 dark:text-amber-400">Warning</core:text>
@elseif($service['status'] === 'unknown')
<span class="w-2 h-2 rounded-full bg-zinc-400"></span>
<core:text size="sm" class="text-zinc-500">Unknown</core:text>
@else
<span class="w-2 h-2 rounded-full bg-red-500"></span>
<core:text size="sm" class="text-red-600 dark:text-red-400">Unhealthy</core:text>
@endif
</div>
</div>
@endforeach
</div>
<div class="mt-4 pt-4 border-t border-zinc-200 dark:border-zinc-700">
<core:button wire:click="clearCache" variant="subtle" icon="trash" size="sm">
Clear Application Cache
</core:button>
</div>
</core:card>
{{-- Recent Commits --}}
<core:card class="p-6">
<core:heading size="lg" class="mb-4">Recent Commits</core:heading>
@if(count($this->recentCommits) > 0)
<div class="space-y-2">
@foreach($this->recentCommits as $commit)
<div class="flex items-start gap-3 p-3 rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800/50 transition-colors">
<code class="text-xs font-mono text-violet-600 dark:text-violet-400 bg-violet-500/10 px-2 py-1 rounded mt-0.5">{{ $commit['hash'] }}</code>
<div class="flex-1 min-w-0">
<core:text class="truncate" title="{{ $commit['message'] }}">{{ $commit['message'] }}</core:text>
<core:text size="sm" class="text-zinc-500">{{ $commit['author'] }} &middot; {{ $commit['date'] }}</core:text>
</div>
</div>
@endforeach
</div>
@else
<div class="flex flex-col items-center py-8 text-center">
<core:icon name="code-bracket" class="w-12 h-12 text-zinc-300 dark:text-zinc-600 mb-3" />
<core:text class="text-zinc-500">No commit history available</core:text>
<core:text size="sm" class="text-zinc-400">Git may not be available in this environment</core:text>
</div>
@endif
</core:card>
</div>
{{-- Future Coolify Integration Notice --}}
<core:card class="p-6 mt-6 border-dashed">
<div class="flex items-start gap-4">
<div class="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center flex-shrink-0">
<core:icon name="rocket-launch" class="w-5 h-5 text-blue-500" />
</div>
<div>
<core:heading size="sm">Coming Soon: Deployment Management</core:heading>
<core:text size="sm" class="text-zinc-600 dark:text-zinc-400 mt-1">
Full deployment management with Coolify integration is planned. You'll be able to trigger deployments, view build logs, rollback to previous versions, and monitor deployment health.
</core:text>
</div>
</div>
</core:card>
</div>

View file

@ -0,0 +1,148 @@
<div>
{{-- Page header --}}
<div class="sm:flex sm:justify-between sm:items-center mb-8">
<div class="mb-4 sm:mb-0">
<h1 class="text-2xl md:text-3xl text-gray-800 dark:text-gray-100 font-bold">Cache Management</h1>
<p class="text-sm text-gray-500 dark:text-gray-400">Clear application caches and optimise performance</p>
</div>
</div>
{{-- Cache actions grid --}}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
{{-- Application Cache --}}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<i class="fa-solid fa-database text-blue-600 dark:text-blue-400"></i>
</div>
<div>
<h3 class="font-semibold text-gray-800 dark:text-gray-100">Application Cache</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">Redis/file cache data</p>
</div>
</div>
<flux:button
wire:click="clearCache"
variant="filled"
class="w-full"
>
<span wire:loading.remove wire:target="clearCache">Clear Cache</span>
<span wire:loading wire:target="clearCache">Clearing...</span>
</flux:button>
</div>
{{-- Config Cache --}}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
<i class="fa-solid fa-cog text-green-600 dark:text-green-400"></i>
</div>
<div>
<h3 class="font-semibold text-gray-800 dark:text-gray-100">Configuration Cache</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">Compiled config files</p>
</div>
</div>
<flux:button
wire:click="clearConfig"
variant="filled"
class="w-full"
>
<span wire:loading.remove wire:target="clearConfig">Clear Config</span>
<span wire:loading wire:target="clearConfig">Clearing...</span>
</flux:button>
</div>
{{-- View Cache --}}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
<i class="fa-solid fa-eye text-purple-600 dark:text-purple-400"></i>
</div>
<div>
<h3 class="font-semibold text-gray-800 dark:text-gray-100">View Cache</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">Compiled Blade templates</p>
</div>
</div>
<flux:button
wire:click="clearViews"
variant="filled"
class="w-full"
>
<span wire:loading.remove wire:target="clearViews">Clear Views</span>
<span wire:loading wire:target="clearViews">Clearing...</span>
</flux:button>
</div>
{{-- Route Cache --}}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-orange-100 dark:bg-orange-900/30 flex items-center justify-center">
<i class="fa-solid fa-route text-orange-600 dark:text-orange-400"></i>
</div>
<div>
<h3 class="font-semibold text-gray-800 dark:text-gray-100">Route Cache</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">Compiled route files</p>
</div>
</div>
<flux:button
wire:click="clearRoutes"
variant="filled"
class="w-full"
>
<span wire:loading.remove wire:target="clearRoutes">Clear Routes</span>
<span wire:loading wire:target="clearRoutes">Clearing...</span>
</flux:button>
</div>
{{-- Clear All --}}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<i class="fa-solid fa-trash text-red-600 dark:text-red-400"></i>
</div>
<div>
<h3 class="font-semibold text-gray-800 dark:text-gray-100">Clear All</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">All caches at once</p>
</div>
</div>
<flux:button
wire:click="clearAll"
variant="danger"
class="w-full"
>
<span wire:loading.remove wire:target="clearAll">Clear All Caches</span>
<span wire:loading wire:target="clearAll">Clearing...</span>
</flux:button>
</div>
{{-- Optimise --}}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-violet-100 dark:bg-violet-900/30 flex items-center justify-center">
<i class="fa-solid fa-bolt text-violet-600 dark:text-violet-400"></i>
</div>
<div>
<h3 class="font-semibold text-gray-800 dark:text-gray-100">Optimise</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">Rebuild all caches</p>
</div>
</div>
<flux:button
wire:click="optimise"
variant="primary"
class="w-full"
>
<span wire:loading.remove wire:target="optimise">Optimise App</span>
<span wire:loading wire:target="optimise">Optimising...</span>
</flux:button>
</div>
</div>
{{-- Last action output --}}
@if($lastOutput)
<div class="bg-gray-900 rounded-lg shadow-sm p-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-medium text-gray-400">Last Action: {{ $lastAction }}</h3>
</div>
<pre class="text-sm text-green-400 font-mono whitespace-pre-wrap">{{ $lastOutput }}</pre>
</div>
@endif
</div>

View file

@ -0,0 +1,112 @@
<div>
{{-- Page header --}}
<div class="sm:flex sm:justify-between sm:items-center mb-8">
<div class="mb-4 sm:mb-0">
<h1 class="text-2xl md:text-3xl text-gray-800 dark:text-gray-100 font-bold">Application Logs</h1>
<p class="text-sm text-gray-500 dark:text-gray-400">View recent Laravel log entries</p>
</div>
<div class="grid grid-flow-col sm:auto-cols-max justify-start sm:justify-end gap-2">
<button
wire:click="refresh"
class="btn border-gray-300 dark:border-gray-600 hover:border-violet-500 text-gray-700 dark:text-gray-300"
>
<i class="fa-solid fa-refresh mr-2"></i>
Refresh
</button>
<button
wire:click="clearLogs"
wire:confirm="Are you sure you want to clear all logs?"
class="btn bg-red-500 hover:bg-red-600 text-white"
>
<i class="fa-solid fa-trash mr-2"></i>
Clear Logs
</button>
</div>
</div>
{{-- Level filter --}}
<div class="mb-4 flex flex-wrap gap-2">
<button
wire:click="setLevel('')"
class="px-3 py-1 text-sm rounded-full {{ $levelFilter === '' ? 'bg-gray-800 text-white' : 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300' }}"
>
All
</button>
<button
wire:click="setLevel('error')"
class="px-3 py-1 text-sm rounded-full {{ $levelFilter === 'error' ? 'bg-red-600 text-white' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' }}"
>
Error
</button>
<button
wire:click="setLevel('warning')"
class="px-3 py-1 text-sm rounded-full {{ $levelFilter === 'warning' ? 'bg-orange-600 text-white' : 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' }}"
>
Warning
</button>
<button
wire:click="setLevel('info')"
class="px-3 py-1 text-sm rounded-full {{ $levelFilter === 'info' ? 'bg-blue-600 text-white' : 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' }}"
>
Info
</button>
<button
wire:click="setLevel('debug')"
class="px-3 py-1 text-sm rounded-full {{ $levelFilter === 'debug' ? 'bg-gray-600 text-white' : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400' }}"
>
Debug
</button>
</div>
{{-- Logs table --}}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden">
@if(count($logs) === 0)
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
<i class="fa-solid fa-file-lines text-4xl mb-4"></i>
<p>No log entries found</p>
</div>
@else
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-40">Time</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-24">Level</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Message</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
@foreach($logs as $log)
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td class="px-4 py-3 text-gray-500 dark:text-gray-400 whitespace-nowrap font-mono text-xs">
{{ $log['time'] }}
</td>
<td class="px-4 py-3 whitespace-nowrap">
@php
$levelClass = match($log['level']) {
'error', 'critical', 'alert', 'emergency' => 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
'warning' => 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
'info', 'notice' => 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
default => 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400',
};
@endphp
<span class="px-2 py-1 text-xs rounded-full {{ $levelClass }}">
{{ strtoupper($log['level']) }}
</span>
</td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-300 font-mono text-xs break-all">
{{ Str::limit($log['message'], 300) }}
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
{{-- Show count --}}
<div class="mt-4 text-sm text-gray-500 dark:text-gray-400">
Showing {{ count($logs) }} of last {{ $limit }} log entries
</div>
</div>

View file

@ -0,0 +1,111 @@
<div>
{{-- Page header --}}
<div class="sm:flex sm:justify-between sm:items-center mb-8">
<div class="mb-4 sm:mb-0">
<h1 class="text-2xl md:text-3xl text-gray-800 dark:text-gray-100 font-bold">Application Routes</h1>
<p class="text-sm text-gray-500 dark:text-gray-400">Browse all registered routes ({{ count($routes) }} total)</p>
</div>
</div>
{{-- Search and filter --}}
<div class="mb-4 flex flex-col sm:flex-row gap-4">
<div class="flex-1">
<input
type="text"
wire:model.live.debounce.300ms="search"
placeholder="Search by URI, name, or controller..."
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 focus:border-violet-500 focus:ring-violet-500"
>
</div>
<div class="flex flex-wrap gap-2">
<button
wire:click="setMethod('')"
class="px-3 py-2 text-sm rounded-lg {{ $methodFilter === '' ? 'bg-gray-800 text-white' : 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300' }}"
>
All
</button>
<button
wire:click="setMethod('GET')"
class="px-3 py-2 text-sm rounded-lg {{ $methodFilter === 'GET' ? 'bg-green-600 text-white' : 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' }}"
>
GET
</button>
<button
wire:click="setMethod('POST')"
class="px-3 py-2 text-sm rounded-lg {{ $methodFilter === 'POST' ? 'bg-blue-600 text-white' : 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' }}"
>
POST
</button>
<button
wire:click="setMethod('PUT')"
class="px-3 py-2 text-sm rounded-lg {{ $methodFilter === 'PUT' ? 'bg-orange-600 text-white' : 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' }}"
>
PUT
</button>
<button
wire:click="setMethod('DELETE')"
class="px-3 py-2 text-sm rounded-lg {{ $methodFilter === 'DELETE' ? 'bg-red-600 text-white' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' }}"
>
DELETE
</button>
</div>
</div>
{{-- Routes table --}}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden">
@php $filteredRoutes = $this->filteredRoutes; @endphp
@if(count($filteredRoutes) === 0)
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
<i class="fa-solid fa-route text-4xl mb-4"></i>
<p>No routes match your search</p>
</div>
@else
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-20">Method</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">URI</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Name</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
@foreach($filteredRoutes as $route)
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td class="px-4 py-2 whitespace-nowrap">
@php
$methodClass = match($route['method']) {
'GET' => 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
'POST' => 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
'PUT', 'PATCH' => 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
'DELETE' => 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
default => 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400',
};
@endphp
<span class="px-2 py-1 text-xs font-medium rounded {{ $methodClass }}">
{{ $route['method'] }}
</span>
</td>
<td class="px-4 py-2 font-mono text-xs text-gray-700 dark:text-gray-300">
{{ $route['uri'] }}
</td>
<td class="px-4 py-2 text-gray-500 dark:text-gray-400 text-xs">
{{ $route['name'] ?? '-' }}
</td>
<td class="px-4 py-2 font-mono text-xs text-gray-500 dark:text-gray-400 break-all">
{{ Str::limit($route['action'], 60) }}
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
{{-- Show count --}}
<div class="mt-4 text-sm text-gray-500 dark:text-gray-400">
Showing {{ count($filteredRoutes) }} of {{ count($routes) }} routes
</div>
</div>

View file

@ -0,0 +1,452 @@
<div>
{{-- Header --}}
<div class="mb-8">
<div class="flex items-start justify-between">
<div class="flex items-center gap-4">
<div class="w-16 h-16 rounded-xl bg-violet-500/20 flex items-center justify-center">
<core:icon name="key" class="text-2xl text-violet-600 dark:text-violet-400" />
</div>
<div>
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-100">Entitlements</h1>
<p class="text-gray-500 dark:text-gray-400 mt-1">Manage what workspaces can access and how much they can use</p>
</div>
</div>
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-violet-500/20 text-violet-600 dark:text-violet-400">
<core:icon name="crown" class="mr-1.5" />
Hades Only
</span>
</div>
</div>
{{-- Flash messages --}}
@if(session('success'))
<div class="mb-6 p-4 rounded-lg bg-green-500/20 text-green-700 dark:text-green-400">
<div class="flex items-center">
<core:icon name="check-circle" class="mr-2" />
{{ session('success') }}
</div>
</div>
@endif
@if(session('error'))
<div class="mb-6 p-4 rounded-lg bg-red-500/20 text-red-700 dark:text-red-400">
<div class="flex items-center">
<core:icon name="circle-xmark" class="mr-2" />
{{ session('error') }}
</div>
</div>
@endif
{{-- Tabs --}}
<div class="border-b border-gray-200 dark:border-gray-700 mb-6">
<nav class="flex gap-6" aria-label="Tabs">
@foreach([
'overview' => ['label' => 'Overview', 'icon' => 'gauge'],
'packages' => ['label' => 'Packages', 'icon' => 'box'],
'features' => ['label' => 'Features', 'icon' => 'puzzle-piece'],
] as $tabKey => $info)
<button
wire:click="setTab('{{ $tabKey }}')"
class="flex items-center gap-2 py-3 px-1 border-b-2 text-sm font-medium transition {{ $tab === $tabKey ? 'border-violet-500 text-violet-600 dark:text-violet-400' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200' }}"
>
<core:icon name="{{ $info['icon'] }}" />
{{ $info['label'] }}
</button>
@endforeach
</nav>
</div>
{{-- Tab Content --}}
<div class="min-h-[500px]">
{{-- Overview Tab --}}
@if($tab === 'overview')
<div class="space-y-6">
{{-- Explanation --}}
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs p-6">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-4">How Entitlements Work</h3>
<div class="prose prose-sm dark:prose-invert max-w-none">
<p class="text-gray-600 dark:text-gray-400">
The entitlement system controls what workspaces can access and how much they can use. Think of it as a flexible permissions and quota system.
</p>
<div class="grid md:grid-cols-3 gap-6 mt-6 not-prose">
{{-- Features --}}
<div class="p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
<div class="flex items-center gap-3 mb-3">
<div class="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
<core:icon name="puzzle-piece" class="text-blue-600 dark:text-blue-400" />
</div>
<h4 class="font-semibold text-gray-800 dark:text-gray-100">Features</h4>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
The atomic building blocks. Each feature is something you can check: "Can they do X?" or "How many X can they have?"
</p>
<div class="space-y-1 text-xs">
<div class="flex items-center gap-2">
<span class="inline-flex items-center px-1.5 py-0.5 rounded bg-gray-500/20 text-gray-600 dark:text-gray-400">boolean</span>
<span class="text-gray-500">On/off access (e.g., core.srv.bio)</span>
</div>
<div class="flex items-center gap-2">
<span class="inline-flex items-center px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-600 dark:text-blue-400">limit</span>
<span class="text-gray-500">Quota (e.g., bio.pages = 10)</span>
</div>
</div>
</div>
{{-- Packages --}}
<div class="p-4 rounded-lg bg-purple-500/10 border border-purple-500/20">
<div class="flex items-center gap-3 mb-3">
<div class="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
<core:icon name="box" class="text-purple-600 dark:text-purple-400" />
</div>
<h4 class="font-semibold text-gray-800 dark:text-gray-100">Packages</h4>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
Bundles of features sold as products. A "Pro" package might include 50 bio pages, social access, and analytics.
</p>
<div class="space-y-1 text-xs">
<div class="flex items-center gap-2">
<span class="inline-flex items-center px-1.5 py-0.5 rounded bg-purple-500/20 text-purple-600 dark:text-purple-400">base</span>
<span class="text-gray-500">One per workspace (plans)</span>
</div>
<div class="flex items-center gap-2">
<span class="inline-flex items-center px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-600 dark:text-blue-400">addon</span>
<span class="text-gray-500">Stackable extras</span>
</div>
</div>
</div>
{{-- Boosts --}}
<div class="p-4 rounded-lg bg-amber-500/10 border border-amber-500/20">
<div class="flex items-center gap-3 mb-3">
<div class="w-10 h-10 rounded-lg bg-amber-500/20 flex items-center justify-center">
<core:icon name="bolt" class="text-amber-600 dark:text-amber-400" />
</div>
<h4 class="font-semibold text-gray-800 dark:text-gray-100">Boosts</h4>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
One-off grants for specific features. Admin can give a workspace +100 pages or enable a feature temporarily.
</p>
<div class="space-y-1 text-xs">
<div class="flex items-center gap-2">
<span class="inline-flex items-center px-1.5 py-0.5 rounded bg-green-500/20 text-green-600 dark:text-green-400">permanent</span>
<span class="text-gray-500">Forever (or until revoked)</span>
</div>
<div class="flex items-center gap-2">
<span class="inline-flex items-center px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-600 dark:text-amber-400">expiring</span>
<span class="text-gray-500">Time-limited</span>
</div>
</div>
</div>
</div>
<div class="mt-6 p-4 rounded-lg bg-gray-100 dark:bg-gray-700/30">
<h5 class="font-medium text-gray-800 dark:text-gray-200 mb-2">The Flow</h5>
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 flex-wrap">
<span class="px-2 py-1 rounded bg-blue-500/20 text-blue-700 dark:text-blue-300">Features</span>
<core:icon name="arrow-right" class="text-gray-400" />
<span class="text-gray-500">bundled into</span>
<core:icon name="arrow-right" class="text-gray-400" />
<span class="px-2 py-1 rounded bg-purple-500/20 text-purple-700 dark:text-purple-300">Packages</span>
<core:icon name="arrow-right" class="text-gray-400" />
<span class="text-gray-500">assigned to</span>
<core:icon name="arrow-right" class="text-gray-400" />
<span class="px-2 py-1 rounded bg-green-500/20 text-green-700 dark:text-green-300">Workspaces</span>
</div>
<p class="text-xs text-gray-500 mt-2">
Boosts bypass packages to grant features directly to workspaces (for support, promotions, etc.)
</p>
</div>
</div>
</div>
{{-- Stats Grid --}}
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs p-5">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
<core:icon name="box" class="text-purple-600 dark:text-purple-400" />
</div>
<div>
<div class="text-2xl font-bold text-gray-800 dark:text-gray-100">{{ $this->stats['packages']['total'] }}</div>
<div class="text-xs text-gray-500">Packages</div>
</div>
</div>
<div class="mt-3 flex items-center gap-3 text-xs">
<span class="text-green-600">{{ $this->stats['packages']['active'] }} active</span>
<span class="text-gray-400">{{ $this->stats['packages']['public'] }} public</span>
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs p-5">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
<core:icon name="puzzle-piece" class="text-blue-600 dark:text-blue-400" />
</div>
<div>
<div class="text-2xl font-bold text-gray-800 dark:text-gray-100">{{ $this->stats['features']['total'] }}</div>
<div class="text-xs text-gray-500">Features</div>
</div>
</div>
<div class="mt-3 flex items-center gap-3 text-xs">
<span class="text-gray-500">{{ $this->stats['features']['boolean'] }} boolean</span>
<span class="text-blue-500">{{ $this->stats['features']['limit'] }} limits</span>
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs p-5">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-green-500/20 flex items-center justify-center">
<core:icon name="folder" class="text-green-600 dark:text-green-400" />
</div>
<div>
<div class="text-2xl font-bold text-gray-800 dark:text-gray-100">{{ $this->stats['assignments']['workspace_packages'] }}</div>
<div class="text-xs text-gray-500">Active Assignments</div>
</div>
</div>
<div class="mt-3 text-xs text-gray-500">
Workspaces with packages
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs p-5">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-amber-500/20 flex items-center justify-center">
<core:icon name="bolt" class="text-amber-600 dark:text-amber-400" />
</div>
<div>
<div class="text-2xl font-bold text-gray-800 dark:text-gray-100">{{ $this->stats['assignments']['active_boosts'] }}</div>
<div class="text-xs text-gray-500">Active Boosts</div>
</div>
</div>
<div class="mt-3 text-xs text-gray-500">
Direct feature grants
</div>
</div>
</div>
{{-- Categories --}}
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs p-5">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-4">Feature Categories</h3>
<div class="flex flex-wrap gap-2">
@forelse($this->stats['categories'] as $category)
<span class="inline-flex items-center px-3 py-1 rounded-lg text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300">
{{ $category }}
</span>
@empty
<span class="text-sm text-gray-400">No categories defined</span>
@endforelse
</div>
</div>
</div>
@endif
{{-- Packages Tab --}}
@if($tab === 'packages')
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Packages</h2>
<p class="text-sm text-gray-500">Bundles of features assigned to workspaces</p>
</div>
<flux:button wire:click="openCreatePackage" icon="plus">
New Package
</flux:button>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs overflow-hidden">
<admin:manager-table
:columns="['Package', 'Code', 'Features', ['label' => 'Type', 'align' => 'center'], ['label' => 'Status', 'align' => 'center'], ['label' => 'Actions', 'align' => 'center']]"
:rows="$this->packageTableRows"
:pagination="$this->packages"
empty="No packages found. Create your first package to get started."
emptyIcon="box"
/>
</div>
</div>
@endif
{{-- Features Tab --}}
@if($tab === 'features')
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Features</h2>
<p class="text-sm text-gray-500">Individual capabilities that can be checked and tracked</p>
</div>
<flux:button wire:click="openCreateFeature" icon="plus">
New Feature
</flux:button>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs overflow-hidden">
<admin:manager-table
:columns="['Feature', 'Code', 'Category', ['label' => 'Type', 'align' => 'center'], ['label' => 'Reset', 'align' => 'center'], ['label' => 'Status', 'align' => 'center'], ['label' => 'Actions', 'align' => 'center']]"
:rows="$this->featureTableRows"
:pagination="$this->features"
empty="No features found. Create your first feature to get started."
emptyIcon="puzzle-piece"
/>
</div>
</div>
@endif
</div>
{{-- Package Modal --}}
<flux:modal wire:model="showPackageModal" class="max-w-xl">
<div class="space-y-6">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-purple-500/20 flex items-center justify-center">
<core:icon name="box" class="text-purple-600 dark:text-purple-400" />
</div>
<flux:heading size="lg">{{ $editingPackageId ? 'Edit Package' : 'Create Package' }}</flux:heading>
</div>
<form wire:submit="savePackage" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<flux:input wire:model="packageCode" label="Code" placeholder="pro" required />
<flux:input wire:model="packageName" label="Name" placeholder="Pro Plan" required />
</div>
<flux:textarea wire:model="packageDescription" label="Description" rows="2" />
<div class="grid grid-cols-3 gap-4">
<flux:input wire:model="packageIcon" label="Icon" placeholder="box" />
<flux:input wire:model="packageColor" label="Colour" placeholder="blue" />
<flux:input wire:model="packageSortOrder" label="Sort Order" type="number" />
</div>
<div class="grid grid-cols-2 gap-4">
<flux:checkbox wire:model="packageIsBasePackage" label="Base Package" description="Only one per workspace" />
<flux:checkbox wire:model="packageIsStackable" label="Stackable" description="Can combine with others" />
</div>
<div class="grid grid-cols-2 gap-4">
<flux:checkbox wire:model="packageIsActive" label="Active" />
<flux:checkbox wire:model="packageIsPublic" label="Public" description="Show on pricing" />
</div>
<div class="flex justify-end gap-3 pt-4">
<flux:button wire:click="closePackageModal" variant="ghost">Cancel</flux:button>
<flux:button type="submit" variant="primary">
{{ $editingPackageId ? 'Update' : 'Create' }}
</flux:button>
</div>
</form>
</div>
</flux:modal>
{{-- Feature Modal --}}
<flux:modal wire:model="showFeatureModal" class="max-w-xl">
<div class="space-y-6">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center">
<core:icon name="puzzle-piece" class="text-blue-600 dark:text-blue-400" />
</div>
<flux:heading size="lg">{{ $editingFeatureId ? 'Edit Feature' : 'Create Feature' }}</flux:heading>
</div>
<form wire:submit="saveFeature" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<flux:input wire:model="featureCode" label="Code" placeholder="bio.pages" required />
<flux:input wire:model="featureName" label="Name" placeholder="Bio Pages" required />
</div>
<flux:textarea wire:model="featureDescription" label="Description" rows="2" />
<div class="grid grid-cols-2 gap-4">
<flux:input wire:model="featureCategory" label="Category" placeholder="biolink" />
<flux:input wire:model="featureSortOrder" label="Sort Order" type="number" />
</div>
<div class="grid grid-cols-2 gap-4">
<flux:select wire:model.live="featureType" label="Type">
<flux:select.option value="boolean">Boolean (on/off)</flux:select.option>
<flux:select.option value="limit">Limit (quota)</flux:select.option>
<flux:select.option value="unlimited">Unlimited</flux:select.option>
</flux:select>
<flux:select wire:model.live="featureResetType" label="Reset">
<flux:select.option value="none">Never</flux:select.option>
<flux:select.option value="monthly">Monthly</flux:select.option>
<flux:select.option value="rolling">Rolling Window</flux:select.option>
</flux:select>
</div>
@if($featureResetType === 'rolling')
<flux:input wire:model="featureRollingDays" type="number" label="Rolling Window (days)" placeholder="30" />
@endif
@if($featureType === 'limit')
<flux:select wire:model="featureParentId" label="Parent Pool (optional)">
<flux:select.option value="">None</flux:select.option>
@foreach($this->parentFeatures as $parent)
<flux:select.option value="{{ $parent->id }}">{{ $parent->name }} ({{ $parent->code }})</flux:select.option>
@endforeach
</flux:select>
@endif
<flux:checkbox wire:model="featureIsActive" label="Active" />
<div class="flex justify-end gap-3 pt-4">
<flux:button wire:click="closeFeatureModal" variant="ghost">Cancel</flux:button>
<flux:button type="submit" variant="primary">
{{ $editingFeatureId ? 'Update' : 'Create' }}
</flux:button>
</div>
</form>
</div>
</flux:modal>
{{-- Features Assignment Modal --}}
<flux:modal wire:model="showFeaturesModal" class="max-w-2xl">
<div class="space-y-6">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-green-500/20 flex items-center justify-center">
<core:icon name="puzzle-piece" class="text-green-600 dark:text-green-400" />
</div>
<flux:heading size="lg">Assign Features to Package</flux:heading>
</div>
<form wire:submit="saveFeatures" class="space-y-6">
@foreach($this->allFeatures as $category => $categoryFeatures)
<div>
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2 capitalize">{{ $category ?: 'General' }}</h4>
<div class="space-y-2">
@foreach($categoryFeatures as $feature)
<div class="flex items-center gap-4 p-3 rounded-lg border border-gray-200 dark:border-gray-700 {{ isset($selectedFeatures[$feature->id]['enabled']) && $selectedFeatures[$feature->id]['enabled'] ? 'bg-green-500/5 border-green-500/30' : '' }}">
<flux:checkbox
wire:click="toggleFeature({{ $feature->id }})"
:checked="isset($selectedFeatures[$feature->id]['enabled']) && $selectedFeatures[$feature->id]['enabled']"
/>
<div class="flex-1 min-w-0">
<div class="font-medium text-gray-800 dark:text-gray-100">{{ $feature->name }}</div>
<code class="text-xs text-gray-500">{{ $feature->code }}</code>
</div>
@if($feature->type === 'limit')
<flux:input
type="number"
wire:model="selectedFeatures.{{ $feature->id }}.limit"
placeholder="Limit"
class="w-24"
:disabled="!isset($selectedFeatures[$feature->id]['enabled']) || !$selectedFeatures[$feature->id]['enabled']"
/>
@elseif($feature->type === 'unlimited')
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-500/20 text-purple-600">Unlimited</span>
@else
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-500/20 text-gray-600">Boolean</span>
@endif
</div>
@endforeach
</div>
</div>
@endforeach
<div class="flex justify-end gap-3 pt-4">
<flux:button wire:click="$set('showFeaturesModal', false)" variant="ghost">Cancel</flux:button>
<flux:button type="submit" variant="primary">Save Features</flux:button>
</div>
</form>
</div>
</flux:modal>
</div>

View file

@ -0,0 +1,77 @@
<admin:module title="Features" subtitle="Manage entitlement features">
<x-slot:actions>
<core:button wire:click="openCreate" icon="plus">New Feature</core:button>
</x-slot:actions>
<admin:flash />
<admin:manager-table
:columns="$this->tableColumns"
:rows="$this->tableRows"
:pagination="$this->features"
empty="No features found. Create your first feature to get started."
emptyIcon="puzzle-piece"
/>
{{-- Create/Edit Feature Modal --}}
<core:modal wire:model="showModal" class="max-w-xl">
<core:heading size="lg">
{{ $editingId ? 'Edit Feature' : 'Create Feature' }}
</core:heading>
<form wire:submit="save" class="mt-4 space-y-4">
<div class="grid grid-cols-2 gap-4">
<core:input wire:model="code" label="Code" placeholder="social.posts.scheduled" required />
<core:input wire:model="name" label="Name" placeholder="Scheduled Posts" required />
</div>
<core:textarea wire:model="description" label="Description" rows="2" />
<div class="grid grid-cols-2 gap-4">
<core:select wire:model="category" label="Category">
<option value="">Select category...</option>
@foreach ($this->categories as $cat)
<option value="{{ $cat }}">{{ ucfirst($cat) }}</option>
@endforeach
<option value="__new">+ New category</option>
</core:select>
<core:input wire:model="sort_order" label="Sort Order" type="number" />
</div>
<div class="grid grid-cols-2 gap-4">
<core:select wire:model="type" label="Type">
<option value="boolean">Boolean (on/off)</option>
<option value="limit">Limit (numeric)</option>
<option value="unlimited">Unlimited</option>
</core:select>
<core:select wire:model="reset_type" label="Reset Type">
<option value="none">Never resets</option>
<option value="monthly">Monthly (billing cycle)</option>
<option value="rolling">Rolling window</option>
</core:select>
</div>
@if ($reset_type === 'rolling')
<core:input wire:model="rolling_window_days" label="Rolling Window (days)" type="number" placeholder="30" />
@endif
<core:select wire:model="parent_feature_id" label="Parent Feature (for global pools)">
<option value="">No parent (standalone)</option>
@foreach ($this->parentFeatures as $parent)
<option value="{{ $parent->id }}">{{ $parent->name }} ({{ $parent->code }})</option>
@endforeach
</core:select>
<core:checkbox wire:model="is_active" label="Active" />
<div class="flex justify-end gap-2 pt-4">
<core:button variant="ghost" wire:click="closeModal">Cancel</core:button>
<core:button type="submit" variant="primary">
{{ $editingId ? 'Update' : 'Create' }}
</core:button>
</div>
</form>
</core:modal>
</admin:module>

View file

@ -0,0 +1,101 @@
<admin:module title="Packages" subtitle="Manage entitlement packages">
<x-slot:actions>
<core:button wire:click="openCreate" icon="plus">New Package</core:button>
</x-slot:actions>
<admin:flash />
<admin:manager-table
:columns="$this->tableColumns"
:rows="$this->tableRows"
:pagination="$this->packages"
empty="No packages found. Create your first package to get started."
emptyIcon="cube"
/>
{{-- Create/Edit Package Modal --}}
<core:modal wire:model="showModal" class="max-w-xl">
<core:heading size="lg">
{{ $editingId ? 'Edit Package' : 'Create Package' }}
</core:heading>
<form wire:submit="save" class="mt-4 space-y-4">
<div class="grid grid-cols-2 gap-4">
<core:input wire:model="code" label="Code" placeholder="creator" required />
<core:input wire:model="name" label="Name" placeholder="Creator Plan" required />
</div>
<core:textarea wire:model="description" label="Description" rows="2" />
<div class="grid grid-cols-3 gap-4">
<core:input wire:model="icon" label="Icon" placeholder="user" />
<core:input wire:model="color" label="Colour" placeholder="blue" />
<core:input wire:model="sort_order" label="Sort Order" type="number" />
</div>
<core:input wire:model="blesta_package_id" label="Blesta Package ID" placeholder="pkg_123" />
<div class="grid grid-cols-2 gap-4">
<core:checkbox wire:model="is_base_package" label="Base Package" description="Only one base package per workspace" />
<core:checkbox wire:model="is_stackable" label="Stackable" description="Can be combined with other packages" />
</div>
<div class="grid grid-cols-2 gap-4">
<core:checkbox wire:model="is_active" label="Active" />
<core:checkbox wire:model="is_public" label="Public" description="Show on pricing page" />
</div>
<div class="flex justify-end gap-2 pt-4">
<core:button variant="ghost" wire:click="closeModal">Cancel</core:button>
<core:button type="submit" variant="primary">
{{ $editingId ? 'Update' : 'Create' }}
</core:button>
</div>
</form>
</core:modal>
{{-- Features Assignment Modal --}}
<core:modal wire:model="showFeaturesModal" class="max-w-2xl">
<core:heading size="lg">Assign Features</core:heading>
<form wire:submit="saveFeatures" class="mt-4 space-y-6">
@foreach ($this->features as $category => $categoryFeatures)
<div>
<core:heading size="sm" class="mb-2 capitalize">{{ $category }}</core:heading>
<div class="space-y-2">
@foreach ($categoryFeatures as $feature)
<div class="flex items-center gap-4 p-2 rounded border border-gray-200 dark:border-gray-700">
<core:checkbox
wire:click="toggleFeature({{ $feature->id }})"
:checked="isset($selectedFeatures[$feature->id]['enabled']) && $selectedFeatures[$feature->id]['enabled']"
/>
<div class="flex-1">
<div class="font-medium">{{ $feature->name }}</div>
<code class="text-xs text-gray-500">{{ $feature->code }}</code>
</div>
@if ($feature->type === 'limit')
<core:input
type="number"
wire:model="selectedFeatures.{{ $feature->id }}.limit"
placeholder="Limit"
class="w-24"
:disabled="!isset($selectedFeatures[$feature->id]['enabled']) || !$selectedFeatures[$feature->id]['enabled']"
/>
@elseif ($feature->type === 'unlimited')
<core:badge color="purple">Unlimited</core:badge>
@else
<core:badge color="gray">Boolean</core:badge>
@endif
</div>
@endforeach
</div>
</div>
@endforeach
<div class="flex justify-end gap-2 pt-4">
<core:button variant="ghost" wire:click="closeModal">Cancel</core:button>
<core:button type="submit" variant="primary">Save Features</core:button>
</div>
</form>
</core:modal>
</admin:module>

View file

@ -0,0 +1,211 @@
{{--
Global search component with Command+K keyboard shortcut.
Include in your layout:
<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 Command+K / Ctrl+K keyboard shortcut
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
$wire.openSearch();
}
});
}
}"
x-on:navigate-to-url.window="Livewire.navigate($event.detail.url)"
>
{{-- Search modal --}}
<core:modal wire:model="open" class="max-w-xl" variant="bare">
<div
class="overflow-hidden rounded-xl bg-white shadow-2xl ring-1 ring-zinc-900/5 dark:bg-zinc-800 dark:ring-zinc-700"
x-on:keydown.arrow-up.prevent="$wire.navigateUp()"
x-on:keydown.arrow-down.prevent="$wire.navigateDown()"
x-on:keydown.enter.prevent="$wire.selectCurrent()"
x-on:keydown.escape.prevent="$wire.closeSearch()"
>
{{-- Search input --}}
<div class="relative">
<core:icon name="magnifying-glass" class="pointer-events-none absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-zinc-400" />
<input
wire:model.live.debounce.300ms="query"
type="text"
placeholder="{{ __('hub::hub.search.placeholder') }}"
class="w-full border-0 bg-transparent py-4 pl-12 pr-4 text-zinc-900 placeholder-zinc-400 focus:outline-none focus:ring-0 dark:text-white"
autofocus
/>
@if($query)
<button
wire:click="$set('query', '')"
type="button"
class="absolute right-4 top-1/2 -translate-y-1/2 rounded p-1 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300"
>
<core:icon name="x-mark" class="h-4 w-4" />
</button>
@endif
</div>
{{-- Results --}}
@if(strlen($query) >= 2)
<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 => $group)
@if(count($group['results']) > 0)
{{-- Category header --}}
<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($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 {{ $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 {{ $selectedIndex === $currentIndex ? 'text-blue-900 dark:text-blue-100' : 'text-zinc-900 dark:text-white' }}">
{{ $item['title'] }}
</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)
<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
@endforeach
@endif
@empty
{{-- No results --}}
<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">
{{ __('hub::hub.search.no_results', ['query' => $query]) }}
</p>
</div>
@endforelse
@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">
{{ __('hub::hub.search.no_results', ['query' => $query]) }}
</p>
</div>
@endif
</div>
{{-- Footer with keyboard hints --}}
<div class="flex items-center justify-between border-t border-zinc-200 bg-zinc-50 px-4 py-2 dark:border-zinc-700 dark:bg-zinc-800/50">
<div class="flex items-center gap-4 text-xs text-zinc-400">
<span class="flex items-center gap-1">
<kbd class="rounded bg-zinc-200 px-1.5 py-0.5 font-mono dark:bg-zinc-700"></kbd>
<kbd class="rounded bg-zinc-200 px-1.5 py-0.5 font-mono dark:bg-zinc-700"></kbd>
{{ __('hub::hub.search.navigate') }}
</span>
<span class="flex items-center gap-1">
<kbd class="rounded bg-zinc-200 px-1.5 py-0.5 font-mono dark:bg-zinc-700"></kbd>
{{ __('hub::hub.search.select') }}
</span>
<span class="flex items-center gap-1">
<kbd class="rounded bg-zinc-200 px-1.5 py-0.5 font-mono dark:bg-zinc-700">esc</kbd>
{{ __('hub::hub.search.close') }}
</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">
<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">
{{ __('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>
</core:modal>
</div>

View file

@ -0,0 +1,180 @@
<div class="space-y-6">
{{-- Header --}}
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-semibold text-zinc-900 dark:text-white">Honeypot Monitor</h1>
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
Track requests to disallowed paths. These may indicate malicious crawlers.
</p>
</div>
<div class="flex gap-2">
<core:button wire:click="deleteOld(30)" variant="ghost" size="sm">
<core:icon name="trash" class="w-4 h-4 mr-1" />
Purge 30d+
</core:button>
</div>
</div>
{{-- Flash Message --}}
@if (session()->has('message'))
<core:callout variant="success">
{{ session('message') }}
</core:callout>
@endif
{{-- Stats Grid --}}
<div class="grid grid-cols-2 md:grid-cols-5 gap-4">
<div class="bg-white dark:bg-zinc-800 rounded-lg p-4 border border-zinc-200 dark:border-zinc-700">
<div class="text-2xl font-bold text-zinc-900 dark:text-white">{{ number_format($stats['total']) }}</div>
<div class="text-sm text-zinc-500 dark:text-zinc-400">Total Hits</div>
</div>
<div class="bg-white dark:bg-zinc-800 rounded-lg p-4 border border-zinc-200 dark:border-zinc-700">
<div class="text-2xl font-bold text-zinc-900 dark:text-white">{{ number_format($stats['today']) }}</div>
<div class="text-sm text-zinc-500 dark:text-zinc-400">Today</div>
</div>
<div class="bg-white dark:bg-zinc-800 rounded-lg p-4 border border-zinc-200 dark:border-zinc-700">
<div class="text-2xl font-bold text-zinc-900 dark:text-white">{{ number_format($stats['this_week']) }}</div>
<div class="text-sm text-zinc-500 dark:text-zinc-400">This Week</div>
</div>
<div class="bg-white dark:bg-zinc-800 rounded-lg p-4 border border-zinc-200 dark:border-zinc-700">
<div class="text-2xl font-bold text-zinc-900 dark:text-white">{{ number_format($stats['unique_ips']) }}</div>
<div class="text-sm text-zinc-500 dark:text-zinc-400">Unique IPs</div>
</div>
<div class="bg-white dark:bg-zinc-800 rounded-lg p-4 border border-zinc-200 dark:border-zinc-700">
<div class="text-2xl font-bold text-orange-600">{{ number_format($stats['bots']) }}</div>
<div class="text-sm text-zinc-500 dark:text-zinc-400">Known Bots</div>
</div>
</div>
{{-- Top Offenders --}}
<div class="grid md:grid-cols-2 gap-4">
{{-- Top IPs --}}
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
<div class="px-4 py-3 border-b border-zinc-200 dark:border-zinc-700">
<h3 class="font-medium text-zinc-900 dark:text-white">Top IPs</h3>
</div>
<div class="divide-y divide-zinc-200 dark:divide-zinc-700">
@forelse($stats['top_ips'] as $row)
<div class="px-4 py-2 flex items-center justify-between">
<code class="text-sm text-zinc-600 dark:text-zinc-300">{{ $row->ip_address }}</code>
<span class="text-sm font-medium text-zinc-900 dark:text-white">{{ $row->hits }} hits</span>
</div>
@empty
<div class="px-4 py-3 text-sm text-zinc-500">No data yet</div>
@endforelse
</div>
</div>
{{-- Top Bots --}}
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
<div class="px-4 py-3 border-b border-zinc-200 dark:border-zinc-700">
<h3 class="font-medium text-zinc-900 dark:text-white">Top Bots</h3>
</div>
<div class="divide-y divide-zinc-200 dark:divide-zinc-700">
@forelse($stats['top_bots'] as $row)
<div class="px-4 py-2 flex items-center justify-between">
<span class="text-sm text-zinc-600 dark:text-zinc-300">{{ $row->bot_name }}</span>
<span class="text-sm font-medium text-zinc-900 dark:text-white">{{ $row->hits }} hits</span>
</div>
@empty
<div class="px-4 py-3 text-sm text-zinc-500">No bots detected yet</div>
@endforelse
</div>
</div>
</div>
{{-- Filters --}}
<div class="flex flex-wrap gap-4 items-center">
<div class="flex-1 min-w-64">
<core:input
wire:model.live.debounce.300ms="search"
type="search"
placeholder="Search IP, user agent, or bot name..."
/>
</div>
<core:select wire:model.live="botFilter" class="w-40">
<option value="">All requests</option>
<option value="1">Bots only</option>
<option value="0">Non-bots</option>
</core:select>
</div>
{{-- Hits Table --}}
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-zinc-200 dark:divide-zinc-700">
<thead class="bg-zinc-50 dark:bg-zinc-900">
<tr>
<th wire:click="sortBy('created_at')" class="px-4 py-3 text-left text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider cursor-pointer hover:text-zinc-700 dark:hover:text-zinc-200">
Time
@if($sortField === 'created_at')
<core:icon name="{{ $sortDirection === 'asc' ? 'arrow-up' : 'arrow-down' }}" class="w-3 h-3 inline ml-1" />
@endif
</th>
<th wire:click="sortBy('ip_address')" class="px-4 py-3 text-left text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider cursor-pointer hover:text-zinc-700 dark:hover:text-zinc-200">
IP Address
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
Path
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
Bot
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
User Agent
</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-200 dark:divide-zinc-700">
@forelse($hits as $hit)
<tr class="hover:bg-zinc-50 dark:hover:bg-zinc-700/50">
<td class="px-4 py-3 text-sm text-zinc-500 dark:text-zinc-400 whitespace-nowrap">
{{ $hit->created_at->diffForHumans() }}
</td>
<td class="px-4 py-3">
<code class="text-sm text-zinc-900 dark:text-white">{{ $hit->ip_address }}</code>
@if($hit->country)
<span class="text-xs text-zinc-500 ml-1">{{ $hit->country }}</span>
@endif
</td>
<td class="px-4 py-3 text-sm text-zinc-600 dark:text-zinc-300">
<code>{{ $hit->path }}</code>
</td>
<td class="px-4 py-3">
@if($hit->is_bot)
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400">
{{ $hit->bot_name ?? 'Bot' }}
</span>
@else
<span class="text-sm text-zinc-400">-</span>
@endif
</td>
<td class="px-4 py-3 text-sm text-zinc-500 dark:text-zinc-400 max-w-xs truncate" title="{{ $hit->user_agent }}">
{{ Str::limit($hit->user_agent, 60) }}
</td>
<td class="px-4 py-3 text-right">
<core:button wire:click="blockIp('{{ $hit->ip_address }}')" variant="ghost" size="xs">
Block
</core:button>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-4 py-8 text-center text-zinc-500 dark:text-zinc-400">
No honeypot hits recorded yet. Good news - no one's ignoring your robots.txt!
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
{{-- Pagination --}}
@if($hits->hasPages())
<div class="px-4 py-3 border-t border-zinc-200 dark:border-zinc-700">
{{ $hits->links() }}
</div>
@endif
</div>
</div>

View file

@ -0,0 +1,126 @@
@php
$darkMode = request()->cookie('dark-mode') === 'true';
@endphp
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="overscroll-none {{ $darkMode ? 'dark' : '' }}"
style="color-scheme: {{ $darkMode ? 'dark' : 'light' }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? 'Admin' }} - {{ config('app.name', 'Host Hub') }}</title>
{{-- Critical CSS: Prevents white flash during page load/navigation --}}
<style>
html {
background-color: #f3f4f6;
}
html.dark {
background-color: #111827;
}
</style>
<script>
{{-- Sync all settings from localStorage to cookies for PHP middleware --}}
(function () {
// Dark mode - sync our key with Flux's key
var darkMode = localStorage.getItem('dark-mode');
if (darkMode === 'true') {
// Sync to Flux's appearance key so the Flux directive doesn't override
localStorage.setItem('flux.appearance', 'dark');
} else if (darkMode === 'false') {
localStorage.setItem('flux.appearance', 'light');
}
// Set cookie for PHP
document.cookie = 'dark-mode=' + (darkMode || 'false') + '; path=/; SameSite=Lax';
// Icon settings
var iconStyle = localStorage.getItem('icon-style') || 'fa-notdog fa-solid';
var iconSize = localStorage.getItem('icon-size') || 'fa-lg';
document.cookie = 'icon-style=' + iconStyle + '; path=/; SameSite=Lax';
document.cookie = 'icon-size=' + iconSize + '; path=/; SameSite=Lax';
})();
</script>
<!-- Fonts -->
@include('layouts::partials.fonts')
<!-- Font Awesome -->
@if(file_exists(public_path('vendor/fontawesome/css/all.min.css')))
<link rel="stylesheet" href="/vendor/fontawesome/css/all.min.css?v={{ filemtime(public_path('vendor/fontawesome/css/all.min.css')) }}">
@else
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
@endif
<!-- Scripts -->
@vite(['resources/css/admin.css', 'resources/js/app.js'])
<!-- Flux -->
@fluxAppearance
</head>
<body
class="font-inter antialiased bg-gray-100 dark:bg-gray-900 text-gray-600 dark:text-gray-400 overscroll-none"
x-data="{ sidebarOpen: false }"
@open-sidebar.window="sidebarOpen = true"
>
<!-- Page wrapper -->
<div class="flex h-[100dvh] overflow-hidden overscroll-none">
@include('hub::admin.components.sidebar')
<!-- Content area (offset for fixed sidebar) -->
<div
class="relative flex flex-col flex-1 overflow-y-auto overflow-x-hidden overscroll-none ml-0 sm:ml-20 lg:ml-64"
x-ref="contentarea">
@include('hub::admin.components.header')
<main class="grow px-4 sm:px-6 lg:px-8 py-8 w-full max-w-9xl mx-auto">
{{ $slot }}
</main>
</div>
</div>
<!-- Toast Notifications -->
@persist('toast')
<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')
<!-- Flux Scripts -->
@fluxScripts
@stack('scripts')
<script>
// Light/Dark mode toggle (guarded for Livewire navigation)
(function() {
if (window.__lightSwitchInitialized) return;
window.__lightSwitchInitialized = true;
const lightSwitch = document.querySelector('.light-switch');
if (lightSwitch) {
lightSwitch.addEventListener('change', () => {
const {checked} = lightSwitch;
document.documentElement.classList.toggle('dark', checked);
document.documentElement.style.colorScheme = checked ? 'dark' : 'light';
localStorage.setItem('dark-mode', checked);
});
}
})();
</script>
</body>
</html>

View file

@ -0,0 +1,706 @@
<div>
{{-- Header --}}
<div class="mb-8">
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 mb-4">
<a href="{{ route('hub.platform') }}" wire:navigate class="hover:text-gray-700 dark:hover:text-gray-200">
<core:icon name="arrow-left" class="mr-1" />
Platform Users
</a>
<span>/</span>
<span>{{ $user->name }}</span>
</div>
<div class="flex items-start justify-between">
<div class="flex items-center gap-4">
@php
$tierColor = match($user->tier?->value ?? 'free') {
'hades' => 'violet',
'apollo' => 'blue',
default => 'gray',
};
@endphp
<div class="w-16 h-16 rounded-xl bg-{{ $tierColor }}-500/20 flex items-center justify-center">
<core:icon name="user" class="text-2xl text-{{ $tierColor }}-600 dark:text-{{ $tierColor }}-400" />
</div>
<div>
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-100">{{ $user->name }}</h1>
<div class="flex items-center gap-3 mt-1">
<span class="text-sm text-gray-500 dark:text-gray-400">{{ $user->email }}</span>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-{{ $tierColor }}-500/20 text-{{ $tierColor }}-600 dark:text-{{ $tierColor }}-400">
{{ ucfirst($user->tier?->value ?? 'free') }}
</span>
@if($user->email_verified_at)
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-500/20 text-green-600 dark:text-green-400">
<core:icon name="check-circle" class="mr-1" />
Verified
</span>
@else
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-500/20 text-amber-600 dark:text-amber-400">
<core:icon name="clock" class="mr-1" />
Unverified
</span>
@endif
</div>
</div>
</div>
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-violet-500/20 text-violet-600 dark:text-violet-400">
<core:icon name="crown" class="mr-1.5" />
Hades Only
</span>
</div>
</div>
{{-- Action message --}}
@if($actionMessage)
<div class="mb-6 p-4 rounded-lg {{ $actionType === 'success' ? 'bg-green-500/20 text-green-700 dark:text-green-400' : ($actionType === 'warning' ? 'bg-amber-500/20 text-amber-700 dark:text-amber-400' : 'bg-red-500/20 text-red-700 dark:text-red-400') }}">
<div class="flex items-center">
<core:icon name="{{ $actionType === 'success' ? 'check-circle' : ($actionType === 'warning' ? 'triangle-exclamation' : 'circle-xmark') }}" class="mr-2" />
{{ $actionMessage }}
</div>
</div>
@endif
{{-- Pending deletion warning --}}
@if($pendingDeletion)
<div class="mb-6 p-4 rounded-lg bg-red-500/20 border border-red-500/30">
<div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-3">
<div class="w-10 h-10 rounded-lg bg-red-500/20 flex items-center justify-center flex-shrink-0">
<core:icon name="triangle-exclamation" class="text-red-600 dark:text-red-400" />
</div>
<div>
<div class="font-medium text-red-800 dark:text-red-200">Account deletion scheduled</div>
<div class="text-sm text-red-700 dark:text-red-300 mt-1">
This account is scheduled for deletion on {{ $pendingDeletion->expires_at->format('j F Y') }}.
@if($pendingDeletion->reason)
Reason: {{ $pendingDeletion->reason }}
@endif
</div>
</div>
</div>
<flux:button wire:click="cancelPendingDeletion" size="sm" variant="danger">
Cancel deletion
</flux:button>
</div>
</div>
@endif
{{-- Tabs --}}
<div class="border-b border-gray-200 dark:border-gray-700 mb-6">
<nav class="flex gap-6" aria-label="Tabs">
@foreach([
'overview' => ['label' => 'Overview', 'icon' => 'gauge'],
'workspaces' => ['label' => 'Workspaces', 'icon' => 'folder'],
'entitlements' => ['label' => 'Entitlements', 'icon' => 'key'],
'data' => ['label' => 'Data & Privacy', 'icon' => 'shield-halved'],
'danger' => ['label' => 'Danger Zone', 'icon' => 'triangle-exclamation'],
] as $tab => $info)
<button
wire:click="setTab('{{ $tab }}')"
class="flex items-center gap-2 py-3 px-1 border-b-2 text-sm font-medium transition {{ $activeTab === $tab ? 'border-blue-500 text-blue-600 dark:text-blue-400' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200' }}"
>
<core:icon name="{{ $info['icon'] }}" />
{{ $info['label'] }}
</button>
@endforeach
</nav>
</div>
{{-- Tab Content --}}
<div class="min-h-[400px]">
{{-- Overview Tab --}}
@if($activeTab === 'overview')
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
{{-- Main content --}}
<div class="lg:col-span-2 space-y-6">
{{-- Account Information --}}
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs p-5">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-4">Account Information</h3>
<div class="grid grid-cols-2 gap-4">
<div>
<div class="text-xs text-gray-500 dark:text-gray-400">User ID</div>
<div class="font-mono text-gray-800 dark:text-gray-100">{{ $user->id }}</div>
</div>
<div>
<div class="text-xs text-gray-500 dark:text-gray-400">Created</div>
<div class="text-gray-800 dark:text-gray-100">{{ $user->created_at?->format('d M Y, H:i') }}</div>
</div>
<div>
<div class="text-xs text-gray-500 dark:text-gray-400">Last Updated</div>
<div class="text-gray-800 dark:text-gray-100">{{ $user->updated_at?->format('d M Y, H:i') }}</div>
</div>
<div>
<div class="text-xs text-gray-500 dark:text-gray-400">Email Verified</div>
<div class="text-gray-800 dark:text-gray-100">
{{ $user->email_verified_at ? $user->email_verified_at->format('d M Y, H:i') : 'Not verified' }}
</div>
</div>
@if($user->tier_expires_at)
<div class="col-span-2">
<div class="text-xs text-gray-500 dark:text-gray-400">Tier Expires</div>
<div class="text-gray-800 dark:text-gray-100">{{ $user->tier_expires_at->format('d M Y') }}</div>
</div>
@endif
</div>
</div>
{{-- Tier Management --}}
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs p-5">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-4">Tier Management</h3>
<div class="flex items-end gap-4">
<div class="flex-1">
<flux:select wire:model="editingTier" label="Account Tier">
@foreach($tiers as $tier)
<flux:select.option value="{{ $tier->value }}">{{ ucfirst($tier->value) }}</flux:select.option>
@endforeach
</flux:select>
</div>
<flux:button wire:click="saveTier" variant="primary">Save Tier</flux:button>
</div>
</div>
{{-- Email Verification --}}
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs p-5">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-4">Email Verification</h3>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<flux:checkbox wire:model="editingVerified" label="Email verified" />
<flux:button wire:click="saveVerification" size="sm">Save</flux:button>
</div>
<flux:button wire:click="resendVerification" variant="ghost" size="sm">
<core:icon name="envelope" class="mr-1" />
Resend verification
</flux:button>
</div>
</div>
</div>
{{-- Sidebar --}}
<div class="space-y-4">
{{-- Quick Stats --}}
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs p-5">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-4">Quick Stats</h3>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400">Workspaces</span>
<span class="font-medium text-gray-800 dark:text-gray-100">{{ $dataCounts['workspaces'] }}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400">Deletion Requests</span>
<span class="font-medium text-gray-800 dark:text-gray-100">{{ $dataCounts['deletion_requests'] }}</span>
</div>
</div>
</div>
{{-- Account Details --}}
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs p-5">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-4">Details</h3>
<div class="space-y-3 text-sm">
<div>
<div class="text-gray-500 dark:text-gray-400">Tier</div>
<div class="text-gray-800 dark:text-gray-100 capitalize">{{ $user->tier?->value ?? 'free' }}</div>
</div>
<div>
<div class="text-gray-500 dark:text-gray-400">Status</div>
<div class="flex items-center gap-2">
@if($user->email_verified_at)
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-500/20 text-green-600">Active</span>
@else
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-500/20 text-amber-600">Pending Verification</span>
@endif
</div>
</div>
</div>
</div>
</div>
</div>
@endif
{{-- Workspaces Tab --}}
@if($activeTab === 'workspaces')
<div class="space-y-6">
{{-- Workspace List --}}
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs overflow-hidden">
<div class="px-5 py-4 border-b border-gray-100 dark:border-gray-700">
<h3 class="font-medium text-gray-800 dark:text-gray-100">Workspaces ({{ $this->workspaces->count() }})</h3>
</div>
@if($this->workspaces->isEmpty())
<div class="px-5 py-12 text-center text-gray-500 dark:text-gray-400">
<div class="w-12 h-12 rounded-xl bg-gray-500/20 flex items-center justify-center mx-auto mb-3">
<core:icon name="folder" class="text-xl text-gray-400" />
</div>
<p>No workspaces</p>
<p class="text-sm mt-1">This user hasn't created any workspaces yet.</p>
</div>
@else
<div class="divide-y divide-gray-100 dark:divide-gray-700">
@foreach($this->workspaces as $workspace)
<div class="px-5 py-4">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-{{ $workspace->color ?? 'blue' }}-500/20 flex items-center justify-center">
<core:icon name="{{ $workspace->icon ?? 'folder' }}" class="text-{{ $workspace->color ?? 'blue' }}-600 dark:text-{{ $workspace->color ?? 'blue' }}-400" />
</div>
<div>
<div class="font-medium text-gray-800 dark:text-gray-100">{{ $workspace->name }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400 font-mono">{{ $workspace->slug }}</div>
</div>
</div>
<flux:button wire:click="openPackageModal({{ $workspace->id }})" size="sm">
<core:icon name="plus" class="mr-1" />
Add Package
</flux:button>
</div>
@if($workspace->workspacePackages->isEmpty())
<div class="text-sm text-gray-500 dark:text-gray-400 italic ml-13">No packages provisioned</div>
@else
<div class="ml-13 space-y-2">
@foreach($workspace->workspacePackages as $wp)
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-{{ $wp->package->color ?? 'gray' }}-500/20 flex items-center justify-center">
<core:icon name="{{ $wp->package->icon ?? 'box' }}" class="text-sm text-{{ $wp->package->color ?? 'gray' }}-600 dark:text-{{ $wp->package->color ?? 'gray' }}-400" />
</div>
<div>
<div class="font-medium text-gray-800 dark:text-gray-100 text-sm">{{ $wp->package->name }}</div>
<div class="text-xs text-gray-500 font-mono">{{ $wp->package->code }}</div>
</div>
</div>
<div class="flex items-center gap-2">
@if($wp->package->is_base_package)
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-500/20 text-blue-600 dark:text-blue-400">Base</span>
@endif
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-500/20 text-green-600 dark:text-green-400">
{{ ucfirst($wp->status ?? 'active') }}
</span>
<button
wire:click="revokePackage({{ $workspace->id }}, '{{ $wp->package->code }}')"
wire:confirm="Revoke '{{ $wp->package->name }}' from this workspace?"
class="p-1.5 text-red-600 hover:bg-red-500/20 rounded-lg transition"
title="Revoke package"
>
<core:icon name="trash" class="text-sm" />
</button>
</div>
</div>
@endforeach
</div>
@endif
</div>
@endforeach
</div>
@endif
</div>
</div>
@endif
{{-- Entitlements Tab --}}
@if($activeTab === 'entitlements')
<div class="space-y-6">
@if($this->workspaces->isEmpty())
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs p-12 text-center">
<div class="w-12 h-12 rounded-xl bg-gray-500/20 flex items-center justify-center mx-auto mb-3">
<core:icon name="key" class="text-xl text-gray-400" />
</div>
<p class="text-gray-500 dark:text-gray-400">No workspaces</p>
<p class="text-sm text-gray-400 mt-1">This user has no workspaces to manage entitlements for.</p>
</div>
@else
@foreach($this->workspaceEntitlements as $wsId => $data)
@php $workspace = $data['workspace']; $stats = $data['stats']; $boosts = $data['boosts']; $summary = $data['summary']; @endphp
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs overflow-hidden">
{{-- Workspace Header --}}
<div class="px-5 py-4 border-b border-gray-100 dark:border-gray-700 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-{{ $workspace->color ?? 'blue' }}-500/20 flex items-center justify-center">
<core:icon name="{{ $workspace->icon ?? 'folder' }}" class="text-{{ $workspace->color ?? 'blue' }}-600 dark:text-{{ $workspace->color ?? 'blue' }}-400" />
</div>
<div>
<h3 class="font-medium text-gray-800 dark:text-gray-100">{{ $workspace->name }}</h3>
<div class="text-xs text-gray-500 font-mono">{{ $workspace->slug }}</div>
</div>
</div>
<flux:button wire:click="openEntitlementModal({{ $workspace->id }})" size="sm">
<core:icon name="plus" class="mr-1" />
Add Entitlement
</flux:button>
</div>
{{-- Quick Stats --}}
<div class="px-5 py-4 border-b border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/20">
<div class="grid grid-cols-4 gap-4 text-center">
<div>
<div class="text-lg font-bold text-gray-800 dark:text-gray-100">{{ $stats['total'] }}</div>
<div class="text-xs text-gray-500">Total</div>
</div>
<div>
<div class="text-lg font-bold text-green-600 dark:text-green-400">{{ $stats['allowed'] }}</div>
<div class="text-xs text-gray-500">Allowed</div>
</div>
<div>
<div class="text-lg font-bold text-red-600 dark:text-red-400">{{ $stats['denied'] }}</div>
<div class="text-xs text-gray-500">Denied</div>
</div>
<div>
<div class="text-lg font-bold text-purple-600 dark:text-purple-400">{{ $stats['boosts'] }}</div>
<div class="text-xs text-gray-500">Boosts</div>
</div>
</div>
</div>
{{-- Active Boosts --}}
@if($boosts->count() > 0)
<div class="px-5 py-4 border-b border-gray-100 dark:border-gray-700">
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-3">
<core:icon name="bolt" class="mr-1 text-purple-500" />
Active Boosts
</h4>
<div class="space-y-2">
@foreach($boosts as $boost)
<div class="flex items-center justify-between p-3 bg-purple-500/10 rounded-lg">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center">
<core:icon name="bolt" class="text-sm text-purple-600 dark:text-purple-400" />
</div>
<div>
<div class="font-medium text-gray-800 dark:text-gray-100 font-mono text-sm">{{ $boost->feature_code }}</div>
<div class="flex items-center gap-2 text-xs text-gray-500">
<span class="capitalize">{{ str_replace('_', ' ', $boost->boost_type) }}</span>
@if($boost->limit_value)
<span>· +{{ number_format($boost->limit_value) }}</span>
@endif
@if($boost->expires_at)
<span>· Expires {{ $boost->expires_at->format('d M Y') }}</span>
@else
<span class="text-green-500">· Permanent</span>
@endif
</div>
</div>
</div>
<button
wire:click="removeBoost({{ $boost->id }})"
wire:confirm="Remove this boost?"
class="p-1.5 text-red-600 hover:bg-red-500/20 rounded-lg transition"
title="Remove boost"
>
<core:icon name="trash" class="text-sm" />
</button>
</div>
@endforeach
</div>
</div>
@endif
{{-- Allowed Entitlements Summary --}}
<div class="px-5 py-4">
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-3">Allowed Features</h4>
@php
$allowedFeatures = $summary->flatten(1)->where('allowed', true);
@endphp
@if($allowedFeatures->isEmpty())
<p class="text-sm text-gray-400 italic">No features enabled</p>
@else
<div class="flex flex-wrap gap-2">
@foreach($allowedFeatures as $entitlement)
<div class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium
{{ $entitlement['unlimited'] ? 'bg-purple-500/20 text-purple-700 dark:text-purple-300' : 'bg-green-500/20 text-green-700 dark:text-green-300' }}">
<core:icon name="{{ $entitlement['unlimited'] ? 'infinity' : 'check' }}" class="text-xs" />
{{ $entitlement['name'] }}
@if(!$entitlement['unlimited'] && $entitlement['limit'])
<span class="text-gray-500">({{ number_format($entitlement['used'] ?? 0) }}/{{ number_format($entitlement['limit']) }})</span>
@endif
</div>
@endforeach
</div>
@endif
</div>
</div>
@endforeach
@endif
</div>
@endif
{{-- Data & Privacy Tab --}}
@if($activeTab === 'data')
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
{{-- Main content --}}
<div class="lg:col-span-2 space-y-6">
{{-- Stored Data Preview --}}
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs overflow-hidden">
<div class="px-5 py-4 border-b border-gray-100 dark:border-gray-700 flex items-center justify-between">
<div>
<h3 class="font-medium text-gray-800 dark:text-gray-100">Stored Data</h3>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">GDPR Article 15 - Right of access</p>
</div>
<flux:button wire:click="exportUserData" size="sm">
<core:icon name="arrow-down-tray" class="mr-1" />
Export JSON
</flux:button>
</div>
<div class="bg-gray-900 dark:bg-gray-950 p-4 overflow-x-auto max-h-[500px] overflow-y-auto">
<pre class="text-xs text-green-400 font-mono whitespace-pre-wrap">{{ json_encode($userData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</pre>
</div>
</div>
</div>
{{-- Sidebar --}}
<div class="space-y-4">
{{-- GDPR Info --}}
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs p-5">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-4">GDPR Compliance</h3>
<div class="space-y-3 text-sm">
<div class="flex items-start gap-3">
<div class="w-8 h-8 rounded-lg bg-blue-500/20 flex items-center justify-center flex-shrink-0">
<core:icon name="file-export" class="text-blue-600 dark:text-blue-400" />
</div>
<div>
<div class="font-medium text-gray-800 dark:text-gray-100">Article 20</div>
<div class="text-xs text-gray-500">Data portability</div>
</div>
</div>
<div class="flex items-start gap-3">
<div class="w-8 h-8 rounded-lg bg-green-500/20 flex items-center justify-center flex-shrink-0">
<core:icon name="eye" class="text-green-600 dark:text-green-400" />
</div>
<div>
<div class="font-medium text-gray-800 dark:text-gray-100">Article 15</div>
<div class="text-xs text-gray-500">Right of access</div>
</div>
</div>
<div class="flex items-start gap-3">
<div class="w-8 h-8 rounded-lg bg-red-500/20 flex items-center justify-center flex-shrink-0">
<core:icon name="trash" class="text-red-600 dark:text-red-400" />
</div>
<div>
<div class="font-medium text-gray-800 dark:text-gray-100">Article 17</div>
<div class="text-xs text-gray-500">Right to erasure</div>
</div>
</div>
</div>
</div>
</div>
</div>
@endif
{{-- Danger Zone Tab --}}
@if($activeTab === 'danger')
<div class="max-w-2xl space-y-6">
{{-- Scheduled Deletion --}}
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs overflow-hidden">
<div class="px-5 py-4 border-b border-amber-200 dark:border-amber-800 bg-amber-500/10">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-amber-500/20 flex items-center justify-center">
<core:icon name="clock" class="text-amber-600 dark:text-amber-400" />
</div>
<div>
<h3 class="font-medium text-amber-900 dark:text-amber-200">Schedule Deletion</h3>
<p class="text-sm text-amber-700 dark:text-amber-300">GDPR Article 17 - Right to erasure</p>
</div>
</div>
</div>
<div class="px-5 py-4">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Schedule account deletion with a 7-day grace period. The user will be notified and can cancel during this time.
</p>
<flux:button wire:click="confirmDelete(false)" :disabled="$pendingDeletion">
<core:icon name="clock" class="mr-1" />
Schedule Deletion
</flux:button>
</div>
</div>
{{-- Immediate Deletion --}}
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs overflow-hidden">
<div class="px-5 py-4 border-b border-red-200 dark:border-red-800 bg-red-500/10">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-red-500/20 flex items-center justify-center">
<core:icon name="trash" class="text-red-600 dark:text-red-400" />
</div>
<div>
<h3 class="font-medium text-red-900 dark:text-red-200">Immediate Deletion</h3>
<p class="text-sm text-red-700 dark:text-red-300">Permanently delete account and all data</p>
</div>
</div>
</div>
<div class="px-5 py-4">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Permanently delete this account and all associated data immediately. This action cannot be undone.
</p>
<flux:button wire:click="confirmDelete(true)" variant="danger">
<core:icon name="trash" class="mr-1" />
Delete Immediately
</flux:button>
</div>
</div>
{{-- Anonymisation --}}
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs overflow-hidden">
<div class="px-5 py-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/30">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-gray-500/20 flex items-center justify-center">
<core:icon name="user-minus" class="text-gray-600 dark:text-gray-400" />
</div>
<div>
<h3 class="font-medium text-gray-800 dark:text-gray-200">Anonymise Account</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Replace PII with anonymous data</p>
</div>
</div>
</div>
<div class="px-5 py-4">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Replace all personally identifiable information with anonymous data while keeping the account structure intact. This is an alternative to full deletion.
</p>
<flux:button wire:click="anonymizeUser" variant="ghost">
<core:icon name="user-minus" class="mr-1" />
Anonymise User
</flux:button>
</div>
</div>
</div>
@endif
</div>
{{-- Delete confirmation modal --}}
<flux:modal wire:model="showDeleteConfirm" class="max-w-lg">
<div class="space-y-6">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-red-500/20 flex items-center justify-center">
<core:icon name="triangle-exclamation" class="text-red-600 dark:text-red-400" />
</div>
<flux:heading size="lg">
{{ $immediateDelete ? 'Delete account immediately' : 'Schedule account deletion' }}
</flux:heading>
</div>
<p class="text-gray-600 dark:text-gray-400">
@if($immediateDelete)
This will permanently delete <strong class="text-gray-800 dark:text-gray-200">{{ $user->email }}</strong> and all associated data immediately. This action cannot be undone.
@else
This will schedule <strong class="text-gray-800 dark:text-gray-200">{{ $user->email }}</strong> for deletion in 7 days. The user can cancel during this period.
@endif
</p>
<flux:input wire:model="deleteReason" label="Reason (optional)" placeholder="GDPR request, user requested, etc." />
<div class="flex justify-end gap-3">
<flux:button wire:click="cancelDelete" variant="ghost">Cancel</flux:button>
<flux:button wire:click="scheduleDelete" :variant="$immediateDelete ? 'danger' : 'primary'">
{{ $immediateDelete ? 'Delete permanently' : 'Schedule deletion' }}
</flux:button>
</div>
</div>
</flux:modal>
{{-- Package provisioning modal --}}
<flux:modal wire:model="showPackageModal" class="max-w-lg">
<div class="space-y-6">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center">
<core:icon name="box" class="text-blue-600 dark:text-blue-400" />
</div>
<flux:heading size="lg">Provision Package</flux:heading>
</div>
@if($selectedWorkspaceId)
@php
$selectedWorkspace = $this->workspaces->firstWhere('id', $selectedWorkspaceId);
@endphp
<div class="p-3 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<div class="text-xs text-gray-500 dark:text-gray-400">Workspace</div>
<div class="font-medium text-gray-800 dark:text-gray-100">{{ $selectedWorkspace?->name ?? 'Unknown' }}</div>
</div>
@endif
<flux:select wire:model="selectedPackageCode" label="Select Package">
<flux:select.option value="">Choose a package...</flux:select.option>
@foreach($this->availablePackages as $package)
<flux:select.option value="{{ $package->code }}">
{{ $package->name }}
@if($package->is_base_package) (Base) @endif
@if(!$package->is_public) (Internal) @endif
</flux:select.option>
@endforeach
</flux:select>
<div class="rounded-lg bg-amber-50 dark:bg-amber-900/20 p-3">
<p class="text-sm text-amber-800 dark:text-amber-200">
The package will be assigned immediately with no expiry date. You can modify or remove it later.
</p>
</div>
<div class="flex justify-end gap-3">
<flux:button wire:click="closePackageModal" variant="ghost">Cancel</flux:button>
<flux:button wire:click="provisionPackage" variant="primary" :disabled="!$selectedPackageCode">
Provision Package
</flux:button>
</div>
</div>
</flux:modal>
{{-- Entitlement provisioning modal --}}
<flux:modal wire:model="showEntitlementModal" class="max-w-lg">
<div class="space-y-6">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-purple-500/20 flex items-center justify-center">
<core:icon name="bolt" class="text-purple-600 dark:text-purple-400" />
</div>
<flux:heading size="lg">Add Entitlement</flux:heading>
</div>
@if($entitlementWorkspaceId)
@php
$entitlementWorkspace = $this->workspaces->firstWhere('id', $entitlementWorkspaceId);
@endphp
<div class="p-3 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<div class="text-xs text-gray-500 dark:text-gray-400">Workspace</div>
<div class="font-medium text-gray-800 dark:text-gray-100">{{ $entitlementWorkspace?->name ?? 'Unknown' }}</div>
</div>
@endif
<flux:select wire:model="entitlementFeatureCode" variant="listbox" searchable label="Feature" placeholder="Search features...">
@foreach($this->allFeatures->groupBy('category') as $category => $features)
<flux:select.option disabled>── {{ ucfirst($category ?: 'General') }} ──</flux:select.option>
@foreach($features as $feature)
<flux:select.option value="{{ $feature->code }}">
{{ $feature->name }} ({{ $feature->code }})
</flux:select.option>
@endforeach
@endforeach
</flux:select>
<flux:select wire:model.live="entitlementType" label="Type">
<flux:select.option value="enable">Enable (Toggle on)</flux:select.option>
<flux:select.option value="add_limit">Add Limit (Extra quota)</flux:select.option>
<flux:select.option value="unlimited">Unlimited</flux:select.option>
</flux:select>
@if($entitlementType === 'add_limit')
<flux:input wire:model="entitlementLimit" type="number" label="Limit Value" placeholder="e.g. 100" min="1" />
@endif
<flux:select wire:model.live="entitlementDuration" label="Duration">
<flux:select.option value="permanent">Permanent</flux:select.option>
<flux:select.option value="duration">Expires on date</flux:select.option>
</flux:select>
@if($entitlementDuration === 'duration')
<flux:input wire:model="entitlementExpiresAt" type="date" label="Expires At" />
@endif
<div class="rounded-lg bg-purple-50 dark:bg-purple-900/20 p-3">
<p class="text-sm text-purple-800 dark:text-purple-200">
This will create a boost that grants the selected feature directly to this workspace, independent of packages.
</p>
</div>
<div class="flex justify-end gap-3">
<flux:button wire:click="closeEntitlementModal" variant="ghost">Cancel</flux:button>
<flux:button wire:click="provisionEntitlement" variant="primary" :disabled="!$entitlementFeatureCode">
Add Entitlement
</flux:button>
</div>
</div>
</flux:modal>
</div>

View file

@ -0,0 +1,278 @@
<div>
<!-- Page header -->
<div class="sm:flex sm:justify-between sm:items-center mb-8">
<div class="mb-4 sm:mb-0">
<h1 class="text-2xl md:text-3xl text-gray-800 dark:text-gray-100 font-bold">Platform Admin</h1>
<p class="text-gray-500 dark:text-gray-400">Manage users, tiers, and platform operations</p>
</div>
<div class="flex items-center gap-2">
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-violet-500/20 text-violet-600 dark:text-violet-400">
<core:icon name="crown" class="mr-1.5" />
Hades Only
</span>
</div>
</div>
<!-- Action message -->
@if($actionMessage)
<div class="mb-6 p-4 rounded-lg {{ $actionType === 'success' ? 'bg-green-500/20 text-green-700 dark:text-green-400' : ($actionType === 'warning' ? 'bg-amber-500/20 text-amber-700 dark:text-amber-400' : 'bg-red-500/20 text-red-700 dark:text-red-400') }}">
<div class="flex items-center">
<core:icon name="{{ $actionType === 'success' ? 'check-circle' : ($actionType === 'warning' ? 'triangle-exclamation' : 'circle-xmark') }}" class="mr-2" />
{{ $actionMessage }}
</div>
</div>
@endif
<!-- Stats Grid -->
<div class="grid grid-cols-2 md:grid-cols-4 xl:grid-cols-7 gap-4 mb-8">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs p-4">
<div class="text-2xl font-bold text-gray-800 dark:text-gray-100">{{ number_format($stats['total_users']) }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Total Users</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs p-4">
<div class="text-2xl font-bold text-green-600 dark:text-green-400">{{ number_format($stats['verified_users']) }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Verified</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs p-4">
<div class="text-2xl font-bold text-violet-600 dark:text-violet-400">{{ number_format($stats['hades_users']) }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Hades</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs p-4">
<div class="text-2xl font-bold text-blue-600 dark:text-blue-400">{{ number_format($stats['apollo_users']) }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Apollo</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs p-4">
<div class="text-2xl font-bold text-gray-600 dark:text-gray-400">{{ number_format($stats['free_users']) }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Free</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs p-4">
<div class="text-2xl font-bold text-amber-600 dark:text-amber-400">{{ number_format($stats['users_today']) }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Today</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs p-4">
<div class="text-2xl font-bold text-cyan-600 dark:text-cyan-400">{{ number_format($stats['users_this_week']) }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">This Week</div>
</div>
</div>
<div class="grid grid-cols-12 gap-6">
<!-- User Management -->
<div class="col-span-full xl:col-span-8">
<div class="bg-white dark:bg-gray-800 shadow-xs rounded-xl">
<header class="px-5 py-4 border-b border-gray-100 dark:border-gray-700/60">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h2 class="font-semibold text-gray-800 dark:text-gray-100">User Management</h2>
<div class="flex flex-wrap items-center gap-2">
<!-- Search -->
<core:input
wire:model.live.debounce.300ms="search"
placeholder="Search users..."
icon="magnifying-glass"
size="sm"
class="w-48"
/>
<!-- Tier filter -->
<core:select wire:model.live="tierFilter" size="sm">
<core:select.option value="">All Tiers</core:select.option>
@foreach($tiers as $tier)
<core:select.option value="{{ $tier->value }}">{{ ucfirst($tier->value) }}</core:select.option>
@endforeach
</core:select>
<!-- Verified filter -->
<core:select wire:model.live="verifiedFilter" size="sm">
<core:select.option value="">All Status</core:select.option>
<core:select.option value="1">Verified</core:select.option>
<core:select.option value="0">Unverified</core:select.option>
</core:select>
</div>
</div>
</header>
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="text-xs uppercase text-gray-400 dark:text-gray-500 bg-gray-50 dark:bg-gray-700/20">
<th class="px-4 py-3 text-left font-medium cursor-pointer hover:text-gray-600 dark:hover:text-gray-300" wire:click="sortBy('name')">
<div class="flex items-center gap-1">
Name
@if($sortField === 'name')
<core:icon name="{{ $sortDirection === 'asc' ? 'arrow-up' : 'arrow-down' }}" class="text-xs" />
@endif
</div>
</th>
<th class="px-4 py-3 text-left font-medium cursor-pointer hover:text-gray-600 dark:hover:text-gray-300" wire:click="sortBy('email')">
<div class="flex items-center gap-1">
Email
@if($sortField === 'email')
<core:icon name="{{ $sortDirection === 'asc' ? 'arrow-up' : 'arrow-down' }}" class="text-xs" />
@endif
</div>
</th>
<th class="px-4 py-3 text-left font-medium">Tier</th>
<th class="px-4 py-3 text-left font-medium">Verified</th>
<th class="px-4 py-3 text-left font-medium cursor-pointer hover:text-gray-600 dark:hover:text-gray-300" wire:click="sortBy('created_at')">
<div class="flex items-center gap-1">
Joined
@if($sortField === 'created_at')
<core:icon name="{{ $sortDirection === 'asc' ? 'arrow-up' : 'arrow-down' }}" class="text-xs" />
@endif
</div>
</th>
<th class="px-4 py-3 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700/60">
@forelse($users as $user)
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/20">
<td class="px-4 py-3">
<div class="flex items-center">
<div class="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center mr-3">
<span class="text-xs font-medium text-gray-600 dark:text-gray-300">{{ substr($user->name, 0, 2) }}</span>
</div>
<span class="text-sm font-medium text-gray-800 dark:text-gray-100">{{ $user->name }}</span>
</div>
</td>
<td class="px-4 py-3">
<span class="text-sm text-gray-600 dark:text-gray-400">{{ $user->email }}</span>
</td>
<td class="px-4 py-3">
@php
$tierColor = match($user->tier?->value ?? 'free') {
'hades' => 'violet',
'apollo' => 'blue',
default => 'gray',
};
@endphp
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-{{ $tierColor }}-500/20 text-{{ $tierColor }}-600 dark:text-{{ $tierColor }}-400">
{{ ucfirst($user->tier?->value ?? 'free') }}
</span>
</td>
<td class="px-4 py-3">
@if($user->email_verified_at)
<span class="inline-flex items-center text-green-600 dark:text-green-400">
<core:icon name="check-circle" class="mr-1" />
<span class="text-xs">Verified</span>
</span>
@else
<span class="inline-flex items-center text-amber-600 dark:text-amber-400">
<core:icon name="clock" class="mr-1" />
<span class="text-xs">Pending</span>
</span>
@endif
</td>
<td class="px-4 py-3">
<span class="text-sm text-gray-600 dark:text-gray-400">{{ $user->created_at->format('d M Y') }}</span>
</td>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-2">
@if(!$user->email_verified_at)
<button wire:click="verifyEmail({{ $user->id }})" class="p-1.5 text-green-600 hover:bg-green-500/20 rounded-lg transition" title="Verify email">
<core:icon name="check" />
</button>
@endif
<a href="{{ route('hub.platform.user', $user->id) }}" wire:navigate class="p-1.5 text-violet-600 hover:bg-violet-500/20 rounded-lg transition" title="View user details">
<core:icon name="arrow-right" />
</a>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
No users found matching your criteria.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if($users->hasPages())
<div class="px-5 py-4 border-t border-gray-100 dark:border-gray-700/60">
{{ $users->links() }}
</div>
@endif
</div>
</div>
<!-- System Info & DevOps -->
<div class="col-span-full xl:col-span-4 space-y-6">
<!-- System Info -->
<div class="bg-white dark:bg-gray-800 shadow-xs rounded-xl">
<header class="px-5 py-4 border-b border-gray-100 dark:border-gray-700/60">
<h2 class="font-semibold text-gray-800 dark:text-gray-100">System Info</h2>
</header>
<div class="p-5 space-y-3">
@foreach($systemInfo as $label => $value)
<div class="flex items-center justify-between">
<span class="text-sm text-gray-500 dark:text-gray-400">{{ str_replace('_', ' ', ucwords($label, '_')) }}</span>
<span class="text-sm font-medium text-gray-800 dark:text-gray-100">{{ $value }}</span>
</div>
@endforeach
</div>
</div>
<!-- DevOps Tools -->
<div class="bg-white dark:bg-gray-800 shadow-xs rounded-xl">
<header class="px-5 py-4 border-b border-gray-100 dark:border-gray-700/60">
<h2 class="font-semibold text-gray-800 dark:text-gray-100">DevOps Tools</h2>
</header>
<div class="p-5 space-y-3">
<button wire:click="clearCache" wire:loading.attr="disabled" class="w-full flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/30 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700/50 transition">
<div class="flex items-center">
<core:icon name="broom" class="mr-3 text-amber-500" />
<span class="text-sm font-medium text-gray-800 dark:text-gray-100">Clear Cache</span>
</div>
<core:icon name="chevron-right" class="text-gray-400" wire:loading.remove wire:target="clearCache" />
<core:icon name="spinner" class="text-gray-400 animate-spin" wire:loading wire:target="clearCache" />
</button>
<button wire:click="clearOpcache" wire:loading.attr="disabled" class="w-full flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/30 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700/50 transition">
<div class="flex items-center">
<core:icon name="microchip" class="mr-3 text-blue-500" />
<span class="text-sm font-medium text-gray-800 dark:text-gray-100">Clear OPcache</span>
</div>
<core:icon name="chevron-right" class="text-gray-400" wire:loading.remove wire:target="clearOpcache" />
<core:icon name="spinner" class="text-gray-400 animate-spin" wire:loading wire:target="clearOpcache" />
</button>
<button wire:click="restartQueue" wire:loading.attr="disabled" class="w-full flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/30 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700/50 transition">
<div class="flex items-center">
<core:icon name="rotate" class="mr-3 text-green-500" />
<span class="text-sm font-medium text-gray-800 dark:text-gray-100">Restart Queue</span>
</div>
<core:icon name="chevron-right" class="text-gray-400" wire:loading.remove wire:target="restartQueue" />
<core:icon name="spinner" class="text-gray-400 animate-spin" wire:loading wire:target="restartQueue" />
</button>
</div>
</div>
<!-- Quick Links -->
<div class="bg-white dark:bg-gray-800 shadow-xs rounded-xl">
<header class="px-5 py-4 border-b border-gray-100 dark:border-gray-700/60">
<h2 class="font-semibold text-gray-800 dark:text-gray-100">Quick Links</h2>
</header>
<div class="p-5 space-y-2">
<a href="/horizon" target="_blank" class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/30 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700/50 transition">
<div class="flex items-center">
<core:icon name="layer-group" class="mr-3 text-violet-500" />
<span class="text-sm font-medium text-gray-800 dark:text-gray-100">Horizon</span>
</div>
<core:icon name="arrow-up-right-from-square" class="text-gray-400" />
</a>
<a href="/telescope" target="_blank" class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/30 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700/50 transition">
<div class="flex items-center">
<core:icon name="satellite-dish" class="mr-3 text-blue-500" />
<span class="text-sm font-medium text-gray-800 dark:text-gray-100">Telescope</span>
</div>
<core:icon name="arrow-up-right-from-square" class="text-gray-400" />
</a>
<a href="/pulse" target="_blank" class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/30 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700/50 transition">
<div class="flex items-center">
<core:icon name="heart-pulse" class="mr-3 text-red-500" />
<span class="text-sm font-medium text-gray-800 dark:text-gray-100">Pulse</span>
</div>
<core:icon name="arrow-up-right-from-square" class="text-gray-400" />
</a>
</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,175 @@
<div>
<!-- Profile Header -->
<div class="bg-white dark:bg-gray-800 shadow-xs rounded-xl mb-6 overflow-hidden">
<!-- Gradient banner -->
<div class="h-32 bg-gradient-to-r {{ $tierColor }}"></div>
<div class="px-6 pb-6">
<!-- Avatar and basic info -->
<div class="flex flex-col sm:flex-row sm:items-end gap-4 -mt-12">
<div class="w-24 h-24 rounded-full bg-gradient-to-br {{ $tierColor }} flex items-center justify-center text-white text-3xl font-bold ring-4 ring-white dark:ring-gray-800 shadow-lg">
{{ $userInitials }}
</div>
<div class="flex-1 sm:pb-2">
<div class="flex flex-col sm:flex-row sm:items-center gap-2">
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-100">{{ $userName }}</h1>
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-gradient-to-r {{ $tierColor }} text-white w-fit">
{{ $userTier }}
</span>
</div>
<p class="text-gray-500 dark:text-gray-400 mt-1">{{ $userEmail }}</p>
@if($memberSince)
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">{{ __('hub::hub.profile.member_since', ['date' => $memberSince]) }}</p>
@endif
</div>
<div class="flex gap-2">
<a href="{{ route('hub.account.settings') }}" class="inline-flex items-center px-4 py-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 rounded-lg transition-colors text-sm font-medium">
<core:icon name="gear" class="mr-2" /> {{ __('hub::hub.profile.actions.settings') }}
</a>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Left column: Quotas -->
<div class="lg:col-span-2 space-y-6">
<!-- Usage Quotas -->
<div class="bg-white dark:bg-gray-800 shadow-xs rounded-xl">
<header class="px-5 py-4 border-b border-gray-100 dark:border-gray-700/60">
<h2 class="font-semibold text-gray-800 dark:text-gray-100">
<core:icon name="gauge-high" class="text-violet-500 mr-2" />{{ __('hub::hub.profile.sections.quotas') }}
</h2>
</header>
<div class="p-5">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
@foreach($quotas as $key => $quota)
<div class="bg-gray-50 dark:bg-gray-700/30 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-600 dark:text-gray-300">{{ $quota['label'] }}</span>
<span class="text-sm text-gray-500 dark:text-gray-400">
@if($quota['limit'])
{{ $quota['used'] }} / {{ $quota['limit'] }}
@else
{{ $quota['used'] }} <span class="text-xs text-violet-500">({{ __('hub::hub.profile.quotas.unlimited') }})</span>
@endif
</span>
</div>
@if($quota['limit'])
@php
$percentage = min(100, ($quota['used'] / $quota['limit']) * 100);
$barColor = $percentage > 90 ? 'bg-red-500' : ($percentage > 70 ? 'bg-amber-500' : 'bg-violet-500');
@endphp
<div class="w-full h-2 bg-gray-200 dark:bg-gray-600 rounded-full overflow-hidden">
<div class="{{ $barColor }} h-full rounded-full transition-all duration-300" style="width: {{ $percentage }}%"></div>
</div>
@else
<div class="w-full h-2 bg-gradient-to-r from-violet-500 to-purple-500 rounded-full"></div>
@endif
</div>
@endforeach
</div>
@if($userTier !== 'Hades')
<div class="mt-4 p-4 bg-gradient-to-r from-violet-500/10 to-purple-500/10 border border-violet-500/20 rounded-lg">
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-gray-800 dark:text-gray-100">{{ __('hub::hub.profile.quotas.need_more') }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ __('hub::hub.profile.quotas.need_more_description') }}</p>
</div>
<a href="{{ route('pricing') }}" class="inline-flex items-center px-4 py-2 bg-violet-600 hover:bg-violet-700 text-white rounded-lg transition-colors text-sm font-medium">
{{ __('hub::hub.profile.actions.upgrade') }}
</a>
</div>
</div>
@endif
</div>
</div>
<!-- Service Status -->
<div class="bg-white dark:bg-gray-800 shadow-xs rounded-xl">
<header class="px-5 py-4 border-b border-gray-100 dark:border-gray-700/60">
<h2 class="font-semibold text-gray-800 dark:text-gray-100">
<core:icon name="cubes" class="text-violet-500 mr-2" />{{ __('hub::hub.profile.sections.services') }}
</h2>
</header>
<div class="p-5">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
@foreach($serviceStats as $service)
<div class="flex items-center gap-4 p-4 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<div class="w-12 h-12 {{ $service['color'] }} rounded-lg flex items-center justify-center text-white">
<core:icon :name="ltrim($service['icon'], 'fa-')" class="text-xl" />
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-800 dark:text-gray-100">{{ $service['name'] }}</span>
@if($service['status'] === 'active')
<span class="w-2 h-2 bg-green-500 rounded-full"></span>
@else
<span class="w-2 h-2 bg-gray-400 rounded-full"></span>
@endif
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 truncate">{{ $service['stat'] }}</p>
</div>
</div>
@endforeach
</div>
</div>
</div>
</div>
<!-- Right column: Activity -->
<div class="space-y-6">
<!-- Recent Activity -->
<div class="bg-white dark:bg-gray-800 shadow-xs rounded-xl">
<header class="px-5 py-4 border-b border-gray-100 dark:border-gray-700/60">
<h2 class="font-semibold text-gray-800 dark:text-gray-100">
<core:icon name="clock-rotate-left" class="text-violet-500 mr-2" />{{ __('hub::hub.profile.sections.activity') }}
</h2>
</header>
<div class="p-5">
@if(count($recentActivity) > 0)
<div class="space-y-4">
@foreach($recentActivity as $activity)
<div class="flex gap-3">
<div class="w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center flex-shrink-0">
<core:icon :name="ltrim($activity['icon'], 'fa-')" class="{{ $activity['color'] }} text-sm" />
</div>
<div class="flex-1 min-w-0">
<p class="text-sm text-gray-700 dark:text-gray-300">{{ $activity['message'] }}</p>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{{ $activity['time'] }}</p>
</div>
</div>
@endforeach
</div>
@else
<p class="text-sm text-gray-500 dark:text-gray-400 text-center py-4">{{ __('hub::hub.profile.activity.no_activity') }}</p>
@endif
</div>
</div>
<!-- Quick Actions -->
<div class="bg-white dark:bg-gray-800 shadow-xs rounded-xl">
<header class="px-5 py-4 border-b border-gray-100 dark:border-gray-700/60">
<h2 class="font-semibold text-gray-800 dark:text-gray-100">
<core:icon name="bolt" class="text-violet-500 mr-2" />{{ __('hub::hub.profile.sections.quick_actions') }}
</h2>
</header>
<div class="p-5 space-y-2">
<a href="{{ route('hub.account.settings') }}" class="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors group">
<core:icon name="user-pen" class="text-gray-400 group-hover:text-violet-500 transition-colors" />
<span class="text-sm text-gray-600 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">{{ __('hub::hub.profile.actions.edit_profile') }}</span>
</a>
<a href="{{ route('hub.account.settings') }}#password" class="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors group">
<core:icon name="key" class="text-gray-400 group-hover:text-violet-500 transition-colors" />
<span class="text-sm text-gray-600 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">{{ __('hub::hub.profile.actions.change_password') }}</span>
</a>
<a href="{{ route('hub.account.settings') }}#delete-account" class="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors group">
<core:icon name="file-export" class="text-gray-400 group-hover:text-violet-500 transition-colors" />
<span class="text-sm text-gray-600 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">{{ __('hub::hub.profile.actions.export_data') }}</span>
</a>
</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,242 @@
<admin:module :title="__('hub::hub.prompts.title')" :subtitle="__('hub::hub.prompts.subtitle')">
<x-slot:actions>
<core:button wire:click="create" icon="plus">{{ __('hub::hub.prompts.labels.new_prompt') }}</core:button>
</x-slot:actions>
<admin:filter-bar cols="4">
<admin:search model="search" :placeholder="__('hub::hub.prompts.labels.search_prompts')" />
<admin:filter model="category" :options="$this->categoryOptions" :placeholder="__('hub::hub.prompts.labels.all_categories')" />
<admin:filter model="model" :options="$this->modelOptions" :placeholder="__('hub::hub.prompts.labels.all_models')" />
<admin:clear-filters :fields="['search', 'category', 'model']" />
</admin:filter-bar>
<admin:manager-table
:columns="$this->tableColumns"
:rows="$this->tableRows"
:pagination="$this->prompts"
:empty="__('hub::hub.prompts.labels.empty')"
emptyIcon="document-text"
/>
{{-- Editor Modal --}}
<core:modal name="prompt-editor" :show="$showEditor" class="max-w-6xl" @close="closeEditor">
<div class="space-y-6">
<core:heading size="lg">
{{ $editingPromptId ? __('hub::hub.prompts.editor.edit_title') : __('hub::hub.prompts.editor.new_title') }}
</core:heading>
<form wire:submit="save" class="space-y-6">
{{-- Basic Info --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<core:input
wire:model="name"
:label="__('hub::hub.prompts.editor.name')"
:placeholder="__('hub::hub.prompts.editor.name_placeholder')"
required
/>
<core:select wire:model="promptCategory" :label="__('hub::hub.prompts.editor.category')">
<core:select.option value="content">{{ __('hub::hub.prompts.categories.content') }}</core:select.option>
<core:select.option value="seo">{{ __('hub::hub.prompts.categories.seo') }}</core:select.option>
<core:select.option value="refinement">{{ __('hub::hub.prompts.categories.refinement') }}</core:select.option>
<core:select.option value="translation">{{ __('hub::hub.prompts.categories.translation') }}</core:select.option>
<core:select.option value="analysis">{{ __('hub::hub.prompts.categories.analysis') }}</core:select.option>
</core:select>
</div>
<core:textarea
wire:model="description"
:label="__('hub::hub.prompts.editor.description')"
:placeholder="__('hub::hub.prompts.editor.description_placeholder')"
rows="2"
/>
{{-- Model Settings --}}
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<core:select wire:model="promptModel" :label="__('hub::hub.prompts.editor.model')">
<core:select.option value="claude">{{ __('hub::hub.prompts.models.claude') }}</core:select.option>
<core:select.option value="gemini">{{ __('hub::hub.prompts.models.gemini') }}</core:select.option>
</core:select>
<core:input
type="number"
wire:model="modelConfig.temperature"
:label="__('hub::hub.prompts.editor.temperature')"
min="0"
max="2"
step="0.1"
/>
<core:input
type="number"
wire:model="modelConfig.max_tokens"
:label="__('hub::hub.prompts.editor.max_tokens')"
min="100"
max="200000"
step="100"
/>
</div>
{{-- System Prompt with Monaco --}}
<div>
<core:label>{{ __('hub::hub.prompts.editor.system_prompt') }}</core:label>
<div wire:ignore class="mt-1">
<div
id="system-prompt-editor"
class="h-64 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden"
x-data="{
editor: null,
init() {
require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs' } });
require(['vs/editor/editor.main'], () => {
this.editor = monaco.editor.create(this.$el, {
value: @js($systemPrompt),
language: 'markdown',
theme: document.documentElement.classList.contains('dark') ? 'vs-dark' : 'vs',
minimap: { enabled: false },
wordWrap: 'on',
lineNumbers: 'off',
fontSize: 14,
padding: { top: 12, bottom: 12 }
});
this.editor.onDidChangeModelContent(() => {
$wire.set('systemPrompt', this.editor.getValue());
});
});
}
}"
></div>
</div>
</div>
{{-- User Template with Monaco --}}
<div>
<core:label>{{ __('hub::hub.prompts.editor.user_template') }}</core:label>
<core:text size="sm" class="text-zinc-500 mb-1">{{ __('hub::hub.prompts.editor.user_template_hint') }}</core:text>
<div wire:ignore class="mt-1">
<div
id="user-template-editor"
class="h-48 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden"
x-data="{
editor: null,
init() {
require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs' } });
require(['vs/editor/editor.main'], () => {
this.editor = monaco.editor.create(this.$el, {
value: @js($userTemplate),
language: 'markdown',
theme: document.documentElement.classList.contains('dark') ? 'vs-dark' : 'vs',
minimap: { enabled: false },
wordWrap: 'on',
lineNumbers: 'off',
fontSize: 14,
padding: { top: 12, bottom: 12 }
});
this.editor.onDidChangeModelContent(() => {
$wire.set('userTemplate', this.editor.getValue());
});
});
}
}"
></div>
</div>
</div>
{{-- Variables --}}
<div>
<div class="flex justify-between items-center mb-2">
<core:label>{{ __('hub::hub.prompts.editor.template_variables') }}</core:label>
<core:button type="button" wire:click="addVariable" size="xs" variant="ghost" icon="plus">
{{ __('hub::hub.prompts.editor.add_variable') }}
</core:button>
</div>
@if(count($variables) > 0)
<div class="space-y-2">
@foreach($variables as $index => $var)
<div class="flex gap-2 items-start p-3 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg">
<core:input
wire:model="variables.{{ $index }}.name"
:placeholder="__('hub::hub.prompts.editor.variable_name')"
size="sm"
class="flex-1"
/>
<core:input
wire:model="variables.{{ $index }}.description"
:placeholder="__('hub::hub.prompts.editor.variable_description')"
size="sm"
class="flex-1"
/>
<core:input
wire:model="variables.{{ $index }}.default"
:placeholder="__('hub::hub.prompts.editor.variable_default')"
size="sm"
class="flex-1"
/>
<core:button type="button" wire:click="removeVariable({{ $index }})" size="sm" variant="ghost" icon="x-mark" />
</div>
@endforeach
</div>
@else
<core:text size="sm" class="text-zinc-500 italic">{{ __('hub::hub.prompts.editor.no_variables') }}</core:text>
@endif
</div>
{{-- Active Toggle --}}
<core:switch wire:model="isActive" :label="__('hub::hub.prompts.editor.active')" :description="__('hub::hub.prompts.editor.active_description')" />
{{-- Actions --}}
<div class="flex justify-between pt-4 border-t border-zinc-200 dark:border-zinc-700">
@if($editingPromptId)
<core:button type="button" wire:click="$set('showVersions', true)" variant="ghost" icon="clock">
{{ __('hub::hub.prompts.editor.version_history') }}
</core:button>
@else
<div></div>
@endif
<div class="flex gap-3">
<core:button type="button" wire:click="closeEditor" variant="ghost">
{{ __('hub::hub.prompts.editor.cancel') }}
</core:button>
<core:button type="submit" variant="primary">
{{ $editingPromptId ? __('hub::hub.prompts.editor.update_prompt') : __('hub::hub.prompts.editor.create_prompt') }}
</core:button>
</div>
</div>
</form>
</div>
</core:modal>
{{-- Version History Modal --}}
<core:modal name="version-history" :show="$showVersions" @close="$set('showVersions', false)">
<core:heading size="lg" class="mb-4">{{ __('hub::hub.prompts.versions.title') }}</core:heading>
@if($this->promptVersions->isNotEmpty())
<div class="space-y-2 max-h-96 overflow-y-auto">
@foreach($this->promptVersions as $version)
<div class="flex justify-between items-center p-3 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg">
<div>
<core:text class="font-medium">{{ __('hub::hub.prompts.versions.version', ['number' => $version->version]) }}</core:text>
<core:text size="sm" class="text-zinc-500">
{{ $version->created_at->format('M j, Y H:i') }}
@if($version->creator)
{{ __('hub::hub.prompts.versions.by', ['name' => $version->creator->name]) }}
@endif
</core:text>
</div>
<core:button wire:click="restoreVersion({{ $version->id }})" size="sm" variant="ghost" icon="arrow-uturn-left">
{{ __('hub::hub.prompts.versions.restore') }}
</core:button>
</div>
@endforeach
</div>
@else
<core:text class="text-zinc-500 italic">{{ __('hub::hub.prompts.versions.no_history') }}</core:text>
@endif
</core:modal>
</admin:module>
@push('scripts')
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js"></script>
@endpush

View file

@ -0,0 +1,79 @@
<admin:module title="Services" subtitle="Manage platform services and their configuration">
<x-slot:actions>
<core:button wire:click="syncFromModules" icon="rotate" variant="ghost">
Sync from Modules
</core:button>
</x-slot:actions>
<admin:flash />
<admin:manager-table
:columns="$this->tableColumns"
:rows="$this->tableRows"
empty="No services found. Run the sync to import services from modules."
emptyIcon="cube"
/>
{{-- Edit Service Modal --}}
<core:modal wire:model="showModal" class="max-w-2xl">
<core:heading size="lg">Edit Service</core:heading>
<form wire:submit="save" class="mt-4 space-y-4">
{{-- Read-only section --}}
<div class="rounded-lg bg-zinc-50 dark:bg-zinc-800/50 p-4 space-y-3">
<div class="text-xs font-medium text-zinc-500 uppercase tracking-wider">Module Information (read-only)</div>
<div class="grid grid-cols-3 gap-4">
<div>
<div class="text-xs text-zinc-500">Code</div>
<code class="text-sm font-mono">{{ $code }}</code>
</div>
<div>
<div class="text-xs text-zinc-500">Module</div>
<code class="text-sm font-mono">{{ $module }}</code>
</div>
<div>
<div class="text-xs text-zinc-500">Entitlement</div>
<code class="text-sm font-mono">{{ $entitlement_code ?: '-' }}</code>
</div>
</div>
</div>
{{-- Editable fields --}}
<div class="grid grid-cols-2 gap-4">
<core:input wire:model="name" label="Display Name" placeholder="Bio" required />
<core:input wire:model="tagline" label="Tagline" placeholder="Link-in-bio pages" />
</div>
<core:textarea wire:model="description" label="Description" rows="3" placeholder="Marketing description for the service catalogue..." />
<div class="grid grid-cols-3 gap-4">
<core:input wire:model="icon" label="Icon" placeholder="link" description="Font Awesome icon name" />
<core:input wire:model="color" label="Colour" placeholder="pink" description="Tailwind colour name" />
<core:input wire:model="sort_order" label="Sort Order" type="number" />
</div>
<div class="border-t border-zinc-200 dark:border-zinc-700 pt-4">
<div class="text-xs font-medium text-zinc-500 uppercase tracking-wider mb-3">Marketing Configuration</div>
<div class="grid grid-cols-2 gap-4">
<core:input wire:model="marketing_domain" label="Marketing Domain" placeholder="lthn.test" description="Domain for marketing site" />
<core:input wire:model="docs_url" label="Documentation URL" placeholder="https://docs.host.uk.com/bio" />
</div>
<core:input wire:model="marketing_url" label="Marketing URL Override" placeholder="https://lthn.test" description="Overrides auto-generated URL from domain" class="mt-4" />
</div>
<div class="border-t border-zinc-200 dark:border-zinc-700 pt-4">
<div class="text-xs font-medium text-zinc-500 uppercase tracking-wider mb-3">Visibility</div>
<div class="grid grid-cols-3 gap-4">
<core:checkbox wire:model="is_enabled" label="Enabled" description="Service is active" />
<core:checkbox wire:model="is_public" label="Public" description="Show in catalogue" />
<core:checkbox wire:model="is_featured" label="Featured" description="Highlight in marketing" />
</div>
</div>
<div class="flex justify-end gap-2 pt-4">
<core:button variant="ghost" wire:click="closeModal">Cancel</core:button>
<core:button type="submit" variant="primary">Update Service</core:button>
</div>
</form>
</core:modal>
</admin:module>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,390 @@
<div>
<admin:page-header :title="__('hub::hub.settings.title')" :description="__('hub::hub.settings.subtitle')" />
{{-- Settings card with sidebar --}}
<div class="bg-white dark:bg-gray-800 shadow-xs rounded-xl">
<div class="flex flex-col md:flex-row md:-mr-px">
{{-- Sidebar navigation --}}
<div class="flex flex-nowrap overflow-x-scroll no-scrollbar md:block md:overflow-auto px-3 py-6 border-b md:border-b-0 md:border-r border-gray-200 dark:border-gray-700/60 min-w-60 md:space-y-3">
{{-- Account settings group --}}
<div>
<div class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase mb-3 hidden md:block">{{ __('hub::hub.settings.sections.profile.title') }}</div>
<ul class="flex flex-nowrap md:block mr-3 md:mr-0">
<li class="mr-0.5 md:mr-0 md:mb-0.5">
<button
wire:click="$set('activeSection', 'profile')"
@class([
'flex items-center px-2.5 py-2 rounded-lg whitespace-nowrap w-full text-left',
'bg-gradient-to-r from-violet-500/[0.12] dark:from-violet-500/[0.24] to-violet-500/[0.04]' => $activeSection === 'profile',
])
>
<svg class="shrink-0 fill-current mr-2 {{ $activeSection === 'profile' ? 'text-violet-400' : 'text-gray-400 dark:text-gray-500' }}" width="16" height="16" viewBox="0 0 16 16">
<path d="M8 9a4 4 0 1 1 0-8 4 4 0 0 1 0 8Zm0-2a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm-5.143 7.91a1 1 0 1 1-1.714-1.033A7.996 7.996 0 0 1 8 10a7.996 7.996 0 0 1 6.857 3.877 1 1 0 1 1-1.714 1.032A5.996 5.996 0 0 0 8 12a5.996 5.996 0 0 0-5.143 2.91Z" />
</svg>
<span class="text-sm font-medium {{ $activeSection === 'profile' ? 'text-violet-500 dark:text-violet-400' : 'text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200' }}">{{ __('hub::hub.settings.nav.profile') }}</span>
</button>
</li>
<li class="mr-0.5 md:mr-0 md:mb-0.5">
<button
wire:click="$set('activeSection', 'preferences')"
@class([
'flex items-center px-2.5 py-2 rounded-lg whitespace-nowrap w-full text-left',
'bg-gradient-to-r from-violet-500/[0.12] dark:from-violet-500/[0.24] to-violet-500/[0.04]' => $activeSection === 'preferences',
])
>
<svg class="shrink-0 fill-current mr-2 {{ $activeSection === 'preferences' ? 'text-violet-400' : 'text-gray-400 dark:text-gray-500' }}" width="16" height="16" viewBox="0 0 16 16">
<path d="M10.5 1a.5.5 0 0 1 .5.5v1.567a6.5 6.5 0 1 1-7.77 7.77H1.5a.5.5 0 0 1 0-1h1.77a6.5 6.5 0 0 1 6.24-6.24V1.5a.5.5 0 0 1 .5-.5Zm-.5 3.073V5.5a.5.5 0 0 0 1 0V4.51a5.5 5.5 0 1 1-5.49 5.49H5.5a.5.5 0 0 0 0-1H4.073A5.5 5.5 0 0 1 10 4.073ZM8 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" />
</svg>
<span class="text-sm font-medium {{ $activeSection === 'preferences' ? 'text-violet-500 dark:text-violet-400' : 'text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200' }}">{{ __('hub::hub.settings.nav.preferences') }}</span>
</button>
</li>
</ul>
</div>
{{-- Security settings group --}}
<div class="md:mt-6">
<div class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase mb-3 hidden md:block">{{ __('hub::hub.settings.nav.security') }}</div>
<ul class="flex flex-nowrap md:block mr-3 md:mr-0">
@if($isTwoFactorEnabled)
<li class="mr-0.5 md:mr-0 md:mb-0.5">
<button
wire:click="$set('activeSection', 'two_factor')"
@class([
'flex items-center px-2.5 py-2 rounded-lg whitespace-nowrap w-full text-left',
'bg-gradient-to-r from-violet-500/[0.12] dark:from-violet-500/[0.24] to-violet-500/[0.04]' => $activeSection === 'two_factor',
])
>
<svg class="shrink-0 fill-current mr-2 {{ $activeSection === 'two_factor' ? 'text-violet-400' : 'text-gray-400 dark:text-gray-500' }}" width="16" height="16" viewBox="0 0 16 16">
<path d="M8 0a1 1 0 0 1 1 1v.07A7.002 7.002 0 0 1 15 8a7 7 0 0 1-14 0 7.002 7.002 0 0 1 6-6.93V1a1 1 0 0 1 1-1Zm0 4a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm0 2a2 2 0 1 1 0 4 2 2 0 0 1 0-4Z" />
</svg>
<span class="text-sm font-medium {{ $activeSection === 'two_factor' ? 'text-violet-500 dark:text-violet-400' : 'text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200' }}">2FA</span>
</button>
</li>
@endif
<li class="mr-0.5 md:mr-0 md:mb-0.5">
<button
wire:click="$set('activeSection', 'password')"
@class([
'flex items-center px-2.5 py-2 rounded-lg whitespace-nowrap w-full text-left',
'bg-gradient-to-r from-violet-500/[0.12] dark:from-violet-500/[0.24] to-violet-500/[0.04]' => $activeSection === 'password',
])
>
<svg class="shrink-0 fill-current mr-2 {{ $activeSection === 'password' ? 'text-violet-400' : 'text-gray-400 dark:text-gray-500' }}" width="16" height="16" viewBox="0 0 16 16">
<path d="M11.5 0A2.5 2.5 0 0 0 9 2.5V4H2a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-1V2.5A2.5 2.5 0 0 0 10.5 0h-1ZM10 4V2.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5V4h-2ZM8 10a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z" />
</svg>
<span class="text-sm font-medium {{ $activeSection === 'password' ? 'text-violet-500 dark:text-violet-400' : 'text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200' }}">{{ __('hub::hub.settings.nav.password') }}</span>
</button>
</li>
</ul>
</div>
{{-- Danger zone --}}
@if($isDeleteAccountEnabled)
<div class="md:mt-6">
<div class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase mb-3 hidden md:block">{{ __('hub::hub.settings.nav.danger_zone') }}</div>
<ul class="flex flex-nowrap md:block">
<li class="mr-0.5 md:mr-0 md:mb-0.5">
<button
wire:click="$set('activeSection', 'delete')"
@class([
'flex items-center px-2.5 py-2 rounded-lg whitespace-nowrap w-full text-left',
'bg-gradient-to-r from-red-500/[0.12] dark:from-red-500/[0.24] to-red-500/[0.04]' => $activeSection === 'delete',
])
>
<svg class="shrink-0 fill-current mr-2 {{ $activeSection === 'delete' ? 'text-red-400' : 'text-gray-400 dark:text-gray-500' }}" width="16" height="16" viewBox="0 0 16 16">
<path d="M5 2a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1h3a1 1 0 1 1 0 2h-.08l-.82 9.835A2 2 0 0 1 11.106 16H4.894a2 2 0 0 1-1.994-1.835L2.08 5H2a1 1 0 1 1 0-2h3V2Zm1 3v8a.5.5 0 0 0 1 0V5a.5.5 0 0 0-1 0Zm3 0v8a.5.5 0 0 0 1 0V5a.5.5 0 0 0-1 0Z" />
</svg>
<span class="text-sm font-medium {{ $activeSection === 'delete' ? 'text-red-500 dark:text-red-400' : 'text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200' }}">{{ __('hub::hub.settings.sections.delete_account.title') }}</span>
</button>
</li>
</ul>
</div>
@endif
</div>
{{-- Content panel --}}
<div class="grow p-6">
{{-- Profile Section --}}
@if($activeSection === 'profile')
<form wire:submit="updateProfile">
<flux:fieldset>
<flux:legend>{{ __('hub::hub.settings.sections.profile.title') }}</flux:legend>
<flux:description>{{ __('hub::hub.settings.sections.profile.description') }}</flux:description>
<div class="space-y-4 mt-4">
<flux:input
wire:model="name"
:label="__('hub::hub.settings.fields.name')"
:placeholder="__('hub::hub.settings.fields.name_placeholder')"
/>
<flux:input
type="email"
wire:model="email"
:label="__('hub::hub.settings.fields.email')"
:placeholder="__('hub::hub.settings.fields.email_placeholder')"
/>
</div>
<div class="flex justify-end mt-6">
<flux:button type="submit" variant="primary">
{{ __('hub::hub.settings.actions.save_profile') }}
</flux:button>
</div>
</flux:fieldset>
</form>
@endif
{{-- Preferences Section --}}
@if($activeSection === 'preferences')
<div>
<h2 class="text-2xl text-gray-800 dark:text-gray-100 font-bold mb-1">{{ __('hub::hub.settings.sections.preferences.title') }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-5">{{ __('hub::hub.settings.sections.preferences.description') }}</p>
<form wire:submit="updatePreferences" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<flux:field>
<flux:label>{{ __('hub::hub.settings.fields.language') }}</flux:label>
<flux:select wire:model="locale">
@foreach($locales as $loc)
<flux:select.option value="{{ $loc['long'] }}">{{ $loc['long'] }}</flux:select.option>
@endforeach
</flux:select>
<flux:error name="locale" />
</flux:field>
<flux:field>
<flux:label>{{ __('hub::hub.settings.fields.timezone') }}</flux:label>
<flux:select wire:model="timezone">
@foreach($timezones as $group => $zones)
<optgroup label="{{ $group }}">
@foreach($zones as $zone => $label)
<flux:select.option value="{{ $zone }}">{{ $label }}</flux:select.option>
@endforeach
</optgroup>
@endforeach
</flux:select>
<flux:error name="timezone" />
</flux:field>
<flux:field>
<flux:label>{{ __('hub::hub.settings.fields.time_format') }}</flux:label>
<flux:select wire:model="time_format">
<flux:select.option value="12">{{ __('hub::hub.settings.fields.time_format_12') }}</flux:select.option>
<flux:select.option value="24">{{ __('hub::hub.settings.fields.time_format_24') }}</flux:select.option>
</flux:select>
<flux:error name="time_format" />
</flux:field>
<flux:field>
<flux:label>{{ __('hub::hub.settings.fields.week_starts_on') }}</flux:label>
<flux:select wire:model="week_starts_on">
<flux:select.option value="0">{{ __('hub::hub.settings.fields.week_sunday') }}</flux:select.option>
<flux:select.option value="1">{{ __('hub::hub.settings.fields.week_monday') }}</flux:select.option>
</flux:select>
<flux:error name="week_starts_on" />
</flux:field>
</div>
<div class="flex justify-end">
<flux:button type="submit" variant="primary">
{{ __('hub::hub.settings.actions.save_preferences') }}
</flux:button>
</div>
</form>
</div>
@endif
{{-- Two-Factor Authentication Section --}}
@if($activeSection === 'two_factor' && $isTwoFactorEnabled)
<div>
<h2 class="text-2xl text-gray-800 dark:text-gray-100 font-bold mb-1">{{ __('hub::hub.settings.sections.two_factor.title') }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-5">{{ __('hub::hub.settings.sections.two_factor.description') }}</p>
@if(!$userHasTwoFactorEnabled && !$showTwoFactorSetup)
<div class="flex items-center justify-between">
<div>
<p class="text-gray-600 dark:text-gray-400">{{ __('hub::hub.settings.two_factor.not_enabled') }}</p>
<p class="text-sm text-gray-500 dark:text-gray-500 mt-1">{{ __('hub::hub.settings.two_factor.not_enabled_description') }}</p>
</div>
<flux:button wire:click="enableTwoFactor" variant="primary">
{{ __('hub::hub.settings.actions.enable') }}
</flux:button>
</div>
@endif
@if($showTwoFactorSetup)
<div class="space-y-4">
<div class="p-4 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
{{ __('hub::hub.settings.two_factor.setup_instructions') }}
</p>
<div class="flex flex-col sm:flex-row items-center gap-6">
<div class="bg-white p-4 rounded-lg">
{!! $twoFactorQrCode !!}
</div>
<div class="flex-1">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-2">{{ __('hub::hub.settings.two_factor.secret_key') }}</p>
<code class="block p-2 bg-gray-100 dark:bg-gray-700 rounded text-sm font-mono break-all">{{ $twoFactorSecretKey }}</code>
</div>
</div>
</div>
<flux:field>
<flux:label>{{ __('hub::hub.settings.fields.verification_code') }}</flux:label>
<flux:input wire:model="twoFactorCode" placeholder="{{ __('hub::hub.settings.fields.verification_code_placeholder') }}" maxlength="6" />
<flux:error name="twoFactorCode" />
</flux:field>
<div class="flex gap-2">
<flux:button wire:click="confirmTwoFactor" variant="primary">
{{ __('hub::hub.settings.actions.confirm') }}
</flux:button>
<flux:button wire:click="$set('showTwoFactorSetup', false)" variant="ghost">
{{ __('hub::hub.settings.actions.cancel') }}
</flux:button>
</div>
</div>
@endif
@if($userHasTwoFactorEnabled && !$showTwoFactorSetup)
<div class="space-y-4">
<div class="flex items-center gap-2 text-green-600 dark:text-green-400">
<flux:icon name="shield-check" />
<span class="font-medium">{{ __('hub::hub.settings.two_factor.enabled') }}</span>
</div>
@if($showRecoveryCodes && count($recoveryCodes) > 0)
<div class="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
<p class="text-sm text-yellow-700 dark:text-yellow-400 mb-3">
<strong>{{ __('hub::hub.settings.two_factor.recovery_codes_warning') }}</strong>
</p>
<div class="grid grid-cols-2 gap-2 font-mono text-sm">
@foreach($recoveryCodes as $code)
<code class="p-2 bg-gray-100 dark:bg-gray-700 rounded">{{ $code }}</code>
@endforeach
</div>
</div>
@endif
<div class="flex gap-2">
<flux:button wire:click="showRecoveryCodesModal">
{{ __('hub::hub.settings.actions.view_recovery_codes') }}
</flux:button>
<flux:button wire:click="regenerateRecoveryCodes">
{{ __('hub::hub.settings.actions.regenerate_codes') }}
</flux:button>
<flux:button wire:click="disableTwoFactor" variant="danger">
{{ __('hub::hub.settings.actions.disable') }}
</flux:button>
</div>
</div>
@endif
</div>
@endif
{{-- Password Section --}}
@if($activeSection === 'password')
<div>
<h2 class="text-2xl text-gray-800 dark:text-gray-100 font-bold mb-1">{{ __('hub::hub.settings.sections.password.title') }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-5">{{ __('hub::hub.settings.sections.password.description') }}</p>
<form wire:submit="updatePassword" class="space-y-4">
<flux:field>
<flux:label>{{ __('hub::hub.settings.fields.current_password') }}</flux:label>
<flux:input type="password" wire:model="current_password" viewable />
<flux:error name="current_password" />
</flux:field>
<flux:field>
<flux:label>{{ __('hub::hub.settings.fields.new_password') }}</flux:label>
<flux:input type="password" wire:model="new_password" viewable />
<flux:error name="new_password" />
</flux:field>
<flux:field>
<flux:label>{{ __('hub::hub.settings.fields.confirm_password') }}</flux:label>
<flux:input type="password" wire:model="new_password_confirmation" viewable />
<flux:error name="new_password_confirmation" />
</flux:field>
<div class="flex justify-end">
<flux:button type="submit" variant="primary">
{{ __('hub::hub.settings.actions.update_password') }}
</flux:button>
</div>
</form>
</div>
@endif
{{-- Delete Account Section --}}
@if($activeSection === 'delete' && $isDeleteAccountEnabled)
<div>
<h2 class="text-2xl text-red-600 dark:text-red-400 font-bold mb-1">{{ __('hub::hub.settings.sections.delete_account.title') }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-5">{{ __('hub::hub.settings.sections.delete_account.description') }}</p>
@if($pendingDeletion)
{{-- Pending Deletion State --}}
<div class="p-4 bg-red-500/10 border border-red-500/20 rounded-lg mb-4">
<div class="flex items-start gap-3">
<flux:icon name="clock" class="text-red-500 mt-0.5" />
<div class="flex-1">
<p class="font-medium text-red-600 dark:text-red-400">{{ __('hub::hub.settings.delete.scheduled_title') }}</p>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
{{ __('hub::hub.settings.delete.scheduled_description', ['date' => $pendingDeletion->expires_at->format('F j, Y \a\t g:i A'), 'days' => $pendingDeletion->daysRemaining()]) }}
</p>
<p class="text-sm text-gray-500 dark:text-gray-500 mt-2">
{{ __('hub::hub.settings.delete.scheduled_email_note') }}
</p>
</div>
</div>
</div>
<flux:button wire:click="cancelAccountDeletion" icon="x-mark">
{{ __('hub::hub.settings.actions.cancel_deletion') }}
</flux:button>
@elseif($showDeleteConfirmation)
{{-- Confirmation Form --}}
<div class="space-y-4">
<div class="p-4 bg-red-500/10 border border-red-500/20 rounded-lg">
<p class="text-sm text-red-600 dark:text-red-400 font-medium mb-2">
<flux:icon name="exclamation-triangle" class="inline mr-1" /> {{ __('hub::hub.settings.delete.warning_title') }}
</p>
<ul class="text-sm text-gray-600 dark:text-gray-400 space-y-1 ml-5 list-disc">
<li>{{ __('hub::hub.settings.delete.warning_delay') }}</li>
<li>{{ __('hub::hub.settings.delete.warning_workspaces') }}</li>
<li>{{ __('hub::hub.settings.delete.warning_content') }}</li>
<li>{{ __('hub::hub.settings.delete.warning_email') }}</li>
</ul>
</div>
<flux:field>
<flux:label>{{ __('hub::hub.settings.fields.delete_reason') }}</flux:label>
<flux:textarea wire:model="deleteReason" placeholder="{{ __('hub::hub.settings.fields.delete_reason_placeholder') }}" rows="2" />
</flux:field>
<div class="flex gap-2">
<flux:button wire:click="requestAccountDeletion" variant="danger" icon="trash">
{{ __('hub::hub.settings.actions.request_deletion') }}
</flux:button>
<flux:button wire:click="$set('showDeleteConfirmation', false)" variant="ghost">
{{ __('hub::hub.settings.actions.cancel') }}
</flux:button>
</div>
</div>
@else
{{-- Initial State --}}
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
{{ __('hub::hub.settings.delete.initial_description') }}
</p>
<flux:button wire:click="$set('showDeleteConfirmation', true)" variant="danger" icon="trash">
{{ __('hub::hub.settings.actions.delete_account') }}
</flux:button>
@endif
</div>
@endif
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,253 @@
@php
// Map service colors to actual Tailwind classes (dynamic classes don't work with Tailwind purge)
$colorClasses = [
'violet' => [
'bg' => 'bg-violet-500/20',
'icon' => 'text-violet-500',
'link' => 'text-violet-500 hover:text-violet-600',
],
'blue' => [
'bg' => 'bg-blue-500/20',
'icon' => 'text-blue-500',
'link' => 'text-blue-500 hover:text-blue-600',
],
'cyan' => [
'bg' => 'bg-cyan-500/20',
'icon' => 'text-cyan-500',
'link' => 'text-cyan-500 hover:text-cyan-600',
],
'orange' => [
'bg' => 'bg-orange-500/20',
'icon' => 'text-orange-500',
'link' => 'text-orange-500 hover:text-orange-600',
],
'yellow' => [
'bg' => 'bg-yellow-500/20',
'icon' => 'text-yellow-500',
'link' => 'text-yellow-500 hover:text-yellow-600',
],
'teal' => [
'bg' => 'bg-teal-500/20',
'icon' => 'text-teal-500',
'link' => 'text-teal-500 hover:text-teal-600',
],
];
@endphp
<div>
<!-- Page Header -->
<div class="sm:flex sm:justify-between sm:items-center mb-8">
<div class="mb-4 sm:mb-0">
<div class="flex items-center gap-3">
<core:heading size="xl">Site Settings</core:heading>
@if($this->workspace)
<core:badge color="violet" icon="globe">
{{ $this->workspace->name }}
</core:badge>
@endif
</div>
<core:subheading>Configure your site services and settings</core:subheading>
</div>
<div class="flex items-center gap-3">
<core:button variant="ghost" icon="plus">
New Workspace
</core:button>
</div>
</div>
@if (session()->has('success'))
<div class="mb-6 rounded-lg bg-green-50 dark:bg-green-900/20 p-4 text-green-700 dark:text-green-300">
{{ session('success') }}
</div>
@endif
@if (session()->has('error'))
<div class="mb-6 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-red-700 dark:text-red-300">
{{ session('error') }}
</div>
@endif
@if(!$this->workspace)
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-xl p-6">
<div class="flex items-center">
<core:icon name="triangle-exclamation" class="text-yellow-500 w-6 h-6 mr-3" />
<div>
<h3 class="font-medium text-yellow-800 dark:text-yellow-200">No Workspace Selected</h3>
<p class="text-yellow-700 dark:text-yellow-300">Please select a workspace using the switcher in the header.</p>
</div>
</div>
</div>
@else
<!-- Tab Navigation -->
<admin:tabs :tabs="$this->tabs" :selected="$tab" />
<!-- Tab Content -->
@if($tab === 'services')
<div class="mb-6 flex items-center justify-between">
<p class="text-gray-600 dark:text-gray-400">Enable services for this site</p>
<core:button href="/hub/account/usage?tab=boosts" wire:navigate variant="primary" icon="bolt">
Get More Services
</core:button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
@foreach($this->serviceCards as $service)
@php $colors = $colorClasses[$service['color']] ?? $colorClasses['violet']; @endphp
<div class="bg-white dark:bg-gray-800 shadow-xs rounded-xl overflow-hidden border border-gray-100 dark:border-gray-700">
{{-- Card Header --}}
<div class="px-5 py-4 border-b border-gray-100 dark:border-gray-700/60">
<div class="flex items-center justify-between">
<div class="flex items-center">
<div class="w-10 h-10 rounded-lg {{ $colors['bg'] }} flex items-center justify-center mr-3">
<core:icon :name="$service['icon']" class="{{ $colors['icon'] }} text-lg" />
</div>
<div>
<h3 class="font-semibold text-gray-800 dark:text-gray-100">{{ $service['name'] }}</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ $service['description'] }}</p>
</div>
</div>
@unless($service['entitled'])
<core:button wire:click="addService('{{ $service['feature'] }}')" variant="primary" size="sm" icon="plus">
Add
</core:button>
@endunless
</div>
</div>
{{-- Features List --}}
<div class="px-5 py-4">
<ul class="space-y-2">
@foreach($service['features'] as $feature)
<li class="flex items-center text-sm text-gray-600 dark:text-gray-300">
<core:icon name="check" class="{{ $colors['icon'] }} mr-2 text-xs" />
{{ $feature }}
</li>
@endforeach
</ul>
</div>
{{-- Card Footer --}}
<div class="px-5 py-3 bg-gray-50 dark:bg-gray-700/20 border-t border-gray-100 dark:border-gray-700/60">
<div class="flex items-center justify-between">
@if($service['entitled'])
<flux:badge color="green" size="sm" icon="check">Active</flux:badge>
<flux:button href="{{ $service['adminRoute'] }}" wire:navigate variant="ghost" size="sm" icon-trailing="chevron-right">
Manage
</flux:button>
@else
<flux:badge color="zinc" size="sm">Not active</flux:badge>
<core:badge color="zinc" size="sm" icon="lock">Locked</core:badge>
@endif
</div>
</div>
</div>
@endforeach
</div>
@elseif($tab === 'general')
<div class="bg-white dark:bg-gray-800 shadow-xs rounded-xl overflow-hidden">
<div class="px-6 py-4 border-b border-gray-100 dark:border-gray-700/60">
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">General Settings</h2>
</div>
<div class="p-6 space-y-4">
<div class="flex items-center justify-between py-3 border-b border-gray-100 dark:border-gray-700/60">
<span class="text-sm text-gray-600 dark:text-gray-400">Site name</span>
<span class="text-sm font-medium text-gray-800 dark:text-gray-200">{{ $this->workspace->name }}</span>
</div>
<div class="flex items-center justify-between py-3 border-b border-gray-100 dark:border-gray-700/60">
<span class="text-sm text-gray-600 dark:text-gray-400">Domain</span>
<span class="text-sm font-medium text-gray-800 dark:text-gray-200">{{ $this->workspace->domain ?? 'Not configured' }}</span>
</div>
<div class="flex items-center justify-between py-3 border-b border-gray-100 dark:border-gray-700/60">
<span class="text-sm text-gray-600 dark:text-gray-400">Description</span>
<span class="text-sm font-medium text-gray-800 dark:text-gray-200">{{ $this->workspace->description ?? 'No description' }}</span>
</div>
<div class="flex items-center justify-between py-3">
<span class="text-sm text-gray-600 dark:text-gray-400">Status</span>
@if($this->workspace->is_active)
<core:badge color="green">Active</core:badge>
@else
<core:badge color="gray">Inactive</core:badge>
@endif
</div>
</div>
</div>
@elseif($tab === 'deployment')
<div class="bg-violet-50 dark:bg-violet-900/20 border border-violet-200 dark:border-violet-800 rounded-xl p-6">
<div class="flex items-start">
<core:icon name="wrench" class="text-violet-500 w-6 h-6 mr-3 flex-shrink-0" />
<div>
<h3 class="font-medium text-violet-800 dark:text-violet-200">Coming Soon</h3>
<p class="text-violet-700 dark:text-violet-300">
Deployment settings will allow you to configure Git repository, branches, build commands, and deploy hooks.
</p>
</div>
</div>
</div>
@elseif($tab === 'environment')
<div class="bg-violet-50 dark:bg-violet-900/20 border border-violet-200 dark:border-violet-800 rounded-xl p-6">
<div class="flex items-start">
<core:icon name="wrench" class="text-violet-500 w-6 h-6 mr-3 flex-shrink-0" />
<div>
<h3 class="font-medium text-violet-800 dark:text-violet-200">Coming Soon</h3>
<p class="text-violet-700 dark:text-violet-300">
Environment settings will allow you to configure environment variables, secrets, and runtime versions.
</p>
</div>
</div>
</div>
@elseif($tab === 'ssl')
<div class="bg-violet-50 dark:bg-violet-900/20 border border-violet-200 dark:border-violet-800 rounded-xl p-6">
<div class="flex items-start">
<core:icon name="wrench" class="text-violet-500 w-6 h-6 mr-3 flex-shrink-0" />
<div>
<h3 class="font-medium text-violet-800 dark:text-violet-200">Coming Soon</h3>
<p class="text-violet-700 dark:text-violet-300">
SSL & Security settings will allow you to manage SSL certificates, force HTTPS, and HTTP/2 configuration.
</p>
</div>
</div>
</div>
@elseif($tab === 'backups')
<div class="bg-violet-50 dark:bg-violet-900/20 border border-violet-200 dark:border-violet-800 rounded-xl p-6">
<div class="flex items-start">
<core:icon name="wrench" class="text-violet-500 w-6 h-6 mr-3 flex-shrink-0" />
<div>
<h3 class="font-medium text-violet-800 dark:text-violet-200">Coming Soon</h3>
<p class="text-violet-700 dark:text-violet-300">
Backup settings will allow you to configure backup frequency, retention periods, and restore points.
</p>
</div>
</div>
</div>
@elseif($tab === 'danger')
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-6">
<div class="flex items-start">
<core:icon name="triangle-exclamation" class="text-red-500 w-6 h-6 mr-3 flex-shrink-0" />
<div>
<h3 class="font-medium text-red-800 dark:text-red-200">Danger Zone</h3>
<p class="text-red-700 dark:text-red-300 mb-4">
These actions are destructive and cannot be undone.
</p>
<div class="space-y-4">
<div class="flex items-center justify-between p-4 bg-white dark:bg-gray-800 rounded-lg border border-red-200 dark:border-red-800">
<div>
<h4 class="font-medium text-gray-800 dark:text-gray-200">Transfer Ownership</h4>
<p class="text-sm text-gray-600 dark:text-gray-400">Transfer this site to another user</p>
</div>
<core:button variant="danger" disabled>Transfer</core:button>
</div>
<div class="flex items-center justify-between p-4 bg-white dark:bg-gray-800 rounded-lg border border-red-200 dark:border-red-800">
<div>
<h4 class="font-medium text-gray-800 dark:text-gray-200">Delete Site</h4>
<p class="text-sm text-gray-600 dark:text-gray-400">Permanently delete this site and all its data</p>
</div>
<core:button variant="danger" disabled>Delete</core:button>
</div>
</div>
</div>
</div>
</div>
@endif
@endif
</div>

View file

@ -0,0 +1,72 @@
<admin:module :title="__('hub::hub.workspaces.title')" :subtitle="__('hub::hub.workspaces.subtitle')">
<x-slot:actions>
<core:button icon="plus">{{ __('hub::hub.workspaces.add') }}</core:button>
</x-slot:actions>
@if($this->workspaces->isEmpty())
<div class="text-center py-12">
<core:icon name="layer-group" class="w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
<p class="text-gray-500 dark:text-gray-400">{{ __('hub::hub.workspaces.empty') }}</p>
</div>
@else
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
@foreach($this->workspaces as $workspace)
@php
$isCurrent = $workspace->slug === $this->currentWorkspaceSlug;
$colorMap = [
'violet' => 'bg-violet-100 dark:bg-violet-500/20 text-violet-500',
'blue' => 'bg-blue-100 dark:bg-blue-500/20 text-blue-500',
'green' => 'bg-green-100 dark:bg-green-500/20 text-green-500',
'orange' => 'bg-orange-100 dark:bg-orange-500/20 text-orange-500',
'red' => 'bg-red-100 dark:bg-red-500/20 text-red-500',
'cyan' => 'bg-cyan-100 dark:bg-cyan-500/20 text-cyan-500',
'gray' => 'bg-gray-100 dark:bg-gray-500/20 text-gray-500',
];
$color = $workspace->color ?? 'violet';
$iconClasses = $colorMap[$color] ?? $colorMap['violet'];
@endphp
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs border border-gray-100 dark:border-gray-700 overflow-hidden">
<div class="p-5">
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-lg {{ $iconClasses }} flex items-center justify-center">
<core:icon :name="$workspace->icon ?? 'folder'" class="w-6 h-6" />
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100">{{ $workspace->name }}</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $workspace->domain ?? $workspace->slug }}</p>
</div>
</div>
@if($isCurrent)
<flux:badge color="green" size="sm" icon="check">
{{ __('hub::hub.workspaces.active') }}
</flux:badge>
@else
<flux:button wire:click="activate('{{ $workspace->slug }}')" size="sm" variant="ghost">
{{ __('hub::hub.workspaces.activate') }}
</flux:button>
@endif
</div>
@if($workspace->description)
<p class="mt-3 text-sm text-gray-600 dark:text-gray-400">{{ $workspace->description }}</p>
@endif
</div>
<div class="px-5 py-3 bg-gray-50 dark:bg-gray-700/30 border-t border-gray-100 dark:border-gray-700 flex items-center justify-between">
<div class="flex items-center gap-2">
@if($workspace->domain)
<flux:button href="https://{{ $workspace->domain }}" target="_blank" size="xs" variant="ghost" icon="arrow-top-right-on-square">
Visit
</flux:button>
@endif
</div>
<flux:button href="{{ route('hub.sites.settings', ['workspace' => $workspace->slug]) }}" wire:navigate size="xs" variant="ghost" icon-trailing="chevron-right">
Settings
</flux:button>
</div>
</div>
@endforeach
</div>
@endif
</admin:module>

View file

@ -0,0 +1,209 @@
<div>
<!-- Page header -->
<div class="mb-8">
<h1 class="text-2xl md:text-3xl text-gray-800 dark:text-gray-100 font-bold">{{ __('hub::hub.usage.title') }}</h1>
<p class="text-gray-500 dark:text-gray-400 mt-1">{{ __('hub::hub.usage.subtitle') }}</p>
</div>
<div class="space-y-6">
<!-- Active Packages -->
<div class="bg-white dark:bg-gray-800 shadow-xs rounded-xl">
<header class="px-5 py-4 border-b border-gray-100 dark:border-gray-700/60">
<h2 class="font-semibold text-gray-800 dark:text-gray-100">{{ __('hub::hub.usage.packages.title') }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ __('hub::hub.usage.packages.subtitle') }}</p>
</header>
<div class="p-5">
@if($activePackages->isEmpty())
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
<core:icon name="box" class="size-8 mx-auto mb-2 opacity-50" />
<p>{{ __('hub::hub.usage.packages.empty') }}</p>
<p class="text-sm mt-1">{{ __('hub::hub.usage.packages.empty_hint') }}</p>
</div>
@else
<div class="grid gap-4 sm:grid-cols-2">
@foreach($activePackages as $workspacePackage)
<div class="flex items-start gap-4 p-4 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
@if($workspacePackage->package->icon)
<div class="shrink-0 w-10 h-10 rounded-lg bg-{{ $workspacePackage->package->color ?? 'blue' }}-500/10 flex items-center justify-center">
<core:icon :name="$workspacePackage->package->icon" class="size-5 text-{{ $workspacePackage->package->color ?? 'blue' }}-500" />
</div>
@endif
<div class="flex-1 min-w-0">
<h3 class="font-medium text-gray-900 dark:text-gray-100">
{{ $workspacePackage->package->name }}
</h3>
@if($workspacePackage->package->description)
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
{{ $workspacePackage->package->description }}
</p>
@endif
<div class="flex items-center gap-2 mt-2">
@if($workspacePackage->package->is_base_package)
<core:badge size="sm" color="purple">{{ __('hub::hub.usage.badges.base') }}</core:badge>
@else
<core:badge size="sm" color="blue">{{ __('hub::hub.usage.badges.addon') }}</core:badge>
@endif
<core:badge size="sm" color="green">{{ __('hub::hub.usage.badges.active') }}</core:badge>
@if($workspacePackage->expires_at)
<span class="text-xs text-gray-500">
{{ __('hub::hub.usage.packages.renews', ['time' => $workspacePackage->expires_at->diffForHumans()]) }}
</span>
@endif
</div>
</div>
</div>
@endforeach
</div>
@endif
</div>
</div>
<!-- Usage by Category -->
@forelse($usageSummary as $category => $features)
<div class="bg-white dark:bg-gray-800 shadow-xs rounded-xl">
<header class="px-5 py-4 border-b border-gray-100 dark:border-gray-700/60">
<h2 class="font-semibold text-gray-800 dark:text-gray-100 capitalize">{{ $category ?? __('hub::hub.usage.categories.general') }}</h2>
</header>
<div class="p-5 space-y-4">
@foreach($features as $feature)
<div class="space-y-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-700 dark:text-gray-300">
{{ $feature['name'] }}
</span>
@if(!$feature['allowed'])
<core:badge size="sm" color="gray">{{ __('hub::hub.usage.badges.not_included') }}</core:badge>
@elseif($feature['unlimited'])
<core:badge size="sm" color="purple">{{ __('hub::hub.usage.badges.unlimited') }}</core:badge>
@elseif($feature['type'] === 'boolean')
<core:badge size="sm" color="green">{{ __('hub::hub.usage.badges.enabled') }}</core:badge>
@endif
</div>
@if($feature['allowed'] && !$feature['unlimited'] && $feature['type'] === 'limit')
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ number_format($feature['used']) }} / {{ number_format($feature['limit']) }}
</span>
@endif
</div>
@if($feature['allowed'] && !$feature['unlimited'] && $feature['type'] === 'limit')
<div class="relative h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
@php
$percentage = min($feature['percentage'] ?? 0, 100);
$colorClass = match(true) {
$percentage >= 90 => 'bg-red-500',
$percentage >= 75 => 'bg-amber-500',
default => 'bg-green-500',
};
@endphp
<div
class="absolute inset-y-0 left-0 {{ $colorClass }} transition-all duration-300"
style="width: {{ $percentage }}%"
></div>
</div>
@if($feature['near_limit'])
<p class="text-xs text-amber-600 dark:text-amber-400">
<core:icon name="triangle-exclamation" class="size-3 mr-1" />
{{ __('hub::hub.usage.warnings.approaching_limit', ['remaining' => $feature['remaining']]) }}
</p>
@endif
@endif
</div>
@endforeach
</div>
</div>
@empty
<div class="bg-white dark:bg-gray-800 shadow-xs rounded-xl">
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
<core:icon name="chart-bar" class="size-8 mx-auto mb-2 opacity-50" />
<p>{{ __('hub::hub.usage.empty.title') }}</p>
<p class="text-sm mt-1">{{ __('hub::hub.usage.empty.hint') }}</p>
</div>
</div>
@endforelse
<!-- Active Boosts -->
@if($activeBoosts->isNotEmpty())
<div class="bg-white dark:bg-gray-800 shadow-xs rounded-xl">
<header class="px-5 py-4 border-b border-gray-100 dark:border-gray-700/60">
<h2 class="font-semibold text-gray-800 dark:text-gray-100">{{ __('hub::hub.usage.active_boosts.title') }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ __('hub::hub.usage.active_boosts.subtitle') }}</p>
</header>
<div class="p-5">
<div class="space-y-3">
@foreach($activeBoosts as $boost)
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<div>
<span class="font-medium text-gray-700 dark:text-gray-300">
{{ $boost->feature_code }}
</span>
<div class="flex items-center gap-2 mt-1">
@switch($boost->boost_type)
@case('add_limit')
<core:badge size="sm" color="blue">
+{{ number_format($boost->limit_value) }}
</core:badge>
@break
@case('unlimited')
<core:badge size="sm" color="purple">{{ __('hub::hub.usage.badges.unlimited') }}</core:badge>
@break
@case('enable')
<core:badge size="sm" color="green">{{ __('hub::hub.usage.badges.enabled') }}</core:badge>
@break
@endswitch
@switch($boost->duration_type)
@case('cycle_bound')
<span class="text-xs text-gray-500">{{ __('hub::hub.usage.duration.cycle_bound') }}</span>
@break
@case('duration')
@if($boost->expires_at)
<span class="text-xs text-gray-500">
{{ __('hub::hub.usage.duration.expires', ['time' => $boost->expires_at->diffForHumans()]) }}
</span>
@endif
@break
@case('permanent')
<span class="text-xs text-gray-500">{{ __('hub::hub.usage.duration.permanent') }}</span>
@break
@endswitch
</div>
</div>
@if($boost->boost_type === 'add_limit' && $boost->limit_value)
<div class="text-right">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ number_format($boost->getRemainingLimit()) }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ __('hub::hub.usage.active_boosts.remaining') }}</span>
</div>
@endif
</div>
@endforeach
</div>
</div>
</div>
@endif
<!-- Upgrade CTA -->
<div class="bg-gradient-to-r from-violet-500/10 to-purple-500/10 dark:from-violet-500/20 dark:to-purple-500/20 rounded-xl p-6 text-center">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
{{ __('hub::hub.usage.cta.title') }}
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">
{{ __('hub::hub.usage.cta.subtitle') }}
</p>
<div class="flex justify-center gap-3">
<core:button href="/hub/account/usage?tab=boosts" wire:navigate variant="outline">
<core:icon name="rocket" class="mr-2" />
{{ __('hub::hub.usage.cta.add_boosts') }}
</core:button>
<core:button href="{{ route('pricing') }}" variant="primary">
<core:icon name="arrow-up-right-from-square" class="mr-2" />
{{ __('hub::hub.usage.cta.view_plans') }}
</core:button>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,40 @@
<admin:module title="Waitlist" subtitle="Manage signups and invitations">
<x-slot:actions>
<core:button wire:click="export" icon="arrow-down-tray" variant="ghost">Export CSV</core:button>
@if (count($selected) > 0)
<core:button wire:click="sendBulkInvites" icon="paper-airplane" variant="primary">
Invite Selected ({{ count($selected) }})
</core:button>
@endif
</x-slot:actions>
<admin:flash />
{{-- Stats Cards --}}
<admin:stats cols="4" class="mb-6">
<admin:stat-card label="Total signups" :value="number_format($totalCount)" />
<admin:stat-card label="Pending invite" :value="number_format($pendingCount)" color="amber" />
<admin:stat-card label="Invited" :value="number_format($invitedCount)" color="blue" />
<admin:stat-card label="Converted" :value="number_format($convertedCount)" color="green" />
</admin:stats>
<admin:filter-bar cols="4">
<admin:search model="search" placeholder="Search emails or names..." />
<admin:filter model="statusFilter" :options="$this->statusOptions" placeholder="All entries" />
<admin:filter model="interestFilter" :options="$this->interests" placeholder="All interests" />
<div class="flex items-center">
<label class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<input type="checkbox" wire:model.live="selectAll" class="rounded">
Select all
</label>
</div>
</admin:filter-bar>
<admin:manager-table
:columns="$this->tableColumns"
:rows="$this->tableRows"
:pagination="$this->entries"
empty="No waitlist entries found."
emptyIcon="users"
/>
</admin:module>

View file

@ -0,0 +1,58 @@
<div class="relative" x-data="{ open: @entangle('open') }">
<!-- Trigger Button -->
<button
@click="open = !open"
class="flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700/50 hover:bg-gray-200 dark:hover:bg-gray-700 transition"
>
<div class="w-6 h-6 rounded-md bg-{{ $current['color'] }}-500/20 flex items-center justify-center">
<core:icon :name="$current['icon']" class="text-{{ $current['color'] }}-500 text-xs" />
</div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">{{ $current['name'] }}</span>
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="open ? 'rotate-180' : ''" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<!-- Dropdown -->
<div
x-show="open"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
@click.outside="open = false"
class="absolute left-0 mt-2 w-64 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden z-50"
x-cloak
>
<div class="px-4 py-3 border-b border-gray-100 dark:border-gray-700">
<p class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">{{ __('hub::hub.workspace_switcher.title') }}</p>
</div>
<div class="py-2">
@foreach($workspaces as $slug => $workspace)
<button
wire:click="switchWorkspace('{{ $slug }}')"
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition {{ $current['slug'] === $slug ? 'bg-gray-50 dark:bg-gray-700/30' : '' }}"
>
<div class="w-8 h-8 rounded-lg bg-{{ $workspace['color'] }}-500/20 flex items-center justify-center shrink-0">
<core:icon :name="$workspace['icon']" class="text-{{ $workspace['color'] }}-500" />
</div>
<div class="flex-1 text-left">
<div class="text-sm font-medium text-gray-800 dark:text-gray-100">{{ $workspace['name'] }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">{{ $workspace['description'] }}</div>
</div>
@if($current['slug'] === $slug)
<core:icon name="check" class="text-{{ $workspace['color'] }}-500" />
@endif
</button>
@endforeach
</div>
<div class="px-4 py-3 border-t border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/20">
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<core:icon name="globe" />
<span class="truncate">{{ $current['domain'] }}</span>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,150 @@
<div x-data="{
copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => {
$wire.dispatch('copy-to-clipboard', { text });
});
}
}" @copy-to-clipboard.window="copyToClipboard($event.detail.text)">
<core:card>
<div class="flex items-center gap-3 mb-6">
<core:icon name="link" class="w-6 h-6 text-violet-500" />
<div>
<core:heading size="lg">WordPress Connector</core:heading>
<core:subheading>Connect your self-hosted WordPress site to sync content</core:subheading>
</div>
</div>
<div class="space-y-6">
<!-- Enable Toggle -->
<core:switch
wire:model.live="enabled"
label="Enable WordPress Connector"
description="Allow your WordPress site to send content updates to Host Hub"
/>
@if($enabled)
<!-- WordPress URL -->
<core:input
wire:model="wordpressUrl"
label="WordPress Site URL"
placeholder="https://your-site.com"
type="url"
/>
<!-- Webhook Configuration -->
<div class="p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg space-y-4">
<core:heading size="sm">Plugin Configuration</core:heading>
<core:text size="sm" class="text-zinc-600 dark:text-zinc-400">
Install the Host Hub Connector plugin on your WordPress site and enter these settings:
</core:text>
<!-- Webhook URL -->
<div>
<core:label>Webhook URL</core:label>
<div class="flex gap-2 mt-1">
<core:input
:value="$this->webhookUrl"
readonly
class="flex-1 font-mono text-sm"
/>
<core:button
wire:click="copyToClipboard('{{ $this->webhookUrl }}')"
variant="ghost"
icon="clipboard"
/>
</div>
</div>
<!-- Webhook Secret -->
<div>
<core:label>Webhook Secret</core:label>
<div class="flex gap-2 mt-1">
<core:input
:value="$this->webhookSecret"
readonly
type="password"
class="flex-1 font-mono text-sm"
x-data="{ show: false }"
:x-bind:type="show ? 'text' : 'password'"
/>
<core:button
wire:click="copyToClipboard('{{ $this->webhookSecret }}')"
variant="ghost"
icon="clipboard"
/>
<core:button
wire:click="regenerateSecret"
wire:confirm="This will invalidate the current secret. You'll need to update your WordPress plugin settings."
variant="ghost"
icon="arrow-path"
/>
</div>
<core:text size="xs" class="text-zinc-500 mt-1">
Keep this secret safe. It's used to verify webhooks are from your WordPress site.
</core:text>
</div>
</div>
<!-- Connection Status -->
<div class="flex items-center justify-between p-4 border border-zinc-200 dark:border-zinc-700 rounded-lg">
<div class="flex items-center gap-3">
@if($this->isVerified)
<div class="w-3 h-3 bg-green-500 rounded-full"></div>
<div>
<core:text class="font-medium text-green-600 dark:text-green-400">Connected</core:text>
@if($this->lastSync)
<core:text size="sm" class="text-zinc-500">Last sync: {{ $this->lastSync }}</core:text>
@endif
</div>
@else
<div class="w-3 h-3 bg-amber-500 rounded-full"></div>
<div>
<core:text class="font-medium text-amber-600 dark:text-amber-400">Not verified</core:text>
<core:text size="sm" class="text-zinc-500">Test the connection to verify</core:text>
</div>
@endif
</div>
<core:button
wire:click="testConnection"
wire:loading.attr="disabled"
variant="ghost"
icon="signal"
:loading="$testing"
>
Test Connection
</core:button>
</div>
@if($testResult)
<core:callout :variant="$testSuccess ? 'success' : 'danger'" icon="{{ $testSuccess ? 'check-circle' : 'exclamation-circle' }}">
{{ $testResult }}
</core:callout>
@endif
<!-- Plugin Download -->
<div class="p-4 border border-dashed border-zinc-300 dark:border-zinc-600 rounded-lg">
<div class="flex items-start gap-3">
<core:icon name="puzzle-piece" class="w-5 h-5 text-violet-500 mt-0.5" />
<div>
<core:heading size="sm">WordPress Plugin</core:heading>
<core:text size="sm" class="text-zinc-600 dark:text-zinc-400 mt-1">
Download and install the Host Hub Connector plugin on your WordPress site to enable content syncing.
</core:text>
<core:button variant="subtle" size="sm" class="mt-2" icon="arrow-down-tray">
Download Plugin
</core:button>
</div>
</div>
</div>
@endif
</div>
<div class="flex justify-end gap-3 mt-6 pt-6 border-t border-zinc-200 dark:border-zinc-700">
<core:button wire:click="save" variant="primary">
Save Settings
</core:button>
</div>
</core:card>
</div>

View file

@ -0,0 +1,179 @@
<?php
namespace Website\Hub\View\Modal\Admin;
use Core\Mod\Social\Actions\Common\UpdateOrCreateService;
use Core\Mod\Social\Services\ServiceManager;
use Livewire\Component;
class AIServices extends Component
{
// Claude configuration
public string $claudeApiKey = '';
public string $claudeModel = 'claude-sonnet-4-20250514';
public bool $claudeActive = false;
// Gemini configuration
public string $geminiApiKey = '';
public string $geminiModel = 'gemini-2.0-flash';
public bool $geminiActive = false;
// OpenAI configuration
public string $openaiSecretKey = '';
public bool $openaiActive = false;
// UI state
public string $activeTab = 'claude';
public string $savedMessage = '';
protected array $claudeModels = [
'claude-sonnet-4-20250514' => 'Claude Sonnet 4 (Recommended)',
'claude-opus-4-20250514' => 'Claude Opus 4',
'claude-3-5-sonnet-20241022' => 'Claude 3.5 Sonnet',
'claude-3-5-haiku-20241022' => 'Claude 3.5 Haiku (Fast)',
];
protected array $geminiModels = [
'gemini-2.0-flash' => 'Gemini 2.0 Flash (Recommended)',
'gemini-2.0-flash-lite' => 'Gemini 2.0 Flash Lite (Fast)',
'gemini-1.5-pro' => 'Gemini 1.5 Pro',
'gemini-1.5-flash' => 'Gemini 1.5 Flash',
];
protected ServiceManager $serviceManager;
public function boot(ServiceManager $serviceManager): void
{
$this->serviceManager = $serviceManager;
}
public function mount(): void
{
$this->loadServices();
}
protected function loadServices(): void
{
// Load Claude
try {
$claude = $this->serviceManager->get('claude');
$this->claudeApiKey = $claude['configuration']['api_key'] ?? '';
$this->claudeModel = $claude['configuration']['model'] ?? 'claude-sonnet-4-20250514';
$this->claudeActive = $claude['active'] ?? false;
} catch (\Exception $e) {
// Service not configured yet
}
// Load Gemini
try {
$gemini = $this->serviceManager->get('gemini');
$this->geminiApiKey = $gemini['configuration']['api_key'] ?? '';
$this->geminiModel = $gemini['configuration']['model'] ?? 'gemini-2.0-flash';
$this->geminiActive = $gemini['active'] ?? false;
} catch (\Exception $e) {
// Service not configured yet
}
// Load OpenAI
try {
$openai = $this->serviceManager->get('openai');
$this->openaiSecretKey = $openai['configuration']['secret_key'] ?? '';
$this->openaiActive = $openai['active'] ?? false;
} catch (\Exception $e) {
// Service not configured yet
}
}
public function saveClaude(): void
{
$this->validate([
'claudeApiKey' => 'required_if:claudeActive,true',
'claudeModel' => 'required|in:'.implode(',', array_keys($this->claudeModels)),
], [
'claudeApiKey.required_if' => 'API key is required when the service is active.',
]);
(new UpdateOrCreateService)(
name: 'claude',
configuration: [
'api_key' => $this->claudeApiKey,
'model' => $this->claudeModel,
],
active: $this->claudeActive
);
// Clear the cache so changes take effect
$this->serviceManager->forget('claude');
$this->savedMessage = 'Claude settings saved.';
$this->dispatch('service-saved');
}
public function saveGemini(): void
{
$this->validate([
'geminiApiKey' => 'required_if:geminiActive,true',
'geminiModel' => 'required|in:'.implode(',', array_keys($this->geminiModels)),
], [
'geminiApiKey.required_if' => 'API key is required when the service is active.',
]);
(new UpdateOrCreateService)(
name: 'gemini',
configuration: [
'api_key' => $this->geminiApiKey,
'model' => $this->geminiModel,
],
active: $this->geminiActive
);
$this->serviceManager->forget('gemini');
$this->savedMessage = 'Gemini settings saved.';
$this->dispatch('service-saved');
}
public function saveOpenAI(): void
{
$this->validate([
'openaiSecretKey' => 'required_if:openaiActive,true',
], [
'openaiSecretKey.required_if' => 'API key is required when the service is active.',
]);
(new UpdateOrCreateService)(
name: 'openai',
configuration: [
'secret_key' => $this->openaiSecretKey,
],
active: $this->openaiActive
);
$this->serviceManager->forget('openai');
$this->savedMessage = 'OpenAI settings saved.';
$this->dispatch('service-saved');
}
public function getClaudeModelsProperty(): array
{
return $this->claudeModels;
}
public function getGeminiModelsProperty(): array
{
return $this->geminiModels;
}
public function render()
{
return view('hub::admin.ai-services')
->layout('hub::admin.layouts.app', ['title' => 'AI Services']);
}
}

View file

@ -0,0 +1,339 @@
<?php
namespace Website\Hub\View\Modal\Admin;
use Core\Front\Admin\AdminMenuRegistry;
use Flux\Flux;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Url;
use Livewire\Component;
use Core\Mod\Social\Actions\Common\UpdateOrCreateService;
use Core\Mod\Social\Services\ServiceManager;
use Core\Mod\Tenant\Models\Feature;
use Core\Mod\Tenant\Models\Workspace;
use Core\Mod\Tenant\Services\EntitlementService;
class AccountUsage extends Component
{
#[Url(as: 'tab')]
public string $activeSection = 'overview';
// Usage data (loaded on demand)
public ?array $usageSummary = null;
public ?array $activePackages = null;
public ?array $activeBoosts = null;
// Boost options (loaded on demand)
public ?array $boostOptions = null;
// AI services loaded flag
protected bool $aiServicesLoaded = false;
// AI Services
public string $claudeApiKey = '';
public string $claudeModel = 'claude-sonnet-4-20250514';
public bool $claudeActive = false;
public string $geminiApiKey = '';
public string $geminiModel = 'gemini-2.0-flash';
public bool $geminiActive = false;
public string $openaiSecretKey = '';
public bool $openaiActive = false;
public string $activeAiTab = 'claude';
protected array $claudeModels = [
'claude-sonnet-4-20250514' => 'Claude Sonnet 4 (Recommended)',
'claude-opus-4-20250514' => 'Claude Opus 4',
'claude-3-5-sonnet-20241022' => 'Claude 3.5 Sonnet',
'claude-3-5-haiku-20241022' => 'Claude 3.5 Haiku (Fast)',
];
protected array $geminiModels = [
'gemini-2.0-flash' => 'Gemini 2.0 Flash (Recommended)',
'gemini-2.0-flash-lite' => 'Gemini 2.0 Flash Lite (Fast)',
'gemini-1.5-pro' => 'Gemini 1.5 Pro',
'gemini-1.5-flash' => 'Gemini 1.5 Flash',
];
protected ServiceManager $serviceManager;
protected EntitlementService $entitlementService;
public function boot(ServiceManager $serviceManager, EntitlementService $entitlementService): void
{
$this->serviceManager = $serviceManager;
$this->entitlementService = $entitlementService;
}
public function mount(): void
{
$this->loadDataForTab($this->activeSection);
}
/**
* Load data when tab changes.
*/
public function updatedActiveSection(string $tab): void
{
$this->loadDataForTab($tab);
}
/**
* Load only the data needed for the active tab.
*/
protected function loadDataForTab(string $tab): void
{
match ($tab) {
'overview' => $this->loadUsageData(),
'boosts' => $this->loadBoostOptions(),
'ai' => $this->loadAiServices(),
default => null,
};
}
protected function loadUsageData(): void
{
if ($this->usageSummary !== null) {
return; // Already loaded
}
$workspace = Auth::user()?->defaultHostWorkspace();
if (! $workspace) {
$this->usageSummary = [];
$this->activePackages = [];
$this->activeBoosts = [];
return;
}
$this->usageSummary = $this->entitlementService->getUsageSummary($workspace)->toArray();
$this->activePackages = $this->entitlementService->getActivePackages($workspace)->toArray();
$this->activeBoosts = $this->entitlementService->getActiveBoosts($workspace)->toArray();
}
protected function loadBoostOptions(): void
{
if ($this->boostOptions !== null) {
return; // Already loaded
}
$addonMapping = config('services.blesta.addon_mapping', []);
$this->boostOptions = collect($addonMapping)->map(function ($config, $blestaId) {
$feature = Feature::where('code', $config['feature_code'])->first();
return [
'blesta_id' => $blestaId,
'feature_code' => $config['feature_code'],
'feature_name' => $feature?->name ?? $config['feature_code'],
'boost_type' => $config['boost_type'],
'limit_value' => $config['limit_value'] ?? null,
'duration_type' => $config['duration_type'],
'description' => $this->getBoostDescription($config),
];
})->values()->toArray();
}
protected function getBoostDescription(array $config): string
{
$type = $config['boost_type'];
$value = $config['limit_value'] ?? null;
$duration = $config['duration_type'];
$description = match ($type) {
'add_limit' => "+{$value} additional",
'unlimited' => 'Unlimited access',
'enable' => 'Feature enabled',
default => 'Boost',
};
$durationText = match ($duration) {
'cycle_bound' => 'until billing cycle ends',
'duration' => 'for limited time',
'permanent' => 'permanently',
default => '',
};
return trim("{$description} {$durationText}");
}
protected function loadAiServices(): void
{
if ($this->aiServicesLoaded) {
return; // Already loaded
}
try {
$claude = $this->serviceManager->get('claude');
$this->claudeApiKey = $claude['configuration']['api_key'] ?? '';
$this->claudeModel = $claude['configuration']['model'] ?? 'claude-sonnet-4-20250514';
$this->claudeActive = $claude['active'] ?? false;
} catch (\Exception) {
}
try {
$gemini = $this->serviceManager->get('gemini');
$this->geminiApiKey = $gemini['configuration']['api_key'] ?? '';
$this->geminiModel = $gemini['configuration']['model'] ?? 'gemini-2.0-flash';
$this->geminiActive = $gemini['active'] ?? false;
} catch (\Exception) {
}
try {
$openai = $this->serviceManager->get('openai');
$this->openaiSecretKey = $openai['configuration']['secret_key'] ?? '';
$this->openaiActive = $openai['active'] ?? false;
} catch (\Exception) {
}
$this->aiServicesLoaded = true;
}
public function purchaseBoost(string $blestaId): void
{
$blestaUrl = config('services.blesta.url', 'https://billing.host.uk.com');
$this->redirect("{$blestaUrl}/order/addon/{$blestaId}");
}
public function saveClaude(): void
{
$this->validate([
'claudeApiKey' => 'required_if:claudeActive,true',
'claudeModel' => 'required|in:'.implode(',', array_keys($this->claudeModels)),
], [
'claudeApiKey.required_if' => 'API key is required when the service is active.',
]);
(new UpdateOrCreateService)(
name: 'claude',
configuration: [
'api_key' => $this->claudeApiKey,
'model' => $this->claudeModel,
],
active: $this->claudeActive
);
$this->serviceManager->forget('claude');
Flux::toast(text: 'Claude settings saved.', variant: 'success');
}
public function saveGemini(): void
{
$this->validate([
'geminiApiKey' => 'required_if:geminiActive,true',
'geminiModel' => 'required|in:'.implode(',', array_keys($this->geminiModels)),
], [
'geminiApiKey.required_if' => 'API key is required when the service is active.',
]);
(new UpdateOrCreateService)(
name: 'gemini',
configuration: [
'api_key' => $this->geminiApiKey,
'model' => $this->geminiModel,
],
active: $this->geminiActive
);
$this->serviceManager->forget('gemini');
Flux::toast(text: 'Gemini settings saved.', variant: 'success');
}
public function saveOpenAI(): void
{
$this->validate([
'openaiSecretKey' => 'required_if:openaiActive,true',
], [
'openaiSecretKey.required_if' => 'API key is required when the service is active.',
]);
(new UpdateOrCreateService)(
name: 'openai',
configuration: [
'secret_key' => $this->openaiSecretKey,
],
active: $this->openaiActive
);
$this->serviceManager->forget('openai');
Flux::toast(text: 'OpenAI settings saved.', variant: 'success');
}
#[Computed]
public function claudeModelsComputed(): array
{
return $this->claudeModels;
}
#[Computed]
public function geminiModelsComputed(): array
{
return $this->geminiModels;
}
/**
* Get all features grouped by category for entitlements display.
*/
#[Computed]
public function allFeatures(): array
{
return Feature::orderBy('category')
->orderBy('name')
->get()
->groupBy('category')
->toArray();
}
/**
* Get all user workspaces with subscription and cost information.
*/
#[Computed]
public function userWorkspaces(): array
{
$user = Auth::user();
if (! $user) {
return [];
}
$registry = app(AdminMenuRegistry::class);
$isHades = $user->isHades();
return $user->workspaces()
->orderBy('name')
->get()
->map(function (Workspace $workspace) use ($registry, $isHades) {
$subscription = $workspace->activeSubscription();
$services = $registry->getAllServiceItems($workspace, $isHades);
return [
'workspace' => $workspace,
'subscription' => $subscription,
'plan' => $subscription?->workspacePackage?->package?->name ?? 'Free',
'status' => $subscription?->status ?? 'inactive',
'renewsAt' => $subscription?->current_period_end,
'price' => $subscription?->workspacePackage?->package?->price ?? 0,
'currency' => $subscription?->workspacePackage?->package?->currency ?? 'GBP',
'services' => $services,
'serviceCount' => count($services),
];
})
->toArray();
}
public function render()
{
return view('hub::admin.account-usage')
->layout('hub::admin.layouts.app', ['title' => 'Usage & Billing']);
}
}

View file

@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace Website\Hub\View\Modal\Admin;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Str;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
use Spatie\Activitylog\Models\Activity;
/**
* Activity log viewer component.
*
* Displays paginated activity log for the current workspace.
*/
#[Title('Activity Log')]
#[Layout('hub::admin.layouts.app')]
class ActivityLog extends Component
{
use WithPagination;
#[Url]
public string $search = '';
#[Url]
public string $logName = '';
#[Url]
public string $event = '';
/**
* Get available log names for filtering.
*/
#[Computed]
public function logNames(): array
{
return Activity::query()
->distinct()
->pluck('log_name')
->filter()
->values()
->toArray();
}
/**
* Get available events for filtering.
*/
#[Computed]
public function events(): array
{
return Activity::query()
->distinct()
->pluck('event')
->filter()
->values()
->toArray();
}
/**
* Get paginated activity records.
*/
#[Computed]
public function activities(): LengthAwarePaginator
{
$user = auth()->user();
$workspace = $user?->defaultHostWorkspace();
$query = Activity::query()
->with(['causer', 'subject'])
->latest();
// Filter by workspace members if workspace exists
if ($workspace) {
$memberIds = $workspace->users->pluck('id');
$query->whereIn('causer_id', $memberIds);
}
// Filter by log name
if ($this->logName) {
$query->where('log_name', $this->logName);
}
// Filter by event
if ($this->event) {
$query->where('event', $this->event);
}
// Search in description
if ($this->search) {
$query->where('description', 'like', "%{$this->search}%");
}
return $query->paginate(20);
}
/**
* Clear all filters.
*/
public function clearFilters(): void
{
$this->search = '';
$this->logName = '';
$this->event = '';
$this->resetPage();
}
#[Computed]
public function logNameOptions(): array
{
$options = ['' => 'All logs'];
foreach ($this->logNames as $name) {
$options[$name] = Str::title($name);
}
return $options;
}
#[Computed]
public function eventOptions(): array
{
$options = ['' => 'All events'];
foreach ($this->events as $eventName) {
$options[$eventName] = Str::title($eventName);
}
return $options;
}
#[Computed]
public function activityItems(): array
{
return $this->activities->map(function ($activity) {
$item = [
'description' => $activity->description,
'event' => $activity->event ?? 'activity',
'timestamp' => $activity->created_at,
];
// Actor
if ($activity->causer) {
$item['actor'] = [
'name' => $activity->causer->name ?? 'User',
'initials' => substr($activity->causer->name ?? 'U', 0, 1),
];
}
// Subject
if ($activity->subject) {
$item['subject'] = [
'type' => class_basename($activity->subject_type),
'name' => $activity->subject->name
?? $activity->subject->title
?? $activity->subject->url
?? (string) $activity->subject_id,
];
}
// Changes diff
if ($activity->properties->has('old') && $activity->properties->has('new')) {
$item['changes'] = [
'old' => $activity->properties['old'],
'new' => $activity->properties['new'],
];
}
return $item;
})->all();
}
public function render()
{
return view('hub::admin.activity-log')
->layout('hub::admin.layouts.app', ['title' => 'Activity Log']);
}
}

View file

@ -0,0 +1,69 @@
<?php
namespace Website\Hub\View\Modal\Admin;
use Livewire\Component;
class Analytics extends Component
{
public array $metrics = [];
public array $chartData = [];
public function mount(): void
{
// Placeholder metrics
$this->metrics = [
[
'label' => 'Total Visitors',
'value' => '—',
'change' => null,
'icon' => 'users',
],
[
'label' => 'Page Views',
'value' => '—',
'change' => null,
'icon' => 'eye',
],
[
'label' => 'Bounce Rate',
'value' => '—',
'change' => null,
'icon' => 'arrow-right-from-bracket',
],
[
'label' => 'Avg. Session',
'value' => '—',
'change' => null,
'icon' => 'clock',
],
];
// Placeholder chart sections
$this->chartData = [
'visitors' => [
'title' => 'Visitors Over Time',
'description' => 'Daily unique visitors across all sites',
],
'pages' => [
'title' => 'Top Pages',
'description' => 'Most visited pages this period',
],
'sources' => [
'title' => 'Traffic Sources',
'description' => 'Where your visitors come from',
],
'devices' => [
'title' => 'Devices',
'description' => 'Device breakdown of your audience',
],
];
}
public function render()
{
return view('hub::admin.analytics')
->layout('hub::admin.layouts.app', ['title' => 'Analytics']);
}
}

View file

@ -0,0 +1,77 @@
<?php
namespace Website\Hub\View\Modal\Admin;
use Core\Mod\Tenant\Models\Feature;
use Livewire\Component;
class BoostPurchase extends Component
{
/**
* Available boost options from config.
*/
public array $boostOptions = [];
public function mount(): void
{
// Require authenticated user with a workspace
if (! auth()->check()) {
abort(403, 'Authentication required.');
}
// Get boost options from config
$addonMapping = config('services.blesta.addon_mapping', []);
$this->boostOptions = collect($addonMapping)->map(function ($config, $blestaId) {
$feature = Feature::where('code', $config['feature_code'])->first();
return [
'blesta_id' => $blestaId,
'feature_code' => $config['feature_code'],
'feature_name' => $feature?->name ?? $config['feature_code'],
'boost_type' => $config['boost_type'],
'limit_value' => $config['limit_value'] ?? null,
'duration_type' => $config['duration_type'],
'description' => $this->getBoostDescription($config),
];
})->values()->toArray();
}
protected function getBoostDescription(array $config): string
{
$type = $config['boost_type'];
$value = $config['limit_value'] ?? null;
$duration = $config['duration_type'];
$description = match ($type) {
'add_limit' => "+{$value} additional",
'unlimited' => 'Unlimited access',
'enable' => 'Feature enabled',
default => 'Boost',
};
$durationText = match ($duration) {
'cycle_bound' => 'until billing cycle ends',
'duration' => 'for limited time',
'permanent' => 'permanently',
default => '',
};
return trim("{$description} {$durationText}");
}
public function purchaseBoost(string $blestaId): void
{
// Redirect to Blesta for purchase
// TODO: Implement when Blesta is configured
$blestaUrl = config('services.blesta.url', 'https://billing.host.uk.com');
$this->redirect("{$blestaUrl}/order/addon/{$blestaId}");
}
public function render()
{
return view('hub::admin.boost-purchase')
->layout('hub::admin.layouts.app', ['title' => 'Purchase Boost']);
}
}

View file

@ -0,0 +1,53 @@
<?php
namespace Website\Hub\View\Modal\Admin;
use Livewire\Component;
class Console extends Component
{
public array $servers = [];
public ?int $selectedServer = null;
public function mount(): void
{
$this->servers = [
[
'id' => 1,
'name' => 'Bio (Production)',
'type' => 'WordPress',
'status' => 'online',
],
[
'id' => 2,
'name' => 'Social (Production)',
'type' => 'Laravel',
'status' => 'online',
],
[
'id' => 3,
'name' => 'Analytics (Production)',
'type' => 'Node.js',
'status' => 'online',
],
[
'id' => 4,
'name' => 'Host Hub (Development)',
'type' => 'Laravel',
'status' => 'online',
],
];
}
public function selectServer(int $serverId): void
{
$this->selectedServer = $serverId;
}
public function render()
{
return view('hub::admin.console')
->layout('hub::admin.layouts.app', ['title' => 'Console']);
}
}

View file

@ -0,0 +1,295 @@
<?php
namespace Website\Hub\View\Modal\Admin;
use Core\Mod\Tenant\Services\WorkspaceService;
use Illuminate\Pagination\LengthAwarePaginator;
use Livewire\Attributes\Computed;
use Livewire\Attributes\On;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
/**
* Content management component.
*
* Native content system - no longer uses WordPress.
*/
class Content extends Component
{
use WithPagination;
public string $tab = 'posts';
#[Url]
public string $search = '';
#[Url]
public string $status = '';
#[Url]
public string $sort = 'date';
#[Url]
public string $dir = 'desc';
public string $view = 'list';
public ?int $editingId = null;
public string $editTitle = '';
public string $editContent = '';
public string $editStatus = 'draft';
public string $editExcerpt = '';
public bool $showEditor = false;
public bool $isCreating = false;
public array $items = [];
public int $total = 0;
public int $perPage = 15;
public array $currentWorkspace = [];
protected WorkspaceService $workspaceService;
public function boot(WorkspaceService $workspaceService): void
{
$this->workspaceService = $workspaceService;
}
public function mount(string $workspace = 'main', string $type = 'posts'): void
{
$this->tab = $type;
// Set workspace from URL
$this->workspaceService->setCurrent($workspace);
$this->currentWorkspace = $this->workspaceService->current();
$this->loadContent();
}
#[On('workspace-changed')]
public function handleWorkspaceChange(string $workspace): void
{
$this->currentWorkspace = $this->workspaceService->current();
$this->resetPage();
$this->loadContent();
}
#[Computed]
public function stats(): array
{
$published = collect($this->items)->where('status', 'publish')->count();
$drafts = collect($this->items)->where('status', 'draft')->count();
return [
[
'title' => 'Total '.ucfirst($this->tab),
'value' => (string) $this->total,
'trend' => '+12%',
'trendUp' => true,
'icon' => $this->tab === 'posts' ? 'newspaper' : ($this->tab === 'pages' ? 'file-lines' : 'images'),
],
[
'title' => 'Published',
'value' => (string) $published,
'trend' => '+8%',
'trendUp' => true,
'icon' => 'check-circle',
],
[
'title' => 'Drafts',
'value' => (string) $drafts,
'trend' => '-3%',
'trendUp' => false,
'icon' => 'pencil',
],
[
'title' => 'This Week',
'value' => (string) collect($this->items)->filter(fn ($i) => \Carbon\Carbon::parse($i['date'] ?? $i['modified'] ?? now())->isCurrentWeek())->count(),
'trend' => '+24%',
'trendUp' => true,
'icon' => 'calendar',
],
];
}
#[Computed]
public function paginator(): LengthAwarePaginator
{
$page = $this->getPage();
return new LengthAwarePaginator(
items: array_slice($this->items, ($page - 1) * $this->perPage, $this->perPage),
total: $this->total,
perPage: $this->perPage,
currentPage: $page,
options: ['path' => request()->url()]
);
}
#[Computed]
public function rows(): array
{
return $this->paginator()->items();
}
public function loadContent(): void
{
// Load demo data - native content system to be implemented
$this->loadDemoData();
// Apply sorting
$this->applySorting();
}
protected function applySorting(): void
{
$items = collect($this->items);
$items = match ($this->sort) {
'title' => $items->sortBy(fn ($i) => $i['title']['rendered'] ?? '', SORT_REGULAR, $this->dir === 'desc'),
'status' => $items->sortBy('status', SORT_REGULAR, $this->dir === 'desc'),
'modified' => $items->sortBy('modified', SORT_REGULAR, $this->dir === 'desc'),
default => $items->sortBy('date', SORT_REGULAR, $this->dir === 'desc'),
};
$this->items = $items->values()->all();
}
protected function loadDemoData(): void
{
$workspaceName = $this->currentWorkspace['name'] ?? 'Host UK';
$workspaceSlug = $this->currentWorkspace['slug'] ?? 'main';
if ($this->tab === 'posts') {
$this->items = [];
for ($i = 1; $i <= 25; $i++) {
$this->items[] = [
'id' => $i,
'title' => ['rendered' => "{$workspaceName} Post #{$i}"],
'content' => ['rendered' => "<p>Content for post {$i} in {$workspaceName}.</p>"],
'status' => $i % 3 === 0 ? 'draft' : 'publish',
'date' => now()->subDays($i)->toIso8601String(),
'modified' => now()->subDays($i - 1)->toIso8601String(),
'excerpt' => ['rendered' => "Excerpt for post {$i}"],
];
}
$this->total = 25;
} elseif ($this->tab === 'pages') {
$pageNames = ['Home', 'About', 'Services', 'Contact', 'Privacy', 'Terms', 'FAQ', 'Blog', 'Portfolio', 'Team'];
$this->items = [];
foreach ($pageNames as $i => $name) {
$this->items[] = [
'id' => $i + 10,
'title' => ['rendered' => $name],
'content' => ['rendered' => "<p>{$workspaceName} {$name} page content.</p>"],
'status' => 'publish',
'date' => now()->subMonths($i)->toIso8601String(),
'modified' => now()->subDays($i)->toIso8601String(),
'excerpt' => ['rendered' => ''],
];
}
$this->total = count($pageNames);
} else {
$this->items = [];
for ($i = 1; $i <= 12; $i++) {
$this->items[] = [
'id' => 100 + $i,
'title' => ['rendered' => "{$workspaceSlug}-image-{$i}.jpg"],
'media_type' => 'image',
'source_url' => '/images/placeholder.jpg',
'date' => now()->subDays($i)->toIso8601String(),
];
}
$this->total = 12;
}
}
public function setSort(string $sort): void
{
if ($this->sort === $sort) {
$this->dir = $this->dir === 'asc' ? 'desc' : 'asc';
} else {
$this->sort = $sort;
$this->dir = 'desc';
}
$this->loadContent();
}
public function setStatus(string $status): void
{
$this->status = $status;
$this->resetPage();
$this->loadContent();
}
public function setView(string $view): void
{
$this->view = $view;
}
public function createNew(): void
{
$this->isCreating = true;
$this->editingId = null;
$this->editTitle = '';
$this->editContent = '';
$this->editStatus = 'draft';
$this->editExcerpt = '';
$this->showEditor = true;
}
public function edit(int $id): void
{
$this->isCreating = false;
$this->editingId = $id;
$item = collect($this->items)->firstWhere('id', $id);
if ($item) {
$this->editTitle = $item['title']['rendered'] ?? '';
$this->editContent = $item['content']['rendered'] ?? '';
$this->editStatus = $item['status'] ?? 'draft';
$this->editExcerpt = $item['excerpt']['rendered'] ?? '';
}
$this->showEditor = true;
}
public function save(): void
{
// Native content save - to be implemented
// For now, just close editor and dispatch event
$this->closeEditor();
$this->dispatch('content-saved');
}
public function delete(int $id): void
{
// Native content delete - to be implemented
// For demo, just remove from items
$this->items = array_values(array_filter($this->items, fn ($p) => $p['id'] !== $id));
$this->total = count($this->items);
}
public function closeEditor(): void
{
$this->showEditor = false;
$this->editingId = null;
$this->isCreating = false;
}
public function render()
{
return view('hub::admin.content')
->layout('hub::admin.layouts.app', ['title' => 'Content']);
}
}

View file

@ -0,0 +1,843 @@
<?php
namespace Website\Hub\View\Modal\Admin;
use Core\Mod\Content\Enums\ContentType;
use Core\Mod\Agentic\Services\AgenticManager;
use Core\Mod\Content\Models\ContentItem;
use Core\Mod\Content\Models\ContentMedia;
use Core\Mod\Content\Models\ContentRevision;
use Core\Mod\Content\Models\ContentTaxonomy;
use Core\Mod\Agentic\Models\Prompt;
use Core\Mod\Tenant\Models\Workspace;
use Core\Mod\Tenant\Services\EntitlementService;
use Illuminate\Support\Str;
use Livewire\Attributes\Computed;
use Livewire\Attributes\On;
use Livewire\Component;
use Livewire\WithFileUploads;
/**
* ContentEditor - Full-featured content editing component.
*
* Phase 2 of TASK-004: Content Editor Enhancements.
*
* Features:
* - Rich text editing with Flux Editor (AC7)
* - Media/image upload (AC8)
* - Category/tag management (AC9)
* - SEO fields (AC10)
* - Scheduling with publish_at (AC11)
* - Revision history (AC12)
*/
class ContentEditor extends Component
{
use WithFileUploads;
// Content data
public ?int $contentId = null;
public ?int $workspaceId = null;
public string $contentType = 'native';
public string $type = 'page';
public string $status = 'draft';
public string $title = '';
public string $slug = '';
public string $excerpt = '';
public string $content = '';
// Scheduling (AC11)
public ?string $publishAt = null;
public bool $isScheduled = false;
// SEO fields (AC10)
public string $seoTitle = '';
public string $seoDescription = '';
public string $seoKeywords = '';
public ?string $ogImage = null;
// Categories and tags (AC9)
public array $selectedCategories = [];
public array $selectedTags = [];
public string $newTag = '';
// Media (AC8)
public ?int $featuredMediaId = null;
public $featuredImageUpload = null;
// Revisions (AC12)
public bool $showRevisions = false;
public array $revisions = [];
// AI Command palette
public bool $showCommand = false;
public string $commandSearch = '';
public ?int $selectedPromptId = null;
public array $promptVariables = [];
public bool $aiProcessing = false;
public ?string $aiResult = null;
// Editor state
public bool $isDirty = false;
public ?string $lastSaved = null;
public int $revisionCount = 0;
// Sidebar state
public string $activeSidebar = 'settings'; // settings, seo, media, revisions
protected AgenticManager $ai;
protected EntitlementService $entitlements;
protected $rules = [
'title' => 'required|string|max:255',
'slug' => 'required|string|max:255',
'excerpt' => 'nullable|string|max:500',
'content' => 'required|string',
'type' => 'required|in:page,post',
'status' => 'required|in:draft,publish,pending,future,private',
'contentType' => 'required|in:native,hostuk,satellite,wordpress',
'publishAt' => 'nullable|date',
'seoTitle' => 'nullable|string|max:70',
'seoDescription' => 'nullable|string|max:160',
'seoKeywords' => 'nullable|string|max:255',
'featuredImageUpload' => 'nullable|image|max:5120', // 5MB max
];
public function boot(AgenticManager $ai, EntitlementService $entitlements): void
{
$this->ai = $ai;
$this->entitlements = $entitlements;
}
public function mount(): void
{
$workspace = request()->route('workspace', 'main');
$id = request()->route('id');
$contentType = request()->route('contentType', 'native');
$workspaceModel = Workspace::where('slug', $workspace)->first();
$this->workspaceId = $workspaceModel?->id;
$this->contentType = $contentType === 'hostuk' ? 'native' : $contentType;
if ($id) {
$this->loadContent((int) $id);
}
}
/**
* Load existing content for editing.
*/
public function loadContent(int $id): void
{
$item = ContentItem::with(['taxonomies', 'revisions'])->findOrFail($id);
$this->contentId = $item->id;
$this->workspaceId = $item->workspace_id;
$this->contentType = $item->content_type instanceof ContentType
? $item->content_type->value
: ($item->content_type ?? 'native');
$this->type = $item->type;
$this->status = $item->status;
$this->title = $item->title;
$this->slug = $item->slug;
$this->excerpt = $item->excerpt ?? '';
$this->content = $item->content_html ?? $item->content_markdown ?? '';
$this->lastSaved = $item->updated_at?->diffForHumans();
$this->revisionCount = $item->revision_count ?? 0;
// Scheduling
$this->publishAt = $item->publish_at?->format('Y-m-d\TH:i');
$this->isScheduled = $item->status === 'future' && $item->publish_at !== null;
// SEO
$seoMeta = $item->seo_meta ?? [];
$this->seoTitle = $seoMeta['title'] ?? '';
$this->seoDescription = $seoMeta['description'] ?? '';
$this->seoKeywords = $seoMeta['keywords'] ?? '';
$this->ogImage = $seoMeta['og_image'] ?? null;
// Taxonomies
$this->selectedCategories = $item->categories->pluck('id')->toArray();
$this->selectedTags = $item->tags->pluck('id')->toArray();
// Media
$this->featuredMediaId = $item->featured_media_id;
}
/**
* Get available categories for this workspace.
*/
#[Computed]
public function categories(): array
{
if (! $this->workspaceId) {
return [];
}
return ContentTaxonomy::where('workspace_id', $this->workspaceId)
->where('type', 'category')
->orderBy('name')
->get()
->toArray();
}
/**
* Get available tags for this workspace.
*/
#[Computed]
public function tags(): array
{
if (! $this->workspaceId) {
return [];
}
return ContentTaxonomy::where('workspace_id', $this->workspaceId)
->where('type', 'tag')
->orderBy('name')
->get()
->toArray();
}
/**
* Get available media for this workspace.
*/
#[Computed]
public function mediaLibrary(): array
{
if (! $this->workspaceId) {
return [];
}
return ContentMedia::where('workspace_id', $this->workspaceId)
->images()
->orderByDesc('created_at')
->take(20)
->get()
->toArray();
}
/**
* Get the featured media object.
*/
#[Computed]
public function featuredMedia(): ?ContentMedia
{
if (! $this->featuredMediaId) {
return null;
}
return ContentMedia::find($this->featuredMediaId);
}
/**
* Generate slug from title.
*/
public function updatedTitle(string $value): void
{
if (empty($this->slug) || $this->slug === Str::slug($this->title)) {
$this->slug = Str::slug($value);
}
$this->isDirty = true;
}
/**
* Mark as dirty when content changes.
*/
public function updatedContent(): void
{
$this->isDirty = true;
}
/**
* Handle scheduling toggle.
*/
public function updatedIsScheduled(bool $value): void
{
if ($value) {
$this->status = 'future';
if (empty($this->publishAt)) {
// Default to tomorrow at 9am
$this->publishAt = now()->addDay()->setTime(9, 0)->format('Y-m-d\TH:i');
}
} else {
if ($this->status === 'future') {
$this->status = 'draft';
}
$this->publishAt = null;
}
$this->isDirty = true;
}
/**
* Add a new tag.
*/
public function addTag(): void
{
if (empty($this->newTag) || ! $this->workspaceId) {
return;
}
$slug = Str::slug($this->newTag);
// Check if tag exists
$existing = ContentTaxonomy::where('workspace_id', $this->workspaceId)
->where('type', 'tag')
->where('slug', $slug)
->first();
if ($existing) {
if (! in_array($existing->id, $this->selectedTags)) {
$this->selectedTags[] = $existing->id;
}
} else {
// Create new tag
$tag = ContentTaxonomy::create([
'workspace_id' => $this->workspaceId,
'type' => 'tag',
'name' => $this->newTag,
'slug' => $slug,
]);
$this->selectedTags[] = $tag->id;
}
$this->newTag = '';
$this->isDirty = true;
}
/**
* Remove a tag.
*/
public function removeTag(int $tagId): void
{
$this->selectedTags = array_values(array_filter(
$this->selectedTags,
fn ($id) => $id !== $tagId
));
$this->isDirty = true;
}
/**
* Toggle a category.
*/
public function toggleCategory(int $categoryId): void
{
if (in_array($categoryId, $this->selectedCategories)) {
$this->selectedCategories = array_values(array_filter(
$this->selectedCategories,
fn ($id) => $id !== $categoryId
));
} else {
$this->selectedCategories[] = $categoryId;
}
$this->isDirty = true;
}
/**
* Set featured image from media library.
*/
public function setFeaturedMedia(int $mediaId): void
{
$this->featuredMediaId = $mediaId;
$this->isDirty = true;
}
/**
* Remove featured image.
*/
public function removeFeaturedMedia(): void
{
$this->featuredMediaId = null;
$this->isDirty = true;
}
/**
* Upload featured image.
*/
public function uploadFeaturedImage(): void
{
$this->validate([
'featuredImageUpload' => 'required|image|max:5120',
]);
if (! $this->workspaceId) {
$this->dispatch('notify', message: 'No workspace selected', type: 'error');
return;
}
// Store the file
$path = $this->featuredImageUpload->store('content-media', 'public');
// Create media record
$media = ContentMedia::create([
'workspace_id' => $this->workspaceId,
'type' => 'image',
'title' => pathinfo($this->featuredImageUpload->getClientOriginalName(), PATHINFO_FILENAME),
'source_url' => asset('storage/'.$path),
'alt_text' => $this->title,
'mime_type' => $this->featuredImageUpload->getMimeType(),
]);
$this->featuredMediaId = $media->id;
$this->featuredImageUpload = null;
$this->isDirty = true;
$this->dispatch('notify', message: 'Image uploaded', type: 'success');
}
/**
* Load revision history.
*/
public function loadRevisions(): void
{
if (! $this->contentId) {
$this->revisions = [];
return;
}
$this->revisions = ContentRevision::forContentItem($this->contentId)
->withoutAutosaves()
->latestFirst()
->with('user')
->take(20)
->get()
->toArray();
$this->showRevisions = true;
$this->activeSidebar = 'revisions';
}
/**
* Restore a revision.
*/
public function restoreRevision(int $revisionId): void
{
$revision = ContentRevision::findOrFail($revisionId);
if ($revision->content_item_id !== $this->contentId) {
$this->dispatch('notify', message: 'Invalid revision', type: 'error');
return;
}
// Load revision data into form
$this->title = $revision->title;
$this->excerpt = $revision->excerpt ?? '';
$this->content = $revision->content_html ?? $revision->content_markdown ?? '';
// Restore SEO if available
if ($revision->seo_meta) {
$this->seoTitle = $revision->seo_meta['title'] ?? '';
$this->seoDescription = $revision->seo_meta['description'] ?? '';
$this->seoKeywords = $revision->seo_meta['keywords'] ?? '';
}
$this->isDirty = true;
$this->showRevisions = false;
$this->dispatch('notify', message: "Restored revision #{$revision->revision_number}", type: 'success');
}
/**
* Save the content.
*/
public function save(string $changeType = ContentRevision::CHANGE_EDIT): void
{
$this->validate();
// Build SEO meta
$seoMeta = [
'title' => $this->seoTitle,
'description' => $this->seoDescription,
'keywords' => $this->seoKeywords,
'og_image' => $this->ogImage,
];
$data = [
'workspace_id' => $this->workspaceId,
'content_type' => $this->contentType,
'type' => $this->type,
'status' => $this->status,
'title' => $this->title,
'slug' => $this->slug,
'excerpt' => $this->excerpt,
'content_html' => $this->content,
'content_markdown' => $this->content,
'seo_meta' => $seoMeta,
'featured_media_id' => $this->featuredMediaId,
'publish_at' => $this->isScheduled && $this->publishAt ? $this->publishAt : null,
'last_edited_by' => auth()->id(),
'sync_status' => 'synced',
'synced_at' => now(),
];
$isNew = ! $this->contentId;
if ($this->contentId) {
$item = ContentItem::findOrFail($this->contentId);
$item->update($data);
} else {
$item = ContentItem::create($data);
$this->contentId = $item->id;
}
// Sync taxonomies
$taxonomyIds = array_merge($this->selectedCategories, $this->selectedTags);
$item->taxonomies()->sync($taxonomyIds);
// Create revision (except for autosaves on new content)
if (! $isNew || $changeType !== ContentRevision::CHANGE_AUTOSAVE) {
$item->createRevision(auth()->user(), $changeType);
$this->revisionCount = $item->fresh()->revision_count ?? 0;
}
$this->isDirty = false;
$this->lastSaved = 'just now';
$this->dispatch('content-saved', id: $item->id);
$this->dispatch('notify', message: 'Content saved successfully', type: 'success');
}
/**
* Autosave the content (called periodically).
*/
public function autosave(): void
{
if (! $this->isDirty || empty($this->title) || empty($this->content)) {
return;
}
$this->save(ContentRevision::CHANGE_AUTOSAVE);
}
/**
* Publish the content.
*/
public function publish(): void
{
$this->status = 'publish';
$this->isScheduled = false;
$this->publishAt = null;
$this->save(ContentRevision::CHANGE_PUBLISH);
}
/**
* Schedule the content.
*/
public function schedule(): void
{
if (empty($this->publishAt)) {
$this->dispatch('notify', message: 'Please set a publish date', type: 'error');
return;
}
$this->status = 'future';
$this->isScheduled = true;
$this->save(ContentRevision::CHANGE_SCHEDULE);
}
/**
* Get available prompts for AI command palette.
*/
#[Computed]
public function prompts(): array
{
$query = Prompt::active();
if ($this->commandSearch) {
$query->where(function ($q) {
$q->where('name', 'like', "%{$this->commandSearch}%")
->orWhere('description', 'like', "%{$this->commandSearch}%")
->orWhere('category', 'like', "%{$this->commandSearch}%");
});
}
return $query->orderBy('category')->orderBy('name')->get()->groupBy('category')->toArray();
}
/**
* Get quick AI actions.
*/
#[Computed]
public function quickActions(): array
{
return [
[
'name' => 'Improve writing',
'description' => 'Enhance clarity and flow',
'icon' => 'sparkles',
'prompt' => 'content-refiner',
'variables' => ['instruction' => 'Improve clarity, flow, and readability while maintaining the original meaning.'],
],
[
'name' => 'Fix grammar',
'description' => 'Correct spelling and grammar',
'icon' => 'check-circle',
'prompt' => 'content-refiner',
'variables' => ['instruction' => 'Fix any spelling, grammar, or punctuation errors using UK English conventions.'],
],
[
'name' => 'Make shorter',
'description' => 'Condense the content',
'icon' => 'arrows-pointing-in',
'prompt' => 'content-refiner',
'variables' => ['instruction' => 'Make this content more concise without losing important information.'],
],
[
'name' => 'Make longer',
'description' => 'Expand with more detail',
'icon' => 'arrows-pointing-out',
'prompt' => 'content-refiner',
'variables' => ['instruction' => 'Expand this content with more detail, examples, and explanation.'],
],
[
'name' => 'Generate SEO',
'description' => 'Create meta title and description',
'icon' => 'magnifying-glass',
'prompt' => 'seo-title-optimizer',
'variables' => [],
],
];
}
/**
* Open the AI command palette.
*/
public function openCommand(): void
{
$this->showCommand = true;
$this->commandSearch = '';
$this->selectedPromptId = null;
$this->promptVariables = [];
}
/**
* Close the AI command palette.
*/
public function closeCommand(): void
{
$this->showCommand = false;
$this->aiResult = null;
}
/**
* Select a prompt from the command palette.
*/
public function selectPrompt(int $promptId): void
{
$this->selectedPromptId = $promptId;
$prompt = Prompt::find($promptId);
if ($prompt && ! empty($prompt->variables)) {
foreach ($prompt->variables as $name => $config) {
$this->promptVariables[$name] = $config['default'] ?? '';
}
}
}
/**
* Execute a quick action.
*/
public function executeQuickAction(string $promptName, array $variables = []): void
{
$prompt = Prompt::where('name', $promptName)->first();
if (! $prompt) {
$this->dispatch('notify', message: 'Prompt not found', type: 'error');
return;
}
$variables['content'] = $this->content;
$this->runAiPrompt($prompt, $variables);
}
/**
* Execute the selected prompt.
*/
public function executePrompt(): void
{
if (! $this->selectedPromptId) {
return;
}
$prompt = Prompt::find($this->selectedPromptId);
if (! $prompt) {
return;
}
$variables = $this->promptVariables;
$variables['content'] = $this->content;
$variables['title'] = $this->title;
$variables['excerpt'] = $this->excerpt;
$this->runAiPrompt($prompt, $variables);
}
/**
* Run an AI prompt and display results.
*/
protected function runAiPrompt(Prompt $prompt, array $variables): void
{
$this->aiProcessing = true;
$this->aiResult = null;
try {
$workspace = $this->workspaceId ? Workspace::find($this->workspaceId) : null;
if ($workspace) {
$result = $this->entitlements->can($workspace, 'ai.credits');
if ($result->isDenied()) {
$this->dispatch('notify', message: $result->message, type: 'error');
$this->aiProcessing = false;
return;
}
}
$provider = $this->ai->provider($prompt->model);
$userPrompt = $this->interpolateVariables($prompt->user_template, $variables);
$response = $provider->generate(
$prompt->system_prompt,
$userPrompt,
$prompt->model_config ?? []
);
$this->aiResult = $response->content;
if ($workspace) {
$this->entitlements->recordUsage(
$workspace,
'ai.credits',
quantity: 1,
user: auth()->user(),
metadata: [
'prompt_id' => $prompt->id,
'model' => $response->model,
'tokens_input' => $response->inputTokens,
'tokens_output' => $response->outputTokens,
'estimated_cost' => $response->estimateCost(),
]
);
}
} catch (\Exception $e) {
$this->dispatch('notify', message: 'AI request failed: '.$e->getMessage(), type: 'error');
}
$this->aiProcessing = false;
}
/**
* Apply AI result to content.
*/
public function applyAiResult(): void
{
if ($this->aiResult) {
$this->content = $this->aiResult;
$this->isDirty = true;
$this->closeCommand();
$this->dispatch('notify', message: 'AI suggestions applied', type: 'success');
}
}
/**
* Insert AI result at cursor (append for now).
*/
public function insertAiResult(): void
{
if ($this->aiResult) {
$this->content .= "\n\n".$this->aiResult;
$this->isDirty = true;
$this->closeCommand();
$this->dispatch('notify', message: 'AI content inserted', type: 'success');
}
}
/**
* Interpolate template variables.
*/
protected function interpolateVariables(string $template, array $variables): string
{
foreach ($variables as $key => $value) {
if (is_array($value)) {
$value = implode(', ', $value);
}
$template = str_replace('{{'.$key.'}}', (string) $value, $template);
}
$template = preg_replace_callback(
'/\{\{#if\s+(\w+)\}\}(.*?)\{\{\/if\}\}/s',
function ($matches) use ($variables) {
$key = $matches[1];
$content = $matches[2];
return ! empty($variables[$key]) ? $content : '';
},
$template
);
$template = preg_replace_callback(
'/\{\{#each\s+(\w+)\}\}(.*?)\{\{\/each\}\}/s',
function ($matches) use ($variables) {
$key = $matches[1];
$content = $matches[2];
if (empty($variables[$key]) || ! is_array($variables[$key])) {
return '';
}
$result = '';
foreach ($variables[$key] as $item) {
$result .= str_replace('{{this}}', $item, $content);
}
return $result;
},
$template
);
return $template;
}
/**
* Handle keyboard shortcut to open command.
*/
#[On('open-ai-command')]
public function handleOpenCommand(): void
{
$this->openCommand();
}
public function render()
{
return view('hub::admin.content-editor')
->layout('hub::admin.layouts.app', [
'title' => $this->contentId ? 'Edit Content' : 'New Content',
]);
}
}

View file

@ -0,0 +1,520 @@
<?php
namespace Website\Hub\View\Modal\Admin;
use Core\Cdn\Services\BunnyCdnService;
use Livewire\Attributes\Computed;
use Livewire\Attributes\On;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
use Core\Mod\Content\Models\ContentItem;
use Core\Mod\Content\Models\ContentTaxonomy;
use Core\Mod\Content\Models\ContentWebhookLog;
use Core\Mod\Tenant\Models\Workspace;
use Core\Mod\Tenant\Services\WorkspaceService;
/**
* Content Manager component.
*
* Native content system - WordPress sync removed.
*/
class ContentManager extends Component
{
use WithPagination;
// View mode: dashboard, kanban, calendar, list, webhooks
public string $view = 'dashboard';
// Filters
#[Url]
public string $search = '';
#[Url]
public string $type = '';
#[Url]
public string $status = '';
#[Url]
public string $syncStatus = '';
#[Url]
public string $category = '';
#[Url]
public string $contentType = ''; // hostuk, satellite
// Sort
#[Url]
public string $sort = 'created_at';
#[Url]
public string $dir = 'desc';
public int $perPage = 20;
// Workspace (named currentWorkspace to avoid conflict with route parameter)
public ?Workspace $currentWorkspace = null;
public string $workspaceSlug = 'main';
// Selected item for preview/edit
public ?int $selectedItemId = null;
public bool $showPreview = false;
// Sync state
public bool $syncing = false;
public ?string $syncMessage = null;
protected WorkspaceService $workspaceService;
protected BunnyCdnService $cdn;
public function boot(
WorkspaceService $workspaceService,
BunnyCdnService $cdn
): void {
$this->workspaceService = $workspaceService;
$this->cdn = $cdn;
}
public function mount(string $workspace = 'main', string $view = 'dashboard'): void
{
$this->workspaceSlug = $workspace;
$this->view = $view;
$this->currentWorkspace = Workspace::where('slug', $workspace)->first();
if (! $this->currentWorkspace) {
session()->flash('error', 'Workspace not found');
}
// Update session so sidebar links stay on this workspace
$this->workspaceService->setCurrent($workspace);
}
#[On('workspace-changed')]
public function handleWorkspaceChange(string $workspace): void
{
$this->workspaceSlug = $workspace;
$this->currentWorkspace = Workspace::where('slug', $workspace)->first();
$this->resetPage();
}
/**
* Available tabs for navigation.
*/
#[Computed]
public function tabs(): array
{
return [
'dashboard' => [
'label' => __('hub::hub.content_manager.tabs.dashboard'),
'icon' => 'chart-pie',
'href' => route('hub.content-manager', ['workspace' => $this->workspaceSlug, 'view' => 'dashboard']),
],
'kanban' => [
'label' => __('hub::hub.content_manager.tabs.kanban'),
'icon' => 'view-columns',
'href' => route('hub.content-manager', ['workspace' => $this->workspaceSlug, 'view' => 'kanban']),
],
'calendar' => [
'label' => __('hub::hub.content_manager.tabs.calendar'),
'icon' => 'calendar',
'href' => route('hub.content-manager', ['workspace' => $this->workspaceSlug, 'view' => 'calendar']),
],
'list' => [
'label' => __('hub::hub.content_manager.tabs.list'),
'icon' => 'list-bullet',
'href' => route('hub.content-manager', ['workspace' => $this->workspaceSlug, 'view' => 'list']),
],
'webhooks' => [
'label' => __('hub::hub.content_manager.tabs.webhooks'),
'icon' => 'bolt',
'href' => route('hub.content-manager', ['workspace' => $this->workspaceSlug, 'view' => 'webhooks']),
],
];
}
/**
* Get content statistics for dashboard.
*/
#[Computed]
public function stats(): array
{
if (! $this->currentWorkspace) {
return $this->emptyStats();
}
$id = $this->currentWorkspace->id;
return [
'total' => ContentItem::forWorkspace($id)->count(),
'posts' => ContentItem::forWorkspace($id)->posts()->count(),
'pages' => ContentItem::forWorkspace($id)->pages()->count(),
'published' => ContentItem::forWorkspace($id)->published()->count(),
'drafts' => ContentItem::forWorkspace($id)->where('status', 'draft')->count(),
'synced' => ContentItem::forWorkspace($id)->where('sync_status', 'synced')->count(),
'pending' => ContentItem::forWorkspace($id)->where('sync_status', 'pending')->count(),
'failed' => ContentItem::forWorkspace($id)->where('sync_status', 'failed')->count(),
'stale' => ContentItem::forWorkspace($id)->where('sync_status', 'stale')->count(),
'categories' => ContentTaxonomy::forWorkspace($id)->categories()->count(),
'tags' => ContentTaxonomy::forWorkspace($id)->tags()->count(),
'webhooks_today' => ContentWebhookLog::forWorkspace($id)
->whereDate('created_at', today())
->count(),
'webhooks_failed' => ContentWebhookLog::forWorkspace($id)->failed()->count(),
// Content by source type
'wordpress' => ContentItem::forWorkspace($id)->wordpress()->count(),
'hostuk' => ContentItem::forWorkspace($id)->hostuk()->count(),
'satellite' => ContentItem::forWorkspace($id)->satellite()->count(),
];
}
/**
* Get chart data for content over time (Flux chart format).
*/
#[Computed]
public function chartData(): array
{
if (! $this->currentWorkspace) {
return [];
}
$days = 30;
$data = [];
for ($i = $days - 1; $i >= 0; $i--) {
$date = now()->subDays($i);
$data[] = [
'date' => $date->toDateString(),
'count' => ContentItem::forWorkspace($this->currentWorkspace->id)
->whereDate('created_at', $date)
->count(),
];
}
return $data;
}
/**
* Get content by type for donut chart.
*/
#[Computed]
public function contentByType(): array
{
if (! $this->currentWorkspace) {
return [];
}
return [
['label' => 'Posts', 'value' => ContentItem::forWorkspace($this->currentWorkspace->id)->posts()->count()],
['label' => 'Pages', 'value' => ContentItem::forWorkspace($this->currentWorkspace->id)->pages()->count()],
];
}
/**
* Get content grouped by status for Kanban board.
*/
#[Computed]
public function kanbanColumns(): array
{
if (! $this->currentWorkspace) {
return [];
}
$id = $this->currentWorkspace->id;
return [
[
'name' => 'Draft',
'status' => 'draft',
'color' => 'gray',
'items' => ContentItem::forWorkspace($id)
->where('status', 'draft')
->orderBy('wp_modified_at', 'desc')
->take(20)
->get(),
],
[
'name' => 'Pending Review',
'status' => 'pending',
'color' => 'yellow',
'items' => ContentItem::forWorkspace($id)
->where('status', 'pending')
->orderBy('wp_modified_at', 'desc')
->take(20)
->get(),
],
[
'name' => 'Scheduled',
'status' => 'future',
'color' => 'blue',
'items' => ContentItem::forWorkspace($id)
->where('status', 'future')
->orderBy('wp_created_at', 'asc')
->take(20)
->get(),
],
[
'name' => 'Published',
'status' => 'publish',
'color' => 'green',
'items' => ContentItem::forWorkspace($id)
->published()
->orderBy('wp_created_at', 'desc')
->take(20)
->get(),
],
];
}
/**
* Get scheduled content for calendar view.
*/
#[Computed]
public function calendarEvents(): array
{
if (! $this->currentWorkspace) {
return [];
}
return ContentItem::forWorkspace($this->currentWorkspace->id)
->whereNotNull('wp_created_at')
->orderBy('wp_created_at', 'desc')
->take(100)
->get()
->map(fn ($item) => [
'id' => $item->id,
'title' => $item->title,
'date' => $item->wp_created_at?->format('Y-m-d'),
'type' => $item->type,
'status' => $item->status,
'color' => $item->status_color,
])
->toArray();
}
/**
* Get paginated content for list view.
*/
#[Computed]
public function content()
{
if (! $this->currentWorkspace) {
// Return empty paginator instead of collection for Flux table compatibility
return ContentItem::query()->whereRaw('1=0')->paginate($this->perPage);
}
$query = ContentItem::forWorkspace($this->currentWorkspace->id)
->with(['author', 'categories', 'tags']);
// Apply filters
if ($this->search) {
$query->where(function ($q) {
$q->where('title', 'like', "%{$this->search}%")
->orWhere('slug', 'like', "%{$this->search}%")
->orWhere('excerpt', 'like', "%{$this->search}%");
});
}
if ($this->type) {
$query->where('type', $this->type);
}
if ($this->status) {
$query->where('status', $this->status);
}
if ($this->syncStatus) {
$query->where('sync_status', $this->syncStatus);
}
if ($this->category) {
$query->whereHas('categories', function ($q) {
$q->where('slug', $this->category);
});
}
if ($this->contentType) {
$query->where('content_type', $this->contentType);
}
// Apply sorting
$query->orderBy($this->sort, $this->dir);
return $query->paginate($this->perPage);
}
/**
* Get categories for filter dropdown.
*/
#[Computed]
public function categories(): array
{
if (! $this->currentWorkspace) {
return [];
}
return ContentTaxonomy::forWorkspace($this->currentWorkspace->id)
->categories()
->orderBy('name')
->pluck('name', 'slug')
->toArray();
}
/**
* Get recent webhook logs.
*/
#[Computed]
public function webhookLogs()
{
if (! $this->currentWorkspace) {
// Return empty paginator instead of collection for Flux table compatibility
return ContentWebhookLog::query()->whereRaw('1=0')->paginate($this->perPage);
}
return ContentWebhookLog::forWorkspace($this->currentWorkspace->id)
->orderBy('created_at', 'desc')
->paginate($this->perPage);
}
/**
* Get the selected item for preview.
*/
#[Computed]
public function selectedItem(): ?ContentItem
{
if (! $this->selectedItemId) {
return null;
}
return ContentItem::with(['author', 'categories', 'tags', 'featuredMedia'])
->find($this->selectedItemId);
}
/**
* Trigger full sync for workspace.
*
* Note: WordPress sync removed - native content system.
*/
public function syncAll(): void
{
if (! $this->currentWorkspace) {
return;
}
$this->syncMessage = 'Native content system - external sync not required';
}
/**
* Purge CDN cache for workspace.
*/
public function purgeCache(): void
{
if (! $this->currentWorkspace) {
return;
}
$success = $this->cdn->purgeWorkspace($this->currentWorkspace->slug);
if ($success) {
$this->syncMessage = 'CDN cache purged successfully';
} else {
$this->syncMessage = 'Failed to purge CDN cache';
}
}
/**
* Select an item for preview.
*/
public function selectItem(int $id): void
{
$this->selectedItemId = $id;
$this->dispatch('modal-show', name: 'content-preview');
}
/**
* Close the preview panel.
*/
public function closePreview(): void
{
$this->selectedItemId = null;
$this->dispatch('modal-close', name: 'content-preview');
}
/**
* Set the sort column.
*/
public function setSort(string $column): void
{
if ($this->sort === $column) {
$this->dir = $this->dir === 'asc' ? 'desc' : 'asc';
} else {
$this->sort = $column;
$this->dir = 'desc';
}
}
/**
* Clear all filters.
*/
public function clearFilters(): void
{
$this->search = '';
$this->type = '';
$this->status = '';
$this->syncStatus = '';
$this->category = '';
$this->contentType = '';
$this->resetPage();
}
/**
* Retry a failed webhook.
*
* Note: WordPress webhooks removed - native content system.
*/
public function retryWebhook(int $logId): void
{
$log = ContentWebhookLog::find($logId);
if ($log && $log->status === 'failed') {
$log->update(['status' => 'pending', 'error_message' => null]);
$this->syncMessage = 'Webhook marked for retry';
}
}
protected function emptyStats(): array
{
return [
'total' => 0,
'posts' => 0,
'pages' => 0,
'published' => 0,
'drafts' => 0,
'synced' => 0,
'pending' => 0,
'failed' => 0,
'stale' => 0,
'categories' => 0,
'tags' => 0,
'webhooks_today' => 0,
'webhooks_failed' => 0,
'wordpress' => 0,
'hostuk' => 0,
'satellite' => 0,
];
}
public function render()
{
return view('hub::admin.content-manager')
->layout('hub::admin.layouts.app', [
'title' => 'Content Manager',
'workspace' => $this->currentWorkspace,
]);
}
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Website\Hub\View\Modal\Admin;
use Illuminate\Contracts\View\View;
use Livewire\Attributes\Title;
use Livewire\Component;
/**
* Dashboard - Simple hub landing page.
*/
#[Title('Dashboard')]
class Dashboard extends Component
{
public function render(): View
{
return view('hub::admin.dashboard')
->layout('hub::admin.layouts.app', ['title' => 'Dashboard']);
}
}

View file

@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace Website\Hub\View\Modal\Admin;
use Core\Mod\Tenant\Models\Workspace;
use Core\Mod\Tenant\Services\WorkspaceService;
use Flux\Flux;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('hub::admin.layouts.app')]
class Databases extends Component
{
public ?Workspace $workspace = null;
// WP Connector settings
public bool $wpConnectorEnabled = false;
public string $wpConnectorUrl = '';
public bool $testingConnection = false;
public ?string $testResult = null;
public bool $testSuccess = false;
// Internal WordPress health
public array $internalWpHealth = [];
public bool $loadingHealth = true;
public function mount(WorkspaceService $workspaceService): void
{
if (! auth()->user()?->isHades()) {
abort(403, 'Hades access required');
}
$slug = $workspaceService->currentSlug();
$this->workspace = Workspace::where('slug', $slug)->first();
if ($this->workspace) {
$this->wpConnectorEnabled = $this->workspace->wp_connector_enabled ?? false;
$this->wpConnectorUrl = $this->workspace->wp_connector_url ?? '';
}
$this->loadInternalWordPressHealth();
}
#[Computed]
public function webhookUrl(): string
{
return $this->workspace?->wp_connector_webhook_url ?? '';
}
#[Computed]
public function webhookSecret(): string
{
return $this->workspace?->wp_connector_secret ?? '';
}
#[Computed]
public function isWpConnectorVerified(): bool
{
return $this->workspace?->wp_connector_verified_at !== null;
}
#[Computed]
public function wpConnectorLastSync(): ?string
{
return $this->workspace?->wp_connector_last_sync?->diffForHumans();
}
public function loadInternalWordPressHealth(): void
{
$this->loadingHealth = true;
// Cache health check for 5 minutes
$this->internalWpHealth = Cache::remember('internal_wp_health', 300, function () {
$health = [
'status' => 'unknown',
'url' => config('services.wordpress.url', 'https://hestia.host.uk.com'),
'api_available' => false,
'version' => null,
'post_count' => null,
'page_count' => null,
'last_check' => now()->toIso8601String(),
];
try {
$response = Http::timeout(5)->get($health['url'].'/wp-json/wp/v2');
if ($response->successful()) {
$health['api_available'] = true;
$health['status'] = 'healthy';
// Get post count
$postsResponse = Http::timeout(5)->head($health['url'].'/wp-json/wp/v2/posts');
if ($postsResponse->successful()) {
$health['post_count'] = (int) $postsResponse->header('X-WP-Total', 0);
}
// Get page count
$pagesResponse = Http::timeout(5)->head($health['url'].'/wp-json/wp/v2/pages');
if ($pagesResponse->successful()) {
$health['page_count'] = (int) $pagesResponse->header('X-WP-Total', 0);
}
} else {
$health['status'] = 'degraded';
}
} catch (\Exception $e) {
$health['status'] = 'offline';
$health['error'] = $e->getMessage();
}
return $health;
});
$this->loadingHealth = false;
}
public function refreshInternalHealth(): void
{
Cache::forget('internal_wp_health');
$this->loadInternalWordPressHealth();
Flux::toast('Health check refreshed');
}
public function saveWpConnector(): void
{
if (! $this->workspace) {
Flux::toast('No workspace selected', variant: 'danger');
return;
}
$this->validate([
'wpConnectorUrl' => 'nullable|url',
]);
if ($this->wpConnectorEnabled && empty($this->wpConnectorUrl)) {
Flux::toast('WordPress URL is required when connector is enabled', variant: 'danger');
return;
}
if ($this->wpConnectorEnabled) {
$this->workspace->enableWpConnector($this->wpConnectorUrl);
Flux::toast('WordPress connector enabled');
} else {
$this->workspace->disableWpConnector();
Flux::toast('WordPress connector disabled');
}
$this->workspace->refresh();
}
public function regenerateSecret(): void
{
if (! $this->workspace) {
return;
}
$this->workspace->generateWpConnectorSecret();
$this->workspace->refresh();
Flux::toast('Webhook secret regenerated. Update the secret in your WordPress plugin.');
}
public function testWpConnection(): void
{
$this->testingConnection = true;
$this->testResult = null;
if (empty($this->workspace?->wp_connector_url)) {
$this->testResult = 'WordPress URL is not configured';
$this->testSuccess = false;
$this->testingConnection = false;
return;
}
try {
$response = Http::timeout(10)->get(
$this->workspace->wp_connector_url.'/wp-json/wp/v2'
);
if ($response->successful()) {
$this->testResult = 'Connected to WordPress REST API';
$this->testSuccess = true;
$this->workspace->markWpConnectorVerified();
} else {
$this->testResult = 'WordPress returned HTTP '.$response->status();
$this->testSuccess = false;
}
} catch (\Exception $e) {
$this->testResult = 'Connection failed: '.$e->getMessage();
$this->testSuccess = false;
}
$this->testingConnection = false;
$this->workspace->refresh();
}
public function copyToClipboard(string $value): void
{
$this->dispatch('copy-to-clipboard', text: $value);
Flux::toast('Copied to clipboard');
}
public function render(): View
{
return view('hub::admin.databases');
}
}

View file

@ -0,0 +1,274 @@
<?php
declare(strict_types=1);
namespace Website\Hub\View\Modal\Admin;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Redis;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Title('Deployments & System Status')]
class Deployments extends Component
{
public bool $refreshing = false;
public function mount(): void
{
$this->checkHadesAccess();
}
#[Computed]
public function services(): array
{
return Cache::remember('admin.deployments.services', 60, function () {
return [
$this->checkDatabase(),
$this->checkRedis(),
$this->checkQueue(),
$this->checkStorage(),
];
});
}
#[Computed]
public function gitInfo(): array
{
return Cache::remember('admin.deployments.git', 300, function () {
$info = [
'branch' => 'unknown',
'commit' => 'unknown',
'message' => 'unknown',
'author' => 'unknown',
'date' => null,
];
try {
// Get current branch
$branchResult = Process::path(base_path())->run('git rev-parse --abbrev-ref HEAD');
if ($branchResult->successful()) {
$info['branch'] = trim($branchResult->output());
}
// Get latest commit info
$commitResult = Process::path(base_path())->run('git log -1 --format="%H|%s|%an|%ai"');
if ($commitResult->successful()) {
$parts = explode('|', trim($commitResult->output()));
if (count($parts) >= 4) {
$info['commit'] = substr($parts[0], 0, 8);
$info['message'] = $parts[1];
$info['author'] = $parts[2];
$info['date'] = \Carbon\Carbon::parse($parts[3])->diffForHumans();
}
}
} catch (\Exception $e) {
// Git not available or not a git repo
}
return $info;
});
}
#[Computed]
public function recentCommits(): array
{
return Cache::remember('admin.deployments.commits', 300, function () {
$commits = [];
try {
$result = Process::path(base_path())->run('git log -10 --format="%H|%s|%an|%ai"');
if ($result->successful()) {
foreach (explode("\n", trim($result->output())) as $line) {
$parts = explode('|', $line);
if (count($parts) >= 4) {
$commits[] = [
'hash' => substr($parts[0], 0, 8),
'message' => \Illuminate\Support\Str::limit($parts[1], 60),
'author' => $parts[2],
'date' => \Carbon\Carbon::parse($parts[3])->diffForHumans(),
];
}
}
}
} catch (\Exception $e) {
// Git not available
}
return $commits;
});
}
#[Computed]
public function stats(): array
{
return [
[
'label' => 'Database',
'value' => $this->checkDatabase()['status'] === 'healthy' ? 'Online' : 'Offline',
'icon' => 'circle-stack',
'color' => $this->checkDatabase()['status'] === 'healthy' ? 'green' : 'red',
],
[
'label' => 'Redis',
'value' => $this->checkRedis()['status'] === 'healthy' ? 'Online' : 'Offline',
'icon' => 'bolt',
'color' => $this->checkRedis()['status'] === 'healthy' ? 'green' : 'red',
],
[
'label' => 'Queue',
'value' => $this->checkQueue()['status'] === 'healthy' ? 'Active' : 'Inactive',
'icon' => 'queue-list',
'color' => $this->checkQueue()['status'] === 'healthy' ? 'green' : 'amber',
],
[
'label' => 'Storage',
'value' => $this->checkStorage()['details']['free'] ?? 'N/A',
'icon' => 'server',
'color' => 'blue',
],
];
}
public function refresh(): void
{
$this->refreshing = true;
Cache::forget('admin.deployments.services');
Cache::forget('admin.deployments.git');
Cache::forget('admin.deployments.commits');
// Force recompute
unset($this->services);
unset($this->gitInfo);
unset($this->recentCommits);
unset($this->stats);
$this->refreshing = false;
$this->dispatch('notify', message: 'System status refreshed');
}
public function clearCache(): void
{
Cache::flush();
$this->dispatch('notify', message: 'Application cache cleared');
}
private function checkDatabase(): array
{
try {
DB::connection()->getPdo();
$version = DB::selectOne('SELECT VERSION() as version');
return [
'name' => 'Database (MariaDB)',
'status' => 'healthy',
'icon' => 'circle-stack',
'details' => [
'version' => $version->version ?? 'Unknown',
'connection' => config('database.default'),
'database' => config('database.connections.'.config('database.default').'.database'),
],
];
} catch (\Exception $e) {
return [
'name' => 'Database (MariaDB)',
'status' => 'unhealthy',
'icon' => 'circle-stack',
'error' => $e->getMessage(),
];
}
}
private function checkRedis(): array
{
try {
$redis = Redis::connection();
$info = $redis->info();
return [
'name' => 'Redis',
'status' => 'healthy',
'icon' => 'bolt',
'details' => [
'version' => $info['redis_version'] ?? 'Unknown',
'memory' => $info['used_memory_human'] ?? 'Unknown',
'clients' => $info['connected_clients'] ?? 0,
'uptime' => isset($info['uptime_in_days']) ? $info['uptime_in_days'].' days' : 'Unknown',
],
];
} catch (\Exception $e) {
return [
'name' => 'Redis',
'status' => 'unhealthy',
'icon' => 'bolt',
'error' => $e->getMessage(),
];
}
}
private function checkQueue(): array
{
try {
$pendingJobs = DB::table('jobs')->count();
$failedJobs = DB::table('failed_jobs')->count();
return [
'name' => 'Queue Workers',
'status' => 'healthy',
'icon' => 'queue-list',
'details' => [
'driver' => config('queue.default'),
'pending' => $pendingJobs,
'failed' => $failedJobs,
],
];
} catch (\Exception $e) {
return [
'name' => 'Queue Workers',
'status' => 'unknown',
'icon' => 'queue-list',
'error' => 'Could not check queue status',
];
}
}
private function checkStorage(): array
{
$storagePath = storage_path();
$freeBytes = disk_free_space($storagePath);
$totalBytes = disk_total_space($storagePath);
$freeGb = $freeBytes ? round($freeBytes / 1024 / 1024 / 1024, 1) : 0;
$totalGb = $totalBytes ? round($totalBytes / 1024 / 1024 / 1024, 1) : 0;
$usedPercent = $totalBytes ? round((($totalBytes - $freeBytes) / $totalBytes) * 100) : 0;
return [
'name' => 'Storage',
'status' => $usedPercent < 90 ? 'healthy' : 'warning',
'icon' => 'server',
'details' => [
'free' => $freeGb.' GB',
'total' => $totalGb.' GB',
'used_percent' => $usedPercent.'%',
],
];
}
private function checkHadesAccess(): void
{
if (! auth()->user()?->isHades()) {
abort(403, 'Hades access required');
}
}
public function render(): View
{
return view('hub::admin.deployments')
->layout('hub::admin.layouts.app', ['title' => 'Deployments & System Status']);
}
}

View file

@ -0,0 +1,534 @@
<?php
declare(strict_types=1);
namespace Website\Hub\View\Modal\Admin\Entitlement;
use Illuminate\Support\Str;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
use Core\Mod\Tenant\Models\Boost;
use Core\Mod\Tenant\Models\Feature;
use Core\Mod\Tenant\Models\Package;
use Core\Mod\Tenant\Models\WorkspacePackage;
#[Title('Entitlements')]
#[Layout('hub::admin.layouts.app')]
class Dashboard extends Component
{
use WithPagination;
#[Url]
public string $tab = 'overview';
// Package form
public bool $showPackageModal = false;
public ?int $editingPackageId = null;
public string $packageCode = '';
public string $packageName = '';
public string $packageDescription = '';
public string $packageIcon = 'box';
public string $packageColor = 'blue';
public int $packageSortOrder = 0;
public bool $packageIsStackable = true;
public bool $packageIsBasePackage = false;
public bool $packageIsActive = true;
public bool $packageIsPublic = true;
// Feature form
public bool $showFeatureModal = false;
public ?int $editingFeatureId = null;
public string $featureCode = '';
public string $featureName = '';
public string $featureDescription = '';
public string $featureCategory = '';
public string $featureType = 'boolean';
public string $featureResetType = 'none';
public ?int $featureRollingDays = null;
public ?int $featureParentId = null;
public int $featureSortOrder = 0;
public bool $featureIsActive = true;
// Features assignment
public bool $showFeaturesModal = false;
public array $selectedFeatures = [];
public function mount(?string $tab = null): void
{
if (! auth()->user()?->isHades()) {
abort(403, 'Hades tier required for entitlement management.');
}
if ($tab && in_array($tab, ['overview', 'packages', 'features'])) {
$this->tab = $tab;
}
}
public function setTab(string $tab): void
{
if (in_array($tab, ['overview', 'packages', 'features'])) {
$this->tab = $tab;
$this->resetPage();
}
}
// ─────────────────────────────────────────────────────────────
// Overview Stats
// ─────────────────────────────────────────────────────────────
#[Computed]
public function stats(): array
{
return [
'packages' => [
'total' => Package::count(),
'active' => Package::where('is_active', true)->count(),
'public' => Package::where('is_public', true)->count(),
'base' => Package::where('is_base_package', true)->count(),
],
'features' => [
'total' => Feature::count(),
'active' => Feature::where('is_active', true)->count(),
'boolean' => Feature::where('type', 'boolean')->count(),
'limit' => Feature::where('type', 'limit')->count(),
],
'assignments' => [
'workspace_packages' => WorkspacePackage::where('status', 'active')->count(),
'active_boosts' => Boost::where('status', 'active')->count(),
],
'categories' => Feature::whereNotNull('category')
->distinct()
->pluck('category')
->toArray(),
];
}
// ─────────────────────────────────────────────────────────────
// Packages
// ─────────────────────────────────────────────────────────────
#[Computed]
public function packages()
{
return Package::withCount('features')
->orderBy('sort_order')
->orderBy('name')
->paginate(15);
}
public function openCreatePackage(): void
{
$this->resetPackageForm();
$this->showPackageModal = true;
}
public function openEditPackage(int $id): void
{
$package = Package::findOrFail($id);
$this->editingPackageId = $id;
$this->packageCode = $package->code;
$this->packageName = $package->name;
$this->packageDescription = $package->description ?? '';
$this->packageIcon = $package->icon ?? 'box';
$this->packageColor = $package->color ?? 'blue';
$this->packageSortOrder = $package->sort_order;
$this->packageIsStackable = $package->is_stackable;
$this->packageIsBasePackage = $package->is_base_package;
$this->packageIsActive = $package->is_active;
$this->packageIsPublic = $package->is_public;
$this->showPackageModal = true;
}
public function savePackage(): void
{
$this->validate([
'packageCode' => ['required', 'string', 'max:50', $this->editingPackageId
? 'unique:entitlement_packages,code,'.$this->editingPackageId
: 'unique:entitlement_packages,code'],
'packageName' => ['required', 'string', 'max:100'],
'packageDescription' => ['nullable', 'string', 'max:500'],
]);
$data = [
'code' => $this->packageCode,
'name' => $this->packageName,
'description' => $this->packageDescription ?: null,
'icon' => $this->packageIcon ?: null,
'color' => $this->packageColor ?: null,
'sort_order' => $this->packageSortOrder,
'is_stackable' => $this->packageIsStackable,
'is_base_package' => $this->packageIsBasePackage,
'is_active' => $this->packageIsActive,
'is_public' => $this->packageIsPublic,
];
if ($this->editingPackageId) {
Package::findOrFail($this->editingPackageId)->update($data);
session()->flash('success', 'Package updated.');
} else {
Package::create($data);
session()->flash('success', 'Package created.');
}
$this->closePackageModal();
unset($this->packages);
unset($this->stats);
}
public function deletePackage(int $id): void
{
$package = Package::findOrFail($id);
if ($package->workspacePackages()->exists()) {
session()->flash('error', 'Cannot delete package with active assignments.');
return;
}
$package->delete();
session()->flash('success', 'Package deleted.');
unset($this->packages);
unset($this->stats);
}
public function openAssignFeatures(int $id): void
{
$this->editingPackageId = $id;
$package = Package::with('features')->findOrFail($id);
$this->selectedFeatures = [];
foreach ($package->features as $feature) {
$this->selectedFeatures[$feature->id] = [
'enabled' => true,
'limit' => $feature->pivot->limit_value,
];
}
$this->showFeaturesModal = true;
}
public function toggleFeature(int $featureId): void
{
if (isset($this->selectedFeatures[$featureId])) {
$this->selectedFeatures[$featureId]['enabled'] = ! $this->selectedFeatures[$featureId]['enabled'];
} else {
$this->selectedFeatures[$featureId] = [
'enabled' => true,
'limit' => null,
];
}
}
public function saveFeatures(): void
{
$package = Package::findOrFail($this->editingPackageId);
$syncData = [];
foreach ($this->selectedFeatures as $featureId => $config) {
if (! empty($config['enabled'])) {
$syncData[$featureId] = [
'limit_value' => isset($config['limit']) && $config['limit'] !== ''
? (int) $config['limit']
: null,
];
}
}
$package->features()->sync($syncData);
session()->flash('success', 'Package features updated.');
$this->showFeaturesModal = false;
unset($this->packages);
}
public function closePackageModal(): void
{
$this->showPackageModal = false;
$this->resetPackageForm();
}
protected function resetPackageForm(): void
{
$this->editingPackageId = null;
$this->packageCode = '';
$this->packageName = '';
$this->packageDescription = '';
$this->packageIcon = 'box';
$this->packageColor = 'blue';
$this->packageSortOrder = 0;
$this->packageIsStackable = true;
$this->packageIsBasePackage = false;
$this->packageIsActive = true;
$this->packageIsPublic = true;
}
// ─────────────────────────────────────────────────────────────
// Features
// ─────────────────────────────────────────────────────────────
#[Computed]
public function features()
{
return Feature::with('parent')
->orderBy('category')
->orderBy('sort_order')
->paginate(20);
}
#[Computed]
public function allFeatures()
{
return Feature::active()
->orderBy('category')
->orderBy('sort_order')
->get()
->groupBy('category');
}
#[Computed]
public function parentFeatures()
{
return Feature::root()
->where('type', 'limit')
->get();
}
#[Computed]
public function featureCategories()
{
return Feature::whereNotNull('category')
->distinct()
->pluck('category');
}
public function openCreateFeature(): void
{
$this->resetFeatureForm();
$this->showFeatureModal = true;
}
public function openEditFeature(int $id): void
{
$feature = Feature::findOrFail($id);
$this->editingFeatureId = $id;
$this->featureCode = $feature->code;
$this->featureName = $feature->name;
$this->featureDescription = $feature->description ?? '';
$this->featureCategory = $feature->category ?? '';
$this->featureType = $feature->type;
$this->featureResetType = $feature->reset_type;
$this->featureRollingDays = $feature->rolling_window_days;
$this->featureParentId = $feature->parent_feature_id;
$this->featureSortOrder = $feature->sort_order;
$this->featureIsActive = $feature->is_active;
$this->showFeatureModal = true;
}
public function saveFeature(): void
{
$this->validate([
'featureCode' => ['required', 'string', 'max:100', $this->editingFeatureId
? 'unique:entitlement_features,code,'.$this->editingFeatureId
: 'unique:entitlement_features,code'],
'featureName' => ['required', 'string', 'max:100'],
'featureDescription' => ['nullable', 'string', 'max:500'],
'featureCategory' => ['nullable', 'string', 'max:50'],
'featureType' => ['required', 'in:boolean,limit,unlimited'],
'featureResetType' => ['required', 'in:none,monthly,rolling'],
]);
$data = [
'code' => $this->featureCode,
'name' => $this->featureName,
'description' => $this->featureDescription ?: null,
'category' => $this->featureCategory ?: null,
'type' => $this->featureType,
'reset_type' => $this->featureResetType,
'rolling_window_days' => $this->featureResetType === 'rolling' ? $this->featureRollingDays : null,
'parent_feature_id' => $this->featureParentId ?: null,
'sort_order' => $this->featureSortOrder,
'is_active' => $this->featureIsActive,
];
if ($this->editingFeatureId) {
Feature::findOrFail($this->editingFeatureId)->update($data);
session()->flash('success', 'Feature updated.');
} else {
Feature::create($data);
session()->flash('success', 'Feature created.');
}
$this->closeFeatureModal();
unset($this->features);
unset($this->allFeatures);
unset($this->stats);
}
public function deleteFeature(int $id): void
{
$feature = Feature::findOrFail($id);
if ($feature->packages()->exists()) {
session()->flash('error', 'Cannot delete feature assigned to packages.');
return;
}
if ($feature->children()->exists()) {
session()->flash('error', 'Cannot delete feature with children.');
return;
}
$feature->delete();
session()->flash('success', 'Feature deleted.');
unset($this->features);
unset($this->allFeatures);
unset($this->stats);
}
public function closeFeatureModal(): void
{
$this->showFeatureModal = false;
$this->resetFeatureForm();
}
protected function resetFeatureForm(): void
{
$this->editingFeatureId = null;
$this->featureCode = '';
$this->featureName = '';
$this->featureDescription = '';
$this->featureCategory = '';
$this->featureType = 'boolean';
$this->featureResetType = 'none';
$this->featureRollingDays = null;
$this->featureParentId = null;
$this->featureSortOrder = 0;
$this->featureIsActive = true;
}
// ─────────────────────────────────────────────────────────────
// Table Helpers
// ─────────────────────────────────────────────────────────────
#[Computed]
public function packageTableRows(): array
{
return $this->packages->map(function ($p) {
$lines = [['bold' => $p->name]];
if ($p->description) {
$lines[] = ['muted' => Str::limit($p->description, 50)];
}
$typeBadge = match (true) {
$p->is_base_package => ['badge' => 'Base', 'color' => 'purple'],
$p->is_stackable => ['badge' => 'Addon', 'color' => 'blue'],
default => ['badge' => 'Standard', 'color' => 'gray'],
};
$statusLines = [];
$statusLines[] = ['badge' => $p->is_active ? 'Active' : 'Inactive', 'color' => $p->is_active ? 'green' : 'gray'];
if ($p->is_public) {
$statusLines[] = ['badge' => 'Public', 'color' => 'sky'];
}
return [
[
'icon' => $p->icon ?? 'box',
'iconColor' => $p->color ?? 'gray',
'lines' => $lines,
],
['mono' => $p->code],
['badge' => $p->features_count.' features', 'color' => 'gray'],
$typeBadge,
['lines' => $statusLines],
[
'actions' => [
['icon' => 'puzzle-piece', 'click' => "openAssignFeatures({$p->id})", 'title' => 'Assign features'],
['icon' => 'pencil', 'click' => "openEditPackage({$p->id})", 'title' => 'Edit'],
['icon' => 'trash', 'click' => "deletePackage({$p->id})", 'confirm' => 'Delete this package?', 'title' => 'Delete', 'class' => 'text-red-600'],
],
],
];
})->all();
}
#[Computed]
public function featureTableRows(): array
{
$typeColors = [
'boolean' => 'gray',
'limit' => 'blue',
'unlimited' => 'purple',
];
return $this->features->map(function ($f) use ($typeColors) {
$lines = [['bold' => $f->name]];
if ($f->description) {
$lines[] = ['muted' => Str::limit($f->description, 40)];
}
if ($f->parent) {
$lines[] = ['muted' => 'Pool: '.$f->parent->name];
}
$resetCell = match ($f->reset_type) {
'none' => ['muted' => 'Never'],
'monthly' => ['badge' => 'Monthly', 'color' => 'green'],
'rolling' => ['badge' => $f->rolling_window_days.'d', 'color' => 'amber'],
default => ['muted' => '-'],
};
return [
['lines' => $lines],
['mono' => $f->code],
$f->category ? ['badge' => $f->category, 'color' => 'gray'] : ['muted' => '-'],
['badge' => ucfirst($f->type), 'color' => $typeColors[$f->type] ?? 'gray'],
$resetCell,
['badge' => $f->is_active ? 'Active' : 'Inactive', 'color' => $f->is_active ? 'green' : 'gray'],
[
'actions' => [
['icon' => 'pencil', 'click' => "openEditFeature({$f->id})", 'title' => 'Edit'],
['icon' => 'trash', 'click' => "deleteFeature({$f->id})", 'confirm' => 'Delete this feature?', 'title' => 'Delete', 'class' => 'text-red-600'],
],
],
];
})->all();
}
public function render()
{
return view('hub::admin.entitlement.dashboard');
}
}

View file

@ -0,0 +1,259 @@
<?php
declare(strict_types=1);
namespace Website\Hub\View\Modal\Admin\Entitlement;
use Core\Mod\Tenant\Models\Feature;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
use Livewire\WithPagination;
#[Title('Features')]
#[Layout('hub::admin.layouts.app')]
class FeatureManager extends Component
{
use WithPagination;
public bool $showModal = false;
/**
* Authorize access - Hades tier only.
*/
public function mount(): void
{
if (! auth()->user()?->isHades()) {
abort(403, 'Hades tier required for feature management.');
}
}
public ?int $editingId = null;
// Form fields
public string $code = '';
public string $name = '';
public string $description = '';
public string $category = '';
public string $type = 'boolean';
public string $reset_type = 'none';
public ?int $rolling_window_days = null;
public ?int $parent_feature_id = null;
public int $sort_order = 0;
public bool $is_active = true;
protected function rules(): array
{
$uniqueRule = $this->editingId
? 'unique:entitlement_features,code,'.$this->editingId
: 'unique:entitlement_features,code';
return [
'code' => ['required', 'string', 'max:100', $uniqueRule],
'name' => ['required', 'string', 'max:100'],
'description' => ['nullable', 'string', 'max:500'],
'category' => ['nullable', 'string', 'max:50'],
'type' => ['required', 'in:boolean,limit,unlimited'],
'reset_type' => ['required', 'in:none,monthly,rolling'],
'rolling_window_days' => ['nullable', 'integer', 'min:1', 'max:365'],
'parent_feature_id' => ['nullable', 'exists:entitlement_features,id'],
'sort_order' => ['integer'],
'is_active' => ['boolean'],
];
}
public function openCreate(): void
{
$this->resetForm();
$this->showModal = true;
}
public function openEdit(int $id): void
{
$feature = Feature::findOrFail($id);
$this->editingId = $id;
$this->code = $feature->code;
$this->name = $feature->name;
$this->description = $feature->description ?? '';
$this->category = $feature->category ?? '';
$this->type = $feature->type;
$this->reset_type = $feature->reset_type;
$this->rolling_window_days = $feature->rolling_window_days;
$this->parent_feature_id = $feature->parent_feature_id;
$this->sort_order = $feature->sort_order;
$this->is_active = $feature->is_active;
$this->showModal = true;
}
public function save(): void
{
$this->validate();
$data = [
'code' => $this->code,
'name' => $this->name,
'description' => $this->description ?: null,
'category' => $this->category ?: null,
'type' => $this->type,
'reset_type' => $this->reset_type,
'rolling_window_days' => $this->reset_type === 'rolling' ? $this->rolling_window_days : null,
'parent_feature_id' => $this->parent_feature_id ?: null,
'sort_order' => $this->sort_order,
'is_active' => $this->is_active,
];
if ($this->editingId) {
Feature::findOrFail($this->editingId)->update($data);
session()->flash('message', 'Feature updated successfully.');
} else {
Feature::create($data);
session()->flash('message', 'Feature created successfully.');
}
$this->closeModal();
}
public function delete(int $id): void
{
$feature = Feature::findOrFail($id);
// Check if feature is used in any packages
if ($feature->packages()->exists()) {
session()->flash('error', 'Cannot delete feature that is assigned to packages.');
return;
}
// Check if feature has children
if ($feature->children()->exists()) {
session()->flash('error', 'Cannot delete feature that has child features.');
return;
}
$feature->delete();
session()->flash('message', 'Feature deleted successfully.');
}
public function closeModal(): void
{
$this->showModal = false;
$this->resetForm();
}
protected function resetForm(): void
{
$this->editingId = null;
$this->code = '';
$this->name = '';
$this->description = '';
$this->category = '';
$this->type = 'boolean';
$this->reset_type = 'none';
$this->rolling_window_days = null;
$this->parent_feature_id = null;
$this->sort_order = 0;
$this->is_active = true;
}
#[Computed]
public function features()
{
return Feature::with('parent')
->orderBy('category')
->orderBy('sort_order')
->paginate(30);
}
#[Computed]
public function categories()
{
return Feature::whereNotNull('category')
->distinct()
->pluck('category');
}
#[Computed]
public function parentFeatures()
{
return Feature::root()
->where('type', 'limit')
->get();
}
#[Computed]
public function tableColumns(): array
{
return [
'Feature',
'Code',
'Category',
['label' => 'Type', 'align' => 'center'],
['label' => 'Reset', 'align' => 'center'],
['label' => 'Status', 'align' => 'center'],
['label' => 'Actions', 'align' => 'center'],
];
}
#[Computed]
public function tableRows(): array
{
$typeColors = [
'boolean' => 'gray',
'limit' => 'blue',
'unlimited' => 'purple',
];
return $this->features->map(function ($f) use ($typeColors) {
// Feature name with description and parent
$featureLines = [['bold' => $f->name]];
if ($f->description) {
$featureLines[] = ['muted' => \Illuminate\Support\Str::limit($f->description, 40)];
}
if ($f->parent) {
$featureLines[] = ['muted' => 'Parent: '.$f->parent->name];
}
// Reset type display
$resetCell = match ($f->reset_type) {
'none' => ['muted' => 'Never'],
'monthly' => ['badge' => 'Monthly', 'color' => 'green'],
'rolling' => ['badge' => $f->rolling_window_days.'d Rolling', 'color' => 'amber'],
default => ['muted' => '-'],
};
return [
['lines' => $featureLines],
['mono' => $f->code],
$f->category ? ['badge' => $f->category, 'color' => 'gray'] : ['muted' => '-'],
['badge' => ucfirst($f->type), 'color' => $typeColors[$f->type] ?? 'gray'],
$resetCell,
['badge' => $f->is_active ? 'Active' : 'Inactive', 'color' => $f->is_active ? 'green' : 'gray'],
[
'actions' => [
['icon' => 'pencil', 'click' => "openEdit({$f->id})", 'title' => 'Edit'],
['icon' => 'trash', 'click' => "delete({$f->id})", 'confirm' => 'Are you sure you want to delete this feature?', 'title' => 'Delete', 'class' => 'text-red-600'],
],
],
];
})->all();
}
public function render()
{
return view('hub::admin.entitlement.feature-manager')
->layout('hub::admin.layouts.app', ['title' => 'Features']);
}
}

View file

@ -0,0 +1,306 @@
<?php
declare(strict_types=1);
namespace Website\Hub\View\Modal\Admin\Entitlement;
use Core\Mod\Tenant\Models\Feature;
use Core\Mod\Tenant\Models\Package;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
use Livewire\WithPagination;
#[Title('Packages')]
#[Layout('hub::admin.layouts.app')]
class PackageManager extends Component
{
use WithPagination;
public bool $showModal = false;
/**
* Authorize access - Hades tier only.
*/
public function mount(): void
{
if (! auth()->user()?->isHades()) {
abort(403, 'Hades tier required for package management.');
}
}
public bool $showFeaturesModal = false;
public ?int $editingId = null;
// Form fields
public string $code = '';
public string $name = '';
public string $description = '';
public string $icon = 'package';
public string $color = 'blue';
public int $sort_order = 0;
public bool $is_stackable = true;
public bool $is_base_package = false;
public bool $is_active = true;
public bool $is_public = true;
public string $blesta_package_id = '';
// Features assignment
public array $selectedFeatures = [];
protected function rules(): array
{
$uniqueRule = $this->editingId
? 'unique:entitlement_packages,code,'.$this->editingId
: 'unique:entitlement_packages,code';
return [
'code' => ['required', 'string', 'max:50', $uniqueRule],
'name' => ['required', 'string', 'max:100'],
'description' => ['nullable', 'string', 'max:500'],
'icon' => ['nullable', 'string', 'max:50'],
'color' => ['nullable', 'string', 'max:20'],
'sort_order' => ['integer'],
'is_stackable' => ['boolean'],
'is_base_package' => ['boolean'],
'is_active' => ['boolean'],
'is_public' => ['boolean'],
'blesta_package_id' => ['nullable', 'string', 'max:100'],
];
}
public function openCreate(): void
{
$this->resetForm();
$this->showModal = true;
}
public function openEdit(int $id): void
{
$package = Package::findOrFail($id);
$this->editingId = $id;
$this->code = $package->code;
$this->name = $package->name;
$this->description = $package->description ?? '';
$this->icon = $package->icon ?? 'package';
$this->color = $package->color ?? 'blue';
$this->sort_order = $package->sort_order;
$this->is_stackable = $package->is_stackable;
$this->is_base_package = $package->is_base_package;
$this->is_active = $package->is_active;
$this->is_public = $package->is_public;
$this->blesta_package_id = $package->blesta_package_id ?? '';
$this->showModal = true;
}
public function save(): void
{
$this->validate();
$data = [
'code' => $this->code,
'name' => $this->name,
'description' => $this->description ?: null,
'icon' => $this->icon ?: null,
'color' => $this->color ?: null,
'sort_order' => $this->sort_order,
'is_stackable' => $this->is_stackable,
'is_base_package' => $this->is_base_package,
'is_active' => $this->is_active,
'is_public' => $this->is_public,
'blesta_package_id' => $this->blesta_package_id ?: null,
];
if ($this->editingId) {
Package::findOrFail($this->editingId)->update($data);
session()->flash('message', 'Package updated successfully.');
} else {
Package::create($data);
session()->flash('message', 'Package created successfully.');
}
$this->closeModal();
}
public function openFeatures(int $id): void
{
$this->editingId = $id;
$package = Package::with('features')->findOrFail($id);
// Build selectedFeatures array with limit values
$this->selectedFeatures = [];
foreach ($package->features as $feature) {
$this->selectedFeatures[$feature->id] = [
'enabled' => true,
'limit' => $feature->pivot->limit_value,
];
}
$this->showFeaturesModal = true;
}
public function saveFeatures(): void
{
$package = Package::findOrFail($this->editingId);
$syncData = [];
foreach ($this->selectedFeatures as $featureId => $config) {
if (! empty($config['enabled'])) {
$syncData[$featureId] = [
'limit_value' => isset($config['limit']) && $config['limit'] !== ''
? (int) $config['limit']
: null,
];
}
}
$package->features()->sync($syncData);
session()->flash('message', 'Package features updated successfully.');
$this->showFeaturesModal = false;
}
public function toggleFeature(int $featureId): void
{
if (isset($this->selectedFeatures[$featureId])) {
$this->selectedFeatures[$featureId]['enabled'] = ! $this->selectedFeatures[$featureId]['enabled'];
} else {
$this->selectedFeatures[$featureId] = [
'enabled' => true,
'limit' => null,
];
}
}
public function delete(int $id): void
{
$package = Package::findOrFail($id);
// Check if any workspaces use this package
if ($package->workspacePackages()->exists()) {
session()->flash('error', 'Cannot delete package with active assignments.');
return;
}
$package->delete();
session()->flash('message', 'Package deleted successfully.');
}
public function closeModal(): void
{
$this->showModal = false;
$this->showFeaturesModal = false;
$this->resetForm();
}
protected function resetForm(): void
{
$this->editingId = null;
$this->code = '';
$this->name = '';
$this->description = '';
$this->icon = 'package';
$this->color = 'blue';
$this->sort_order = 0;
$this->is_stackable = true;
$this->is_base_package = false;
$this->is_active = true;
$this->is_public = true;
$this->blesta_package_id = '';
$this->selectedFeatures = [];
}
#[Computed]
public function packages()
{
return Package::withCount('features')
->orderBy('sort_order')
->orderBy('name')
->paginate(20);
}
#[Computed]
public function features()
{
return Feature::active()
->orderBy('category')
->orderBy('sort_order')
->get()
->groupBy('category');
}
#[Computed]
public function tableColumns(): array
{
return [
'Package',
'Code',
'Features',
['label' => 'Type', 'align' => 'center'],
['label' => 'Status', 'align' => 'center'],
['label' => 'Actions', 'align' => 'center'],
];
}
#[Computed]
public function tableRows(): array
{
return $this->packages->map(function ($p) {
// Package name with icon and description
$packageLines = [['bold' => $p->name]];
if ($p->description) {
$packageLines[] = ['muted' => \Illuminate\Support\Str::limit($p->description, 50)];
}
// Type badge
$typeBadge = match (true) {
$p->is_base_package => ['badge' => 'Base', 'color' => 'purple'],
$p->is_stackable => ['badge' => 'Addon', 'color' => 'blue'],
default => ['badge' => 'Standard', 'color' => 'gray'],
};
// Status badges (multiple)
$statusLines = [];
$statusLines[] = ['badge' => $p->is_active ? 'Active' : 'Inactive', 'color' => $p->is_active ? 'green' : 'gray'];
if ($p->is_public) {
$statusLines[] = ['badge' => 'Public', 'color' => 'sky'];
}
return [
['lines' => $packageLines],
['mono' => $p->code],
['badge' => $p->features_count.' features', 'color' => 'gray'],
$typeBadge,
['lines' => $statusLines],
[
'actions' => [
['icon' => 'puzzle-piece', 'click' => "openFeatures({$p->id})", 'title' => 'Assign features'],
['icon' => 'pencil', 'click' => "openEdit({$p->id})", 'title' => 'Edit'],
['icon' => 'trash', 'click' => "delete({$p->id})", 'confirm' => 'Are you sure you want to delete this package?', 'title' => 'Delete', 'class' => 'text-red-600'],
],
],
];
})->all();
}
public function render()
{
return view('hub::admin.entitlement.package-manager')
->layout('hub::admin.layouts.app', ['title' => 'Packages']);
}
}

View file

@ -0,0 +1,257 @@
<?php
declare(strict_types=1);
namespace Website\Hub\View\Modal\Admin;
use Core\Admin\Search\SearchProviderRegistry;
use Livewire\Attributes\Computed;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* Global search component with Command+K keyboard shortcut.
*
* 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.
*/
#[On('open-global-search')]
public function openSearch(): void
{
$this->open = true;
$this->query = '';
$this->selectedIndex = 0;
}
/**
* Close the search modal.
*/
public function closeSearch(): void
{
$this->open = false;
$this->query = '';
$this->selectedIndex = 0;
}
/**
* Handle query changes - reset selection index.
*/
public function updatedQuery(): void
{
$this->selectedIndex = 0;
}
/**
* Navigate up in results.
*/
public function navigateUp(): void
{
if ($this->selectedIndex > 0) {
$this->selectedIndex--;
}
}
/**
* Navigate down in results.
*/
public function navigateDown(): void
{
$allResults = $this->flatResults;
if ($this->selectedIndex < count($allResults) - 1) {
$this->selectedIndex++;
}
}
/**
* Select the current result.
*/
public function selectCurrent(): void
{
$allResults = $this->flatResults;
if (isset($allResults[$this->selectedIndex])) {
$result = $allResults[$this->selectedIndex];
$this->navigateTo($result);
}
}
/**
* Navigate to a specific result.
*/
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.
*/
#[Computed]
public function results(): array
{
if (strlen($this->query) < 2) {
return [];
}
$user = auth()->user();
$workspace = $user?->defaultHostWorkspace();
return $this->registry->search($this->query, $user, $workspace);
}
/**
* Get flattened results for keyboard navigation.
*/
#[Computed]
public function flatResults(): array
{
return $this->registry->flattenResults($this->results);
}
/**
* Check if there are any results.
*/
#[Computed]
public function hasResults(): bool
{
return ! empty($this->flatResults);
}
/**
* Check if we should show recent searches.
*/
#[Computed]
public function showRecentSearches(): bool
{
return strlen($this->query) < 2 && ! empty($this->recentSearches);
}
public function render()
{
return view('hub::admin.global-search');
}
}

View file

@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace Website\Hub\View\Modal\Admin;
use Core\Mod\Hub\Models\HoneypotHit;
use Livewire\Component;
use Livewire\WithPagination;
class Honeypot extends Component
{
use WithPagination;
public string $search = '';
public string $botFilter = '';
public string $sortField = 'created_at';
public string $sortDirection = 'desc';
protected $queryString = [
'search' => ['except' => ''],
'botFilter' => ['except' => ''],
];
public function mount(): void
{
if (! auth()->user()?->isHades()) {
abort(403, 'Hades tier required.');
}
}
public function updatingSearch(): void
{
$this->resetPage();
}
public function sortBy(string $field): void
{
if ($this->sortField === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortField = $field;
$this->sortDirection = 'asc';
}
}
public function deleteOld(int $days = 30): void
{
HoneypotHit::where('created_at', '<', now()->subDays($days))->delete();
session()->flash('message', "Deleted hits older than {$days} days.");
}
public function blockIp(string $ip): void
{
// This could integrate with a firewall or rate limiter
// For now, just show a message
session()->flash('message', "IP {$ip} flagged for review. Add to firewall manually.");
}
public function render()
{
$hits = HoneypotHit::query()
->when($this->search, function ($query) {
$query->where(function ($q) {
$q->where('ip_address', 'like', "%{$this->search}%")
->orWhere('user_agent', 'like', "%{$this->search}%")
->orWhere('bot_name', 'like', "%{$this->search}%");
});
})
->when($this->botFilter !== '', function ($query) {
$query->where('is_bot', $this->botFilter === '1');
})
->orderBy($this->sortField, $this->sortDirection)
->paginate(50);
return view('hub::admin.honeypot', [
'hits' => $hits,
'stats' => HoneypotHit::getStats(),
])->layout('hub::admin.layouts.app', ['title' => 'Honeypot Monitor']);
}
}

Some files were not shown because too many files have changed in this diff Show more