monorepo sepration
This commit is contained in:
parent
8ee3a54482
commit
71c0805bfd
113 changed files with 25853 additions and 175 deletions
201
README.md
201
README.md
|
|
@ -1,138 +1,113 @@
|
|||
# Core PHP Framework Project
|
||||
# Core Admin Package
|
||||
|
||||
[](https://github.com/host-uk/core-template/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/host-uk/core-template)
|
||||
[](https://packagist.org/packages/host-uk/core-template)
|
||||
[](https://laravel.com)
|
||||
[](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
227
TODO.md
Normal 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.*
|
||||
70
changelog/2026/jan/features.md
Normal file
70
changelog/2026/jan/features.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# Core-Admin - January 2026
|
||||
|
||||
## Features Implemented
|
||||
|
||||
### Form Authorization Components
|
||||
|
||||
Authorization-aware form components that automatically disable/hide based on permissions.
|
||||
|
||||
**Files:**
|
||||
- `src/Forms/Concerns/HasAuthorizationProps.php` - Authorization trait
|
||||
- `src/Forms/View/Components/` - Input, Textarea, Select, Checkbox, Button, Toggle, FormGroup
|
||||
- `resources/views/components/forms/` - Blade templates
|
||||
|
||||
**Components:**
|
||||
- `<x-core-forms.input />` - Text input with label, helper, error
|
||||
- `<x-core-forms.textarea />` - Textarea with auto-resize
|
||||
- `<x-core-forms.select />` - Dropdown with grouped options
|
||||
- `<x-core-forms.checkbox />` - Checkbox with description
|
||||
- `<x-core-forms.button />` - Button with variants, loading state
|
||||
- `<x-core-forms.toggle />` - Toggle with instant save
|
||||
- `<x-core-forms.form-group />` - Wrapper for spacing
|
||||
|
||||
**Usage:**
|
||||
```blade
|
||||
<x-core-forms.input
|
||||
id="name"
|
||||
label="Name"
|
||||
canGate="update"
|
||||
:canResource="$model"
|
||||
wire:model="name"
|
||||
/>
|
||||
|
||||
<x-core-forms.button variant="danger" canGate="delete" :canResource="$model" canHide>
|
||||
Delete
|
||||
</x-core-forms.button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Global Search (⌘K)
|
||||
|
||||
Unified search across resources with keyboard navigation.
|
||||
|
||||
**Files:**
|
||||
- `src/Search/Contracts/SearchProvider.php` - Provider interface
|
||||
- `src/Search/SearchProviderRegistry.php` - Registry with fuzzy matching
|
||||
- `src/Search/SearchResult.php` - Result DTO
|
||||
- `src/Search/Providers/AdminPageSearchProvider.php` - Built-in provider
|
||||
- `src/Website/Hub/View/Modal/Admin/GlobalSearch.php` - Livewire component
|
||||
|
||||
**Features:**
|
||||
- ⌘K / Ctrl+K keyboard shortcut
|
||||
- Arrow key navigation, Enter to select
|
||||
- Fuzzy matching support
|
||||
- Recent searches
|
||||
- Grouped results by provider
|
||||
|
||||
**Usage:**
|
||||
```php
|
||||
// Register custom provider
|
||||
app(SearchProviderRegistry::class)->register(new MySearchProvider());
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### Soketi (Real-time WebSocket)
|
||||
|
||||
Excluded per project decision. Self-hosted Soketi integration not required at this time.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
82
resources/views/components/forms/button.blade.php
Normal file
82
resources/views/components/forms/button.blade.php
Normal 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
|
||||
88
resources/views/components/forms/checkbox.blade.php
Normal file
88
resources/views/components/forms/checkbox.blade.php
Normal 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
|
||||
50
resources/views/components/forms/form-group.blade.php
Normal file
50
resources/views/components/forms/form-group.blade.php
Normal 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>
|
||||
77
resources/views/components/forms/input.blade.php
Normal file
77
resources/views/components/forms/input.blade.php
Normal 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
|
||||
108
resources/views/components/forms/select.blade.php
Normal file
108
resources/views/components/forms/select.blade.php
Normal 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
|
||||
87
resources/views/components/forms/textarea.blade.php
Normal file
87
resources/views/components/forms/textarea.blade.php
Normal 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
|
||||
104
resources/views/components/forms/toggle.blade.php
Normal file
104
resources/views/components/forms/toggle.blade.php
Normal 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
88
src/Boot.php
Normal 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));
|
||||
}
|
||||
}
|
||||
101
src/Forms/Concerns/HasAuthorizationProps.php
Normal file
101
src/Forms/Concerns/HasAuthorizationProps.php
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Admin\Forms\Concerns;
|
||||
|
||||
/**
|
||||
* Provides authorization-aware props for form components.
|
||||
*
|
||||
* Components using this trait can accept `canGate` and `canResource` props
|
||||
* to automatically disable or hide based on user permissions.
|
||||
*
|
||||
* Usage:
|
||||
* ```blade
|
||||
* <x-core-forms.input canGate="update" :canResource="$biolink" id="name" />
|
||||
* <x-core-forms.button canGate="delete" :canResource="$biolink" canHide>Delete</x-core-forms.button>
|
||||
* ```
|
||||
*/
|
||||
trait HasAuthorizationProps
|
||||
{
|
||||
/**
|
||||
* The gate/ability to check (e.g., 'update', 'delete').
|
||||
*/
|
||||
public ?string $canGate = null;
|
||||
|
||||
/**
|
||||
* The resource/model to check the gate against.
|
||||
*/
|
||||
public mixed $canResource = null;
|
||||
|
||||
/**
|
||||
* Whether to hide the component (instead of disabling) when unauthorized.
|
||||
*/
|
||||
public bool $canHide = false;
|
||||
|
||||
/**
|
||||
* Resolve whether the component should be disabled based on authorization.
|
||||
*
|
||||
* If `canGate` and `canResource` are both provided and the user lacks
|
||||
* the required permission, the component will be disabled.
|
||||
*
|
||||
* @param bool $explicitlyDisabled Whether the component was explicitly disabled via props
|
||||
*/
|
||||
protected function resolveDisabledState(bool $explicitlyDisabled = false): bool
|
||||
{
|
||||
// Already explicitly disabled - no need to check authorization
|
||||
if ($explicitlyDisabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// No authorization check configured
|
||||
if (! $this->canGate || $this->canResource === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if user can perform the action
|
||||
return ! $this->userCan();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve whether the component should be hidden based on authorization.
|
||||
*
|
||||
* Only hides if `canHide` is true and the user lacks permission.
|
||||
*/
|
||||
protected function resolveHiddenState(): bool
|
||||
{
|
||||
// Not configured to hide on unauthorized
|
||||
if (! $this->canHide) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// No authorization check configured
|
||||
if (! $this->canGate || $this->canResource === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hide if user cannot perform the action
|
||||
return ! $this->userCan();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user can perform the gate action on the resource.
|
||||
*/
|
||||
protected function userCan(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->can($this->canGate, $this->canResource);
|
||||
}
|
||||
}
|
||||
135
src/Forms/View/Components/Button.php
Normal file
135
src/Forms/View/Components/Button.php
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Admin\Forms\View\Components;
|
||||
|
||||
use Core\Admin\Forms\Concerns\HasAuthorizationProps;
|
||||
use Illuminate\View\Component;
|
||||
|
||||
/**
|
||||
* Button component with authorization support.
|
||||
*
|
||||
* Features:
|
||||
* - Authorization via `canGate` / `canResource` props (disables or hides)
|
||||
* - Variants: primary, secondary, danger, ghost
|
||||
* - Loading state support (with wire:loading integration)
|
||||
* - Icon support (left and right positions)
|
||||
* - Size variants: sm, md, lg
|
||||
* - Dark mode support
|
||||
*
|
||||
* Usage:
|
||||
* ```blade
|
||||
* <x-core-forms.button
|
||||
* variant="primary"
|
||||
* icon="check"
|
||||
* canGate="update"
|
||||
* :canResource="$model"
|
||||
* >
|
||||
* Save Changes
|
||||
* </x-core-forms.button>
|
||||
*
|
||||
* <x-core-forms.button
|
||||
* variant="danger"
|
||||
* canGate="delete"
|
||||
* :canResource="$model"
|
||||
* canHide
|
||||
* >
|
||||
* Delete
|
||||
* </x-core-forms.button>
|
||||
* ```
|
||||
*/
|
||||
class Button extends Component
|
||||
{
|
||||
use HasAuthorizationProps;
|
||||
|
||||
public string $type;
|
||||
|
||||
public string $variant;
|
||||
|
||||
public string $size;
|
||||
|
||||
public ?string $icon;
|
||||
|
||||
public ?string $iconRight;
|
||||
|
||||
public bool $loading;
|
||||
|
||||
public ?string $loadingText;
|
||||
|
||||
public bool $disabled;
|
||||
|
||||
public bool $hidden;
|
||||
|
||||
public string $variantClasses;
|
||||
|
||||
public string $sizeClasses;
|
||||
|
||||
public function __construct(
|
||||
string $type = 'button',
|
||||
string $variant = 'primary',
|
||||
string $size = 'md',
|
||||
?string $icon = null,
|
||||
?string $iconRight = null,
|
||||
bool $loading = false,
|
||||
?string $loadingText = null,
|
||||
bool $disabled = false,
|
||||
// Authorization props
|
||||
?string $canGate = null,
|
||||
mixed $canResource = null,
|
||||
bool $canHide = false,
|
||||
) {
|
||||
$this->type = $type;
|
||||
$this->variant = $variant;
|
||||
$this->size = $size;
|
||||
$this->icon = $icon;
|
||||
$this->iconRight = $iconRight;
|
||||
$this->loading = $loading;
|
||||
$this->loadingText = $loadingText;
|
||||
|
||||
// Authorization setup
|
||||
$this->canGate = $canGate;
|
||||
$this->canResource = $canResource;
|
||||
$this->canHide = $canHide;
|
||||
|
||||
// Resolve states based on authorization
|
||||
$this->disabled = $this->resolveDisabledState($disabled);
|
||||
$this->hidden = $this->resolveHiddenState();
|
||||
|
||||
// Resolve variant and size classes
|
||||
$this->variantClasses = $this->resolveVariantClasses();
|
||||
$this->sizeClasses = $this->resolveSizeClasses();
|
||||
}
|
||||
|
||||
protected function resolveVariantClasses(): string
|
||||
{
|
||||
return match ($this->variant) {
|
||||
'primary' => 'bg-violet-600 hover:bg-violet-700 text-white focus:ring-violet-500 disabled:bg-violet-400',
|
||||
'secondary' => 'bg-gray-100 hover:bg-gray-200 text-gray-700 dark:bg-gray-700 dark:hover:bg-gray-600 dark:text-gray-200 focus:ring-gray-500 disabled:bg-gray-100 disabled:dark:bg-gray-800',
|
||||
'danger' => 'bg-red-600 hover:bg-red-700 text-white focus:ring-red-500 disabled:bg-red-400',
|
||||
'ghost' => 'bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300 focus:ring-gray-500',
|
||||
default => 'bg-violet-600 hover:bg-violet-700 text-white focus:ring-violet-500 disabled:bg-violet-400',
|
||||
};
|
||||
}
|
||||
|
||||
protected function resolveSizeClasses(): string
|
||||
{
|
||||
return match ($this->size) {
|
||||
'sm' => 'px-3 py-1.5 text-sm',
|
||||
'lg' => 'px-6 py-3 text-base',
|
||||
default => 'px-4 py-2 text-sm',
|
||||
};
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('core-forms::components.forms.button');
|
||||
}
|
||||
}
|
||||
89
src/Forms/View/Components/Checkbox.php
Normal file
89
src/Forms/View/Components/Checkbox.php
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Admin\Forms\View\Components;
|
||||
|
||||
use Core\Admin\Forms\Concerns\HasAuthorizationProps;
|
||||
use Illuminate\View\Component;
|
||||
|
||||
/**
|
||||
* Checkbox component with authorization support.
|
||||
*
|
||||
* Features:
|
||||
* - Authorization via `canGate` / `canResource` props
|
||||
* - Label positioning (left/right)
|
||||
* - Description text
|
||||
* - Error display from validation
|
||||
* - Dark mode support
|
||||
*
|
||||
* Usage:
|
||||
* ```blade
|
||||
* <x-core-forms.checkbox
|
||||
* id="is_active"
|
||||
* label="Active"
|
||||
* description="Enable this feature for users"
|
||||
* canGate="update"
|
||||
* :canResource="$model"
|
||||
* wire:model="is_active"
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
class Checkbox extends Component
|
||||
{
|
||||
use HasAuthorizationProps;
|
||||
|
||||
public string $id;
|
||||
|
||||
public ?string $label;
|
||||
|
||||
public ?string $description;
|
||||
|
||||
public ?string $error;
|
||||
|
||||
public string $labelPosition;
|
||||
|
||||
public bool $disabled;
|
||||
|
||||
public bool $hidden;
|
||||
|
||||
public function __construct(
|
||||
string $id,
|
||||
?string $label = null,
|
||||
?string $description = null,
|
||||
?string $error = null,
|
||||
string $labelPosition = 'right',
|
||||
bool $disabled = false,
|
||||
// Authorization props
|
||||
?string $canGate = null,
|
||||
mixed $canResource = null,
|
||||
bool $canHide = false,
|
||||
) {
|
||||
$this->id = $id;
|
||||
$this->label = $label;
|
||||
$this->description = $description;
|
||||
$this->error = $error;
|
||||
$this->labelPosition = $labelPosition;
|
||||
|
||||
// Authorization setup
|
||||
$this->canGate = $canGate;
|
||||
$this->canResource = $canResource;
|
||||
$this->canHide = $canHide;
|
||||
|
||||
// Resolve states based on authorization
|
||||
$this->disabled = $this->resolveDisabledState($disabled);
|
||||
$this->hidden = $this->resolveHiddenState();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('core-forms::components.forms.checkbox');
|
||||
}
|
||||
}
|
||||
88
src/Forms/View/Components/FormGroup.php
Normal file
88
src/Forms/View/Components/FormGroup.php
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Admin\Forms\View\Components;
|
||||
|
||||
use Illuminate\View\Component;
|
||||
|
||||
/**
|
||||
* Form group wrapper component for consistent spacing and error display.
|
||||
*
|
||||
* Features:
|
||||
* - Consistent spacing between form elements
|
||||
* - Error display from validation bag
|
||||
* - Label support
|
||||
* - Helper text support
|
||||
* - Optional required indicator
|
||||
*
|
||||
* Usage:
|
||||
* ```blade
|
||||
* <x-core-forms.form-group label="Email" for="email" error="email" required>
|
||||
* <input type="email" id="email" wire:model="email" />
|
||||
* </x-core-forms.form-group>
|
||||
* ```
|
||||
*/
|
||||
class FormGroup extends Component
|
||||
{
|
||||
public ?string $label;
|
||||
|
||||
public ?string $for;
|
||||
|
||||
public ?string $error;
|
||||
|
||||
public ?string $helper;
|
||||
|
||||
public bool $required;
|
||||
|
||||
public string $errorMessage;
|
||||
|
||||
public function __construct(
|
||||
?string $label = null,
|
||||
?string $for = null,
|
||||
?string $error = null,
|
||||
?string $helper = null,
|
||||
bool $required = false,
|
||||
) {
|
||||
$this->label = $label;
|
||||
$this->for = $for;
|
||||
$this->error = $error;
|
||||
$this->helper = $helper;
|
||||
$this->required = $required;
|
||||
|
||||
// Resolve error message from validation bag
|
||||
$this->errorMessage = $this->resolveError();
|
||||
}
|
||||
|
||||
protected function resolveError(): string
|
||||
{
|
||||
if (! $this->error) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$errors = session('errors');
|
||||
|
||||
if (! $errors) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $errors->first($this->error) ?? '';
|
||||
}
|
||||
|
||||
public function hasError(): bool
|
||||
{
|
||||
return ! empty($this->errorMessage);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('core-forms::components.forms.form-group');
|
||||
}
|
||||
}
|
||||
99
src/Forms/View/Components/Input.php
Normal file
99
src/Forms/View/Components/Input.php
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Admin\Forms\View\Components;
|
||||
|
||||
use Core\Admin\Forms\Concerns\HasAuthorizationProps;
|
||||
use Illuminate\View\Component;
|
||||
|
||||
/**
|
||||
* Text input component with authorization support.
|
||||
*
|
||||
* Features:
|
||||
* - Authorization via `canGate` / `canResource` props
|
||||
* - Label with automatic `for` attribute
|
||||
* - Helper text support
|
||||
* - Error display from validation
|
||||
* - Dark mode support
|
||||
* - Disabled state styling
|
||||
* - Livewire and Alpine.js compatible
|
||||
*
|
||||
* Usage:
|
||||
* ```blade
|
||||
* <x-core-forms.input
|
||||
* id="name"
|
||||
* label="Display Name"
|
||||
* helper="Enter a memorable display name"
|
||||
* canGate="update"
|
||||
* :canResource="$model"
|
||||
* wire:model="name"
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
class Input extends Component
|
||||
{
|
||||
use HasAuthorizationProps;
|
||||
|
||||
public string $id;
|
||||
|
||||
public ?string $label;
|
||||
|
||||
public ?string $helper;
|
||||
|
||||
public ?string $error;
|
||||
|
||||
public string $type;
|
||||
|
||||
public ?string $placeholder;
|
||||
|
||||
public bool $disabled;
|
||||
|
||||
public bool $hidden;
|
||||
|
||||
public bool $required;
|
||||
|
||||
public function __construct(
|
||||
string $id,
|
||||
?string $label = null,
|
||||
?string $helper = null,
|
||||
?string $error = null,
|
||||
string $type = 'text',
|
||||
?string $placeholder = null,
|
||||
bool $disabled = false,
|
||||
bool $required = false,
|
||||
// Authorization props
|
||||
?string $canGate = null,
|
||||
mixed $canResource = null,
|
||||
bool $canHide = false,
|
||||
) {
|
||||
$this->id = $id;
|
||||
$this->label = $label;
|
||||
$this->helper = $helper;
|
||||
$this->error = $error;
|
||||
$this->type = $type;
|
||||
$this->placeholder = $placeholder;
|
||||
$this->required = $required;
|
||||
|
||||
// Authorization setup
|
||||
$this->canGate = $canGate;
|
||||
$this->canResource = $canResource;
|
||||
$this->canHide = $canHide;
|
||||
|
||||
// Resolve states based on authorization
|
||||
$this->disabled = $this->resolveDisabledState($disabled);
|
||||
$this->hidden = $this->resolveHiddenState();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('core-forms::components.forms.input');
|
||||
}
|
||||
}
|
||||
146
src/Forms/View/Components/Select.php
Normal file
146
src/Forms/View/Components/Select.php
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Admin\Forms\View\Components;
|
||||
|
||||
use Core\Admin\Forms\Concerns\HasAuthorizationProps;
|
||||
use Illuminate\View\Component;
|
||||
|
||||
/**
|
||||
* Select dropdown component with authorization support.
|
||||
*
|
||||
* Features:
|
||||
* - Authorization via `canGate` / `canResource` props
|
||||
* - Options array support (value => label or flat array)
|
||||
* - Placeholder option
|
||||
* - Multiple selection support
|
||||
* - Label with automatic `for` attribute
|
||||
* - Helper text support
|
||||
* - Error display from validation
|
||||
* - Dark mode support
|
||||
*
|
||||
* Usage:
|
||||
* ```blade
|
||||
* <x-core-forms.select
|
||||
* id="status"
|
||||
* label="Status"
|
||||
* :options="['draft' => 'Draft', 'published' => 'Published']"
|
||||
* placeholder="Select a status..."
|
||||
* canGate="update"
|
||||
* :canResource="$model"
|
||||
* wire:model="status"
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
class Select extends Component
|
||||
{
|
||||
use HasAuthorizationProps;
|
||||
|
||||
public string $id;
|
||||
|
||||
public ?string $label;
|
||||
|
||||
public ?string $helper;
|
||||
|
||||
public ?string $error;
|
||||
|
||||
public ?string $placeholder;
|
||||
|
||||
public array $options;
|
||||
|
||||
public array $normalizedOptions;
|
||||
|
||||
public bool $multiple;
|
||||
|
||||
public bool $disabled;
|
||||
|
||||
public bool $hidden;
|
||||
|
||||
public bool $required;
|
||||
|
||||
public function __construct(
|
||||
string $id,
|
||||
array $options = [],
|
||||
?string $label = null,
|
||||
?string $helper = null,
|
||||
?string $error = null,
|
||||
?string $placeholder = null,
|
||||
bool $multiple = false,
|
||||
bool $disabled = false,
|
||||
bool $required = false,
|
||||
// Authorization props
|
||||
?string $canGate = null,
|
||||
mixed $canResource = null,
|
||||
bool $canHide = false,
|
||||
) {
|
||||
$this->id = $id;
|
||||
$this->label = $label;
|
||||
$this->helper = $helper;
|
||||
$this->error = $error;
|
||||
$this->placeholder = $placeholder;
|
||||
$this->options = $options;
|
||||
$this->multiple = $multiple;
|
||||
$this->required = $required;
|
||||
|
||||
// Normalize options to value => label format
|
||||
$this->normalizedOptions = $this->normalizeOptions($options);
|
||||
|
||||
// Authorization setup
|
||||
$this->canGate = $canGate;
|
||||
$this->canResource = $canResource;
|
||||
$this->canHide = $canHide;
|
||||
|
||||
// Resolve states based on authorization
|
||||
$this->disabled = $this->resolveDisabledState($disabled);
|
||||
$this->hidden = $this->resolveHiddenState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize options to ensure consistent value => label format.
|
||||
*/
|
||||
protected function normalizeOptions(array $options): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($options as $key => $value) {
|
||||
// Handle grouped options (optgroup)
|
||||
if (is_array($value) && ! isset($value['label'])) {
|
||||
$normalized[$key] = $this->normalizeOptions($value);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle array format: ['label' => 'Display', 'value' => 'actual']
|
||||
if (is_array($value) && isset($value['label'])) {
|
||||
$normalized[$value['value'] ?? $key] = $value['label'];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle flat array: ['option1', 'option2']
|
||||
if (is_int($key)) {
|
||||
$normalized[$value] = $value;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle associative array: ['value' => 'Label']
|
||||
$normalized[$key] = $value;
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('core-forms::components.forms.select');
|
||||
}
|
||||
}
|
||||
104
src/Forms/View/Components/Textarea.php
Normal file
104
src/Forms/View/Components/Textarea.php
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Admin\Forms\View\Components;
|
||||
|
||||
use Core\Admin\Forms\Concerns\HasAuthorizationProps;
|
||||
use Illuminate\View\Component;
|
||||
|
||||
/**
|
||||
* Textarea component with authorization support.
|
||||
*
|
||||
* Features:
|
||||
* - Authorization via `canGate` / `canResource` props
|
||||
* - Configurable rows
|
||||
* - Auto-resize option (via Alpine.js)
|
||||
* - Label with automatic `for` attribute
|
||||
* - Helper text support
|
||||
* - Error display from validation
|
||||
* - Dark mode support
|
||||
*
|
||||
* Usage:
|
||||
* ```blade
|
||||
* <x-core-forms.textarea
|
||||
* id="description"
|
||||
* label="Description"
|
||||
* rows="4"
|
||||
* autoResize
|
||||
* canGate="update"
|
||||
* :canResource="$model"
|
||||
* wire:model="description"
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
class Textarea extends Component
|
||||
{
|
||||
use HasAuthorizationProps;
|
||||
|
||||
public string $id;
|
||||
|
||||
public ?string $label;
|
||||
|
||||
public ?string $helper;
|
||||
|
||||
public ?string $error;
|
||||
|
||||
public ?string $placeholder;
|
||||
|
||||
public int $rows;
|
||||
|
||||
public bool $autoResize;
|
||||
|
||||
public bool $disabled;
|
||||
|
||||
public bool $hidden;
|
||||
|
||||
public bool $required;
|
||||
|
||||
public function __construct(
|
||||
string $id,
|
||||
?string $label = null,
|
||||
?string $helper = null,
|
||||
?string $error = null,
|
||||
?string $placeholder = null,
|
||||
int $rows = 3,
|
||||
bool $autoResize = false,
|
||||
bool $disabled = false,
|
||||
bool $required = false,
|
||||
// Authorization props
|
||||
?string $canGate = null,
|
||||
mixed $canResource = null,
|
||||
bool $canHide = false,
|
||||
) {
|
||||
$this->id = $id;
|
||||
$this->label = $label;
|
||||
$this->helper = $helper;
|
||||
$this->error = $error;
|
||||
$this->placeholder = $placeholder;
|
||||
$this->rows = $rows;
|
||||
$this->autoResize = $autoResize;
|
||||
$this->required = $required;
|
||||
|
||||
// Authorization setup
|
||||
$this->canGate = $canGate;
|
||||
$this->canResource = $canResource;
|
||||
$this->canHide = $canHide;
|
||||
|
||||
// Resolve states based on authorization
|
||||
$this->disabled = $this->resolveDisabledState($disabled);
|
||||
$this->hidden = $this->resolveHiddenState();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('core-forms::components.forms.textarea');
|
||||
}
|
||||
}
|
||||
127
src/Forms/View/Components/Toggle.php
Normal file
127
src/Forms/View/Components/Toggle.php
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Admin\Forms\View\Components;
|
||||
|
||||
use Core\Admin\Forms\Concerns\HasAuthorizationProps;
|
||||
use Illuminate\View\Component;
|
||||
|
||||
/**
|
||||
* Toggle switch component with authorization support.
|
||||
*
|
||||
* Features:
|
||||
* - Authorization via `canGate` / `canResource` props
|
||||
* - `instantSave` for Livewire real-time persistence
|
||||
* - Label and description
|
||||
* - Size variants: sm, md, lg
|
||||
* - Dark mode support
|
||||
*
|
||||
* Usage:
|
||||
* ```blade
|
||||
* <x-core-forms.toggle
|
||||
* id="is_public"
|
||||
* label="Public"
|
||||
* description="Make this visible to everyone"
|
||||
* instantSave
|
||||
* canGate="update"
|
||||
* :canResource="$model"
|
||||
* wire:model="is_public"
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
class Toggle extends Component
|
||||
{
|
||||
use HasAuthorizationProps;
|
||||
|
||||
public string $id;
|
||||
|
||||
public ?string $label;
|
||||
|
||||
public ?string $description;
|
||||
|
||||
public ?string $error;
|
||||
|
||||
public string $size;
|
||||
|
||||
public bool $instantSave;
|
||||
|
||||
public ?string $instantSaveMethod;
|
||||
|
||||
public bool $disabled;
|
||||
|
||||
public bool $hidden;
|
||||
|
||||
public string $trackClasses;
|
||||
|
||||
public string $thumbClasses;
|
||||
|
||||
public function __construct(
|
||||
string $id,
|
||||
?string $label = null,
|
||||
?string $description = null,
|
||||
?string $error = null,
|
||||
string $size = 'md',
|
||||
bool $instantSave = false,
|
||||
?string $instantSaveMethod = null,
|
||||
bool $disabled = false,
|
||||
// Authorization props
|
||||
?string $canGate = null,
|
||||
mixed $canResource = null,
|
||||
bool $canHide = false,
|
||||
) {
|
||||
$this->id = $id;
|
||||
$this->label = $label;
|
||||
$this->description = $description;
|
||||
$this->error = $error;
|
||||
$this->size = $size;
|
||||
$this->instantSave = $instantSave;
|
||||
$this->instantSaveMethod = $instantSaveMethod;
|
||||
|
||||
// Authorization setup
|
||||
$this->canGate = $canGate;
|
||||
$this->canResource = $canResource;
|
||||
$this->canHide = $canHide;
|
||||
|
||||
// Resolve states based on authorization
|
||||
$this->disabled = $this->resolveDisabledState($disabled);
|
||||
$this->hidden = $this->resolveHiddenState();
|
||||
|
||||
// Resolve size classes
|
||||
[$this->trackClasses, $this->thumbClasses] = $this->resolveSizeClasses();
|
||||
}
|
||||
|
||||
protected function resolveSizeClasses(): array
|
||||
{
|
||||
return match ($this->size) {
|
||||
'sm' => ['w-8 h-4', 'w-3 h-3'],
|
||||
'lg' => ['w-14 h-7', 'w-6 h-6'],
|
||||
default => ['w-11 h-6', 'w-5 h-5'],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the wire:change directive for instant save.
|
||||
*/
|
||||
public function wireChange(): ?string
|
||||
{
|
||||
if (! $this->instantSave) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Default to 'save' method if not specified
|
||||
return $this->instantSaveMethod ?? 'save';
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('core-forms::components.forms.toggle');
|
||||
}
|
||||
}
|
||||
268
src/Mod/Hub/Boot.php
Normal file
268
src/Mod/Hub/Boot.php
Normal 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);
|
||||
}
|
||||
}
|
||||
158
src/Mod/Hub/Controllers/TeapotController.php
Normal file
158
src/Mod/Hub/Controllers/TeapotController.php
Normal 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> ·
|
||||
<a href="https://www.rfc-editor.org/rfc/rfc7168" target="_blank" rel="noopener">RFC 7168</a>
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
}
|
||||
}
|
||||
110
src/Mod/Hub/Database/Seeders/ServiceSeeder.php
Normal file
110
src/Mod/Hub/Database/Seeders/ServiceSeeder.php
Normal 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.");
|
||||
}
|
||||
}
|
||||
1034
src/Mod/Hub/Lang/en_GB/hub.php
Normal file
1034
src/Mod/Hub/Lang/en_GB/hub.php
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
206
src/Mod/Hub/Models/HoneypotHit.php
Normal file
206
src/Mod/Hub/Models/HoneypotHit.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
149
src/Mod/Hub/Models/Service.php
Normal file
149
src/Mod/Hub/Models/Service.php
Normal 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;
|
||||
}
|
||||
}
|
||||
255
src/Mod/Hub/Tests/Feature/HubRoutesTest.php
Normal file
255
src/Mod/Hub/Tests/Feature/HubRoutesTest.php
Normal 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();
|
||||
});
|
||||
});
|
||||
198
src/Mod/Hub/Tests/Feature/WorkspaceSwitcherTest.php
Normal file
198
src/Mod/Hub/Tests/Feature/WorkspaceSwitcherTest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
53
src/Mod/Hub/Tests/UseCase/DashboardBasic.php
Normal file
53
src/Mod/Hub/Tests/UseCase/DashboardBasic.php
Normal 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'));
|
||||
});
|
||||
});
|
||||
49
src/Search/Concerns/HasSearchProvider.php
Normal file
49
src/Search/Concerns/HasSearchProvider.php
Normal 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);
|
||||
}
|
||||
}
|
||||
120
src/Search/Contracts/SearchProvider.php
Normal file
120
src/Search/Contracts/SearchProvider.php
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Admin\Search\Contracts;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Interface for search providers.
|
||||
*
|
||||
* Modules implement this interface to contribute searchable content to the
|
||||
* global search (Command+K). Each provider is responsible for:
|
||||
*
|
||||
* - Defining a search type (e.g., 'pages', 'users', 'posts')
|
||||
* - Providing an icon for visual identification
|
||||
* - Executing searches against their data source
|
||||
* - Generating URLs for navigation to results
|
||||
*
|
||||
* ## Search Result Format
|
||||
*
|
||||
* The `search()` method should return a Collection of SearchResult objects
|
||||
* or arrays with the following structure:
|
||||
*
|
||||
* ```php
|
||||
* [
|
||||
* 'id' => 'unique-identifier',
|
||||
* 'title' => 'Result Title',
|
||||
* 'subtitle' => 'Optional description',
|
||||
* 'url' => '/path/to/resource',
|
||||
* 'icon' => 'optional-override-icon',
|
||||
* 'meta' => ['optional' => 'metadata'],
|
||||
* ]
|
||||
* ```
|
||||
*
|
||||
* ## Registration
|
||||
*
|
||||
* Providers are typically registered via `SearchProviderRegistry::register()`
|
||||
* during the AdminPanelBooting event or in a service provider's boot method.
|
||||
*
|
||||
*
|
||||
* @see SearchProviderRegistry For provider registration and discovery
|
||||
* @see SearchResult For the result data structure
|
||||
*/
|
||||
interface SearchProvider
|
||||
{
|
||||
/**
|
||||
* Get the search type identifier.
|
||||
*
|
||||
* This is used for grouping results in the UI and for filtering.
|
||||
* Examples: 'pages', 'users', 'posts', 'products', 'settings'.
|
||||
*/
|
||||
public function searchType(): string;
|
||||
|
||||
/**
|
||||
* Get the display label for this search type.
|
||||
*
|
||||
* This is shown as the group header in the search results.
|
||||
* Should be a human-readable, translatable string.
|
||||
*/
|
||||
public function searchLabel(): string;
|
||||
|
||||
/**
|
||||
* Get the icon name for this search type.
|
||||
*
|
||||
* Used to display an icon next to search results from this provider.
|
||||
* Should be a valid Heroicon or FontAwesome icon name.
|
||||
*/
|
||||
public function searchIcon(): string;
|
||||
|
||||
/**
|
||||
* Execute a search query.
|
||||
*
|
||||
* Searches the provider's data source for matches against the query.
|
||||
* Should implement fuzzy matching where appropriate for better UX.
|
||||
*
|
||||
* @param string $query The search query string
|
||||
* @param int $limit Maximum number of results to return (default: 5)
|
||||
* @return Collection<int, SearchResult|array> Collection of search results
|
||||
*/
|
||||
public function search(string $query, int $limit = 5): Collection;
|
||||
|
||||
/**
|
||||
* Get the URL for a search result.
|
||||
*
|
||||
* Generates the navigation URL for a given search result.
|
||||
* This allows providers to implement custom URL generation logic.
|
||||
*
|
||||
* @param mixed $result The search result (model or array)
|
||||
* @return string The URL to navigate to
|
||||
*/
|
||||
public function getUrl(mixed $result): string;
|
||||
|
||||
/**
|
||||
* Get the priority for ordering in search results.
|
||||
*
|
||||
* Lower numbers appear first. Default should be 50.
|
||||
* Use lower numbers (10-40) for important/frequently accessed resources.
|
||||
* Use higher numbers (60-100) for less important resources.
|
||||
*/
|
||||
public function searchPriority(): int;
|
||||
|
||||
/**
|
||||
* Check if this provider should be active for the current context.
|
||||
*
|
||||
* Override this to implement permission checks or context-based filtering.
|
||||
* For example, only show certain searches to admin users.
|
||||
*
|
||||
* @param object|null $user The authenticated user
|
||||
* @param object|null $workspace The current workspace context
|
||||
*/
|
||||
public function isAvailable(?object $user, ?object $workspace): bool;
|
||||
}
|
||||
216
src/Search/Providers/AdminPageSearchProvider.php
Normal file
216
src/Search/Providers/AdminPageSearchProvider.php
Normal 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'] ?? '#';
|
||||
}
|
||||
}
|
||||
305
src/Search/SearchProviderRegistry.php
Normal file
305
src/Search/SearchProviderRegistry.php
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Admin\Search;
|
||||
|
||||
use Core\Admin\Search\Contracts\SearchProvider;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Registry for search providers.
|
||||
*
|
||||
* Manages registration and discovery of SearchProvider implementations.
|
||||
* Coordinates searching across all registered providers and aggregates
|
||||
* results into a unified structure for the GlobalSearch component.
|
||||
*
|
||||
* ## Fuzzy Matching
|
||||
*
|
||||
* The registry provides built-in fuzzy matching support via the `fuzzyMatch()`
|
||||
* method. Providers can use this for consistent search behavior:
|
||||
*
|
||||
* ```php
|
||||
* public function search(string $query, int $limit = 5): Collection
|
||||
* {
|
||||
* $results = $this->getAllItems();
|
||||
* return $results->filter(function ($item) use ($query) {
|
||||
* return app(SearchProviderRegistry::class)
|
||||
* ->fuzzyMatch($query, $item->title);
|
||||
* })->take($limit);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
class SearchProviderRegistry
|
||||
{
|
||||
/**
|
||||
* Registered search providers.
|
||||
*
|
||||
* @var array<SearchProvider>
|
||||
*/
|
||||
protected array $providers = [];
|
||||
|
||||
/**
|
||||
* Register a search provider.
|
||||
*/
|
||||
public function register(SearchProvider $provider): void
|
||||
{
|
||||
$this->providers[] = $provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register multiple search providers.
|
||||
*
|
||||
* @param array<SearchProvider> $providers
|
||||
*/
|
||||
public function registerMany(array $providers): void
|
||||
{
|
||||
foreach ($providers as $provider) {
|
||||
$this->register($provider);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered providers.
|
||||
*
|
||||
* @return array<SearchProvider>
|
||||
*/
|
||||
public function providers(): array
|
||||
{
|
||||
return $this->providers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available providers for a given context.
|
||||
*
|
||||
* @param object|null $user The authenticated user
|
||||
* @param object|null $workspace The current workspace context
|
||||
* @return Collection<int, SearchProvider>
|
||||
*/
|
||||
public function availableProviders(?object $user, ?object $workspace): Collection
|
||||
{
|
||||
return collect($this->providers)
|
||||
->filter(fn (SearchProvider $provider) => $provider->isAvailable($user, $workspace))
|
||||
->sortBy(fn (SearchProvider $provider) => $provider->searchPriority());
|
||||
}
|
||||
|
||||
/**
|
||||
* Search across all available providers.
|
||||
*
|
||||
* Returns results grouped by search type, sorted by provider priority.
|
||||
*
|
||||
* @param string $query The search query
|
||||
* @param object|null $user The authenticated user
|
||||
* @param object|null $workspace The current workspace context
|
||||
* @param int $limitPerProvider Maximum results per provider
|
||||
* @return array<string, array{label: string, icon: string, results: array}>
|
||||
*/
|
||||
public function search(
|
||||
string $query,
|
||||
?object $user,
|
||||
?object $workspace,
|
||||
int $limitPerProvider = 5
|
||||
): array {
|
||||
$grouped = [];
|
||||
|
||||
foreach ($this->availableProviders($user, $workspace) as $provider) {
|
||||
$type = $provider->searchType();
|
||||
$results = $provider->search($query, $limitPerProvider);
|
||||
|
||||
// Convert results to array format with type/icon
|
||||
$formattedResults = $results->map(function ($result) use ($provider) {
|
||||
if ($result instanceof SearchResult) {
|
||||
return $result->withTypeAndIcon(
|
||||
$provider->searchType(),
|
||||
$provider->searchIcon()
|
||||
)->toArray();
|
||||
}
|
||||
|
||||
// Handle array results
|
||||
if (is_array($result)) {
|
||||
$searchResult = SearchResult::fromArray($result);
|
||||
|
||||
return $searchResult->withTypeAndIcon(
|
||||
$provider->searchType(),
|
||||
$provider->searchIcon()
|
||||
)->toArray();
|
||||
}
|
||||
|
||||
// Handle model objects with getUrl
|
||||
return [
|
||||
'id' => (string) ($result->id ?? uniqid()),
|
||||
'title' => (string) ($result->title ?? $result->name ?? ''),
|
||||
'subtitle' => (string) ($result->subtitle ?? $result->description ?? ''),
|
||||
'url' => $provider->getUrl($result),
|
||||
'type' => $provider->searchType(),
|
||||
'icon' => $provider->searchIcon(),
|
||||
'meta' => [],
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
if (! empty($formattedResults)) {
|
||||
$grouped[$type] = [
|
||||
'label' => $provider->searchLabel(),
|
||||
'icon' => $provider->searchIcon(),
|
||||
'results' => $formattedResults,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten search results into a single array for keyboard navigation.
|
||||
*
|
||||
* @param array $grouped Grouped search results
|
||||
*/
|
||||
public function flattenResults(array $grouped): array
|
||||
{
|
||||
$flat = [];
|
||||
|
||||
foreach ($grouped as $type => $group) {
|
||||
foreach ($group['results'] as $result) {
|
||||
$flat[] = $result;
|
||||
}
|
||||
}
|
||||
|
||||
return $flat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a query fuzzy-matches a target string.
|
||||
*
|
||||
* Supports:
|
||||
* - Case-insensitive partial matching
|
||||
* - Word-start matching (e.g., "ps" matches "Post Settings")
|
||||
* - Abbreviation matching (e.g., "gs" matches "Global Search")
|
||||
*
|
||||
* @param string $query The search query
|
||||
* @param string $target The target string to match against
|
||||
*/
|
||||
public function fuzzyMatch(string $query, string $target): bool
|
||||
{
|
||||
$query = Str::lower(trim($query));
|
||||
$target = Str::lower(trim($target));
|
||||
|
||||
// Empty query matches nothing
|
||||
if ($query === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Direct substring match (most common case)
|
||||
if (Str::contains($target, $query)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Word-start matching: each character matches start of consecutive words
|
||||
// e.g., "ps" matches "Post Settings", "gs" matches "Global Search"
|
||||
$words = preg_split('/\s+/', $target);
|
||||
$queryChars = str_split($query);
|
||||
$wordIndex = 0;
|
||||
$charIndex = 0;
|
||||
|
||||
while ($charIndex < count($queryChars) && $wordIndex < count($words)) {
|
||||
$char = $queryChars[$charIndex];
|
||||
$word = $words[$wordIndex];
|
||||
|
||||
if (Str::startsWith($word, $char)) {
|
||||
$charIndex++;
|
||||
}
|
||||
$wordIndex++;
|
||||
}
|
||||
|
||||
if ($charIndex === count($queryChars)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Abbreviation matching: all query chars appear in order
|
||||
// e.g., "gsr" matches "Global Search Results"
|
||||
$targetIndex = 0;
|
||||
foreach ($queryChars as $char) {
|
||||
$foundAt = strpos($target, $char, $targetIndex);
|
||||
if ($foundAt === false) {
|
||||
return false;
|
||||
}
|
||||
$targetIndex = $foundAt + 1;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate a relevance score for sorting results.
|
||||
*
|
||||
* Higher scores indicate better matches.
|
||||
*
|
||||
* @param string $query The search query
|
||||
* @param string $target The target string
|
||||
* @return int Score from 0-100
|
||||
*/
|
||||
public function relevanceScore(string $query, string $target): int
|
||||
{
|
||||
$query = Str::lower(trim($query));
|
||||
$target = Str::lower(trim($target));
|
||||
|
||||
if ($query === '' || $target === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Exact match
|
||||
if ($target === $query) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
// Starts with query
|
||||
if (Str::startsWith($target, $query)) {
|
||||
return 90;
|
||||
}
|
||||
|
||||
// Contains query as whole word
|
||||
if (preg_match('/\b'.preg_quote($query, '/').'\b/', $target)) {
|
||||
return 80;
|
||||
}
|
||||
|
||||
// Contains query
|
||||
if (Str::contains($target, $query)) {
|
||||
return 70;
|
||||
}
|
||||
|
||||
// Word-start matching
|
||||
$words = preg_split('/\s+/', $target);
|
||||
$queryChars = str_split($query);
|
||||
$matched = 0;
|
||||
$wordIndex = 0;
|
||||
|
||||
foreach ($queryChars as $char) {
|
||||
while ($wordIndex < count($words)) {
|
||||
if (Str::startsWith($words[$wordIndex], $char)) {
|
||||
$matched++;
|
||||
$wordIndex++;
|
||||
break;
|
||||
}
|
||||
$wordIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($matched === count($queryChars)) {
|
||||
return 60;
|
||||
}
|
||||
|
||||
// Fuzzy match
|
||||
if ($this->fuzzyMatch($query, $target)) {
|
||||
return 40;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
104
src/Search/SearchResult.php
Normal file
104
src/Search/SearchResult.php
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Admin\Search;
|
||||
|
||||
use Illuminate\Contracts\Support\Arrayable;
|
||||
use JsonSerializable;
|
||||
|
||||
/**
|
||||
* Data transfer object for search results.
|
||||
*
|
||||
* Represents a single search result from a SearchProvider. Implements
|
||||
* Arrayable and JsonSerializable for easy serialization to Livewire
|
||||
* and JavaScript.
|
||||
*/
|
||||
final class SearchResult implements Arrayable, JsonSerializable
|
||||
{
|
||||
/**
|
||||
* Create a new search result instance.
|
||||
*
|
||||
* @param string $id Unique identifier for the result
|
||||
* @param string $title Primary display text
|
||||
* @param string $url Navigation URL
|
||||
* @param string $type The search type (from provider)
|
||||
* @param string $icon Icon name for display
|
||||
* @param string|null $subtitle Secondary display text
|
||||
* @param array $meta Additional metadata
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $id,
|
||||
public readonly string $title,
|
||||
public readonly string $url,
|
||||
public readonly string $type,
|
||||
public readonly string $icon,
|
||||
public readonly ?string $subtitle = null,
|
||||
public readonly array $meta = [],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a SearchResult from an array.
|
||||
*/
|
||||
public static function fromArray(array $data): static
|
||||
{
|
||||
return new self(
|
||||
id: (string) ($data['id'] ?? uniqid()),
|
||||
title: (string) ($data['title'] ?? ''),
|
||||
url: (string) ($data['url'] ?? '#'),
|
||||
type: (string) ($data['type'] ?? 'unknown'),
|
||||
icon: (string) ($data['icon'] ?? 'document'),
|
||||
subtitle: $data['subtitle'] ?? null,
|
||||
meta: $data['meta'] ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a SearchResult with a new type and icon.
|
||||
*
|
||||
* Used by the registry to set type/icon from the provider.
|
||||
*/
|
||||
public function withTypeAndIcon(string $type, string $icon): static
|
||||
{
|
||||
return new static(
|
||||
id: $this->id,
|
||||
title: $this->title,
|
||||
url: $this->url,
|
||||
type: $type,
|
||||
icon: $this->icon !== 'document' ? $this->icon : $icon,
|
||||
subtitle: $this->subtitle,
|
||||
meta: $this->meta,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the result to an array.
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'title' => $this->title,
|
||||
'subtitle' => $this->subtitle,
|
||||
'url' => $this->url,
|
||||
'type' => $this->type,
|
||||
'icon' => $this->icon,
|
||||
'meta' => $this->meta,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify data which should be serialized to JSON.
|
||||
*/
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return $this->toArray();
|
||||
}
|
||||
}
|
||||
237
src/Search/Tests/SearchProviderRegistryTest.php
Normal file
237
src/Search/Tests/SearchProviderRegistryTest.php
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
165
src/Search/Tests/SearchResultTest.php
Normal file
165
src/Search/Tests/SearchResultTest.php
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Admin\Search\Tests;
|
||||
|
||||
use Core\Admin\Search\SearchResult;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SearchResultTest extends TestCase
|
||||
{
|
||||
public function test_can_create_search_result(): void
|
||||
{
|
||||
$result = new SearchResult(
|
||||
id: '123',
|
||||
title: 'Dashboard',
|
||||
url: '/hub',
|
||||
type: 'pages',
|
||||
icon: 'house',
|
||||
subtitle: 'Overview and quick actions',
|
||||
meta: ['key' => 'value'],
|
||||
);
|
||||
|
||||
$this->assertEquals('123', $result->id);
|
||||
$this->assertEquals('Dashboard', $result->title);
|
||||
$this->assertEquals('/hub', $result->url);
|
||||
$this->assertEquals('pages', $result->type);
|
||||
$this->assertEquals('house', $result->icon);
|
||||
$this->assertEquals('Overview and quick actions', $result->subtitle);
|
||||
$this->assertEquals(['key' => 'value'], $result->meta);
|
||||
}
|
||||
|
||||
public function test_can_create_from_array(): void
|
||||
{
|
||||
$data = [
|
||||
'id' => '456',
|
||||
'title' => 'Settings',
|
||||
'url' => '/hub/settings',
|
||||
'type' => 'pages',
|
||||
'icon' => 'gear',
|
||||
'subtitle' => 'Account settings',
|
||||
'meta' => ['order' => 2],
|
||||
];
|
||||
|
||||
$result = SearchResult::fromArray($data);
|
||||
|
||||
$this->assertEquals('456', $result->id);
|
||||
$this->assertEquals('Settings', $result->title);
|
||||
$this->assertEquals('/hub/settings', $result->url);
|
||||
$this->assertEquals('pages', $result->type);
|
||||
$this->assertEquals('gear', $result->icon);
|
||||
$this->assertEquals('Account settings', $result->subtitle);
|
||||
$this->assertEquals(['order' => 2], $result->meta);
|
||||
}
|
||||
|
||||
public function test_from_array_with_missing_fields(): void
|
||||
{
|
||||
$data = [
|
||||
'title' => 'Minimal',
|
||||
];
|
||||
|
||||
$result = SearchResult::fromArray($data);
|
||||
|
||||
$this->assertNotEmpty($result->id); // Should generate an ID
|
||||
$this->assertEquals('Minimal', $result->title);
|
||||
$this->assertEquals('#', $result->url);
|
||||
$this->assertEquals('unknown', $result->type);
|
||||
$this->assertEquals('document', $result->icon);
|
||||
$this->assertNull($result->subtitle);
|
||||
$this->assertEquals([], $result->meta);
|
||||
}
|
||||
|
||||
public function test_to_array(): void
|
||||
{
|
||||
$result = new SearchResult(
|
||||
id: '789',
|
||||
title: 'Test',
|
||||
url: '/test',
|
||||
type: 'test',
|
||||
icon: 'test-icon',
|
||||
subtitle: 'Test subtitle',
|
||||
meta: ['foo' => 'bar'],
|
||||
);
|
||||
|
||||
$array = $result->toArray();
|
||||
|
||||
$this->assertEquals([
|
||||
'id' => '789',
|
||||
'title' => 'Test',
|
||||
'subtitle' => 'Test subtitle',
|
||||
'url' => '/test',
|
||||
'type' => 'test',
|
||||
'icon' => 'test-icon',
|
||||
'meta' => ['foo' => 'bar'],
|
||||
], $array);
|
||||
}
|
||||
|
||||
public function test_json_serialize(): void
|
||||
{
|
||||
$result = new SearchResult(
|
||||
id: '1',
|
||||
title: 'JSON Test',
|
||||
url: '/json',
|
||||
type: 'json',
|
||||
icon: 'code',
|
||||
);
|
||||
|
||||
$json = json_encode($result);
|
||||
$decoded = json_decode($json, true);
|
||||
|
||||
$this->assertEquals('1', $decoded['id']);
|
||||
$this->assertEquals('JSON Test', $decoded['title']);
|
||||
$this->assertEquals('/json', $decoded['url']);
|
||||
}
|
||||
|
||||
public function test_with_type_and_icon(): void
|
||||
{
|
||||
$original = new SearchResult(
|
||||
id: '1',
|
||||
title: 'Test',
|
||||
url: '/test',
|
||||
type: 'old-type',
|
||||
icon: 'document', // Default icon
|
||||
);
|
||||
|
||||
$modified = $original->withTypeAndIcon('new-type', 'new-icon');
|
||||
|
||||
// Original should be unchanged (immutable)
|
||||
$this->assertEquals('old-type', $original->type);
|
||||
$this->assertEquals('document', $original->icon);
|
||||
|
||||
// Modified should have new values
|
||||
$this->assertEquals('new-type', $modified->type);
|
||||
$this->assertEquals('new-icon', $modified->icon);
|
||||
|
||||
// Other properties should be preserved
|
||||
$this->assertEquals('1', $modified->id);
|
||||
$this->assertEquals('Test', $modified->title);
|
||||
$this->assertEquals('/test', $modified->url);
|
||||
}
|
||||
|
||||
public function test_with_type_and_icon_preserves_custom_icon(): void
|
||||
{
|
||||
$original = new SearchResult(
|
||||
id: '1',
|
||||
title: 'Test',
|
||||
url: '/test',
|
||||
type: 'old-type',
|
||||
icon: 'custom-icon', // Not the default
|
||||
);
|
||||
|
||||
$modified = $original->withTypeAndIcon('new-type', 'fallback-icon');
|
||||
|
||||
// Should keep the custom icon, not use the fallback
|
||||
$this->assertEquals('custom-icon', $modified->icon);
|
||||
$this->assertEquals('new-type', $modified->type);
|
||||
}
|
||||
}
|
||||
195
src/Website/Hub/Boot.php
Normal file
195
src/Website/Hub/Boot.php
Normal 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'),
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
74
src/Website/Hub/Routes/admin.php
Normal file
74
src/Website/Hub/Routes/admin.php
Normal 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');
|
||||
691
src/Website/Hub/View/Blade/admin/account-usage.blade.php
Normal file
691
src/Website/Hub/View/Blade/admin/account-usage.blade.php
Normal 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>
|
||||
19
src/Website/Hub/View/Blade/admin/activity-log.blade.php
Normal file
19
src/Website/Hub/View/Blade/admin/activity-log.blade.php
Normal 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>
|
||||
316
src/Website/Hub/View/Blade/admin/ai-services.blade.php
Normal file
316
src/Website/Hub/View/Blade/admin/ai-services.blade.php
Normal 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>
|
||||
62
src/Website/Hub/View/Blade/admin/analytics.blade.php
Normal file
62
src/Website/Hub/View/Blade/admin/analytics.blade.php
Normal 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>
|
||||
90
src/Website/Hub/View/Blade/admin/boost-purchase.blade.php
Normal file
90
src/Website/Hub/View/Blade/admin/boost-purchase.blade.php
Normal 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>
|
||||
|
|
@ -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
|
||||
183
src/Website/Hub/View/Blade/admin/components/header.blade.php
Normal file
183
src/Website/Hub/View/Blade/admin/components/header.blade.php
Normal 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>
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
<admin:sidebar logo="/images/host-uk-raven.svg" logoText="Host Hub" :logoRoute="route('hub.dashboard')">
|
||||
<admin:sidemenu />
|
||||
</admin:sidebar>
|
||||
|
||||
132
src/Website/Hub/View/Blade/admin/console.blade.php
Normal file
132
src/Website/Hub/View/Blade/admin/console.blade.php
Normal 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>
|
||||
654
src/Website/Hub/View/Blade/admin/content-editor.blade.php
Normal file
654
src/Website/Hub/View/Blade/admin/content-editor.blade.php
Normal 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>
|
||||
161
src/Website/Hub/View/Blade/admin/content-manager.blade.php
Normal file
161
src/Website/Hub/View/Blade/admin/content-manager.blade.php
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
176
src/Website/Hub/View/Blade/admin/content-manager/list.blade.php
Normal file
176
src/Website/Hub/View/Blade/admin/content-manager/list.blade.php
Normal 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>
|
||||
|
|
@ -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>
|
||||
298
src/Website/Hub/View/Blade/admin/content.blade.php
Normal file
298
src/Website/Hub/View/Blade/admin/content.blade.php
Normal 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>
|
||||
96
src/Website/Hub/View/Blade/admin/dashboard.blade.php
Normal file
96
src/Website/Hub/View/Blade/admin/dashboard.blade.php
Normal 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>
|
||||
233
src/Website/Hub/View/Blade/admin/databases.blade.php
Normal file
233
src/Website/Hub/View/Blade/admin/databases.blade.php
Normal 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>
|
||||
160
src/Website/Hub/View/Blade/admin/deployments.blade.php
Normal file
160
src/Website/Hub/View/Blade/admin/deployments.blade.php
Normal 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']))
|
||||
· {{ $service['details']['memory'] }}
|
||||
@endif
|
||||
@if(isset($service['details']['pending']))
|
||||
· {{ $service['details']['pending'] }} pending
|
||||
@endif
|
||||
@if(isset($service['details']['used_percent']))
|
||||
· {{ $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'] }} · {{ $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>
|
||||
148
src/Website/Hub/View/Blade/admin/dev/cache.blade.php
Normal file
148
src/Website/Hub/View/Blade/admin/dev/cache.blade.php
Normal 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>
|
||||
112
src/Website/Hub/View/Blade/admin/dev/logs.blade.php
Normal file
112
src/Website/Hub/View/Blade/admin/dev/logs.blade.php
Normal 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>
|
||||
111
src/Website/Hub/View/Blade/admin/dev/routes.blade.php
Normal file
111
src/Website/Hub/View/Blade/admin/dev/routes.blade.php
Normal 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>
|
||||
452
src/Website/Hub/View/Blade/admin/entitlement/dashboard.blade.php
Normal file
452
src/Website/Hub/View/Blade/admin/entitlement/dashboard.blade.php
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
211
src/Website/Hub/View/Blade/admin/global-search.blade.php
Normal file
211
src/Website/Hub/View/Blade/admin/global-search.blade.php
Normal 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>
|
||||
180
src/Website/Hub/View/Blade/admin/honeypot.blade.php
Normal file
180
src/Website/Hub/View/Blade/admin/honeypot.blade.php
Normal 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>
|
||||
126
src/Website/Hub/View/Blade/admin/layouts/app.blade.php
Normal file
126
src/Website/Hub/View/Blade/admin/layouts/app.blade.php
Normal 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>
|
||||
706
src/Website/Hub/View/Blade/admin/platform-user.blade.php
Normal file
706
src/Website/Hub/View/Blade/admin/platform-user.blade.php
Normal 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>
|
||||
278
src/Website/Hub/View/Blade/admin/platform.blade.php
Normal file
278
src/Website/Hub/View/Blade/admin/platform.blade.php
Normal 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>
|
||||
175
src/Website/Hub/View/Blade/admin/profile.blade.php
Normal file
175
src/Website/Hub/View/Blade/admin/profile.blade.php
Normal 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>
|
||||
242
src/Website/Hub/View/Blade/admin/prompt-manager.blade.php
Normal file
242
src/Website/Hub/View/Blade/admin/prompt-manager.blade.php
Normal 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
|
||||
79
src/Website/Hub/View/Blade/admin/service-manager.blade.php
Normal file
79
src/Website/Hub/View/Blade/admin/service-manager.blade.php
Normal 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>
|
||||
1900
src/Website/Hub/View/Blade/admin/services-admin.blade.php
Normal file
1900
src/Website/Hub/View/Blade/admin/services-admin.blade.php
Normal file
File diff suppressed because it is too large
Load diff
390
src/Website/Hub/View/Blade/admin/settings.blade.php
Normal file
390
src/Website/Hub/View/Blade/admin/settings.blade.php
Normal 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>
|
||||
253
src/Website/Hub/View/Blade/admin/site-settings.blade.php
Normal file
253
src/Website/Hub/View/Blade/admin/site-settings.blade.php
Normal 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>
|
||||
72
src/Website/Hub/View/Blade/admin/sites.blade.php
Normal file
72
src/Website/Hub/View/Blade/admin/sites.blade.php
Normal 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>
|
||||
209
src/Website/Hub/View/Blade/admin/usage-dashboard.blade.php
Normal file
209
src/Website/Hub/View/Blade/admin/usage-dashboard.blade.php
Normal 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>
|
||||
40
src/Website/Hub/View/Blade/admin/waitlist-manager.blade.php
Normal file
40
src/Website/Hub/View/Blade/admin/waitlist-manager.blade.php
Normal 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>
|
||||
|
|
@ -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>
|
||||
150
src/Website/Hub/View/Blade/admin/wp-connector-settings.blade.php
Normal file
150
src/Website/Hub/View/Blade/admin/wp-connector-settings.blade.php
Normal 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>
|
||||
179
src/Website/Hub/View/Modal/Admin/AIServices.php
Normal file
179
src/Website/Hub/View/Modal/Admin/AIServices.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
339
src/Website/Hub/View/Modal/Admin/AccountUsage.php
Normal file
339
src/Website/Hub/View/Modal/Admin/AccountUsage.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
181
src/Website/Hub/View/Modal/Admin/ActivityLog.php
Normal file
181
src/Website/Hub/View/Modal/Admin/ActivityLog.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
69
src/Website/Hub/View/Modal/Admin/Analytics.php
Normal file
69
src/Website/Hub/View/Modal/Admin/Analytics.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
77
src/Website/Hub/View/Modal/Admin/BoostPurchase.php
Normal file
77
src/Website/Hub/View/Modal/Admin/BoostPurchase.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
53
src/Website/Hub/View/Modal/Admin/Console.php
Normal file
53
src/Website/Hub/View/Modal/Admin/Console.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
295
src/Website/Hub/View/Modal/Admin/Content.php
Normal file
295
src/Website/Hub/View/Modal/Admin/Content.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
843
src/Website/Hub/View/Modal/Admin/ContentEditor.php
Normal file
843
src/Website/Hub/View/Modal/Admin/ContentEditor.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
520
src/Website/Hub/View/Modal/Admin/ContentManager.php
Normal file
520
src/Website/Hub/View/Modal/Admin/ContentManager.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
22
src/Website/Hub/View/Modal/Admin/Dashboard.php
Normal file
22
src/Website/Hub/View/Modal/Admin/Dashboard.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
219
src/Website/Hub/View/Modal/Admin/Databases.php
Normal file
219
src/Website/Hub/View/Modal/Admin/Databases.php
Normal 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');
|
||||
}
|
||||
}
|
||||
274
src/Website/Hub/View/Modal/Admin/Deployments.php
Normal file
274
src/Website/Hub/View/Modal/Admin/Deployments.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
534
src/Website/Hub/View/Modal/Admin/Entitlement/Dashboard.php
Normal file
534
src/Website/Hub/View/Modal/Admin/Entitlement/Dashboard.php
Normal 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');
|
||||
}
|
||||
}
|
||||
259
src/Website/Hub/View/Modal/Admin/Entitlement/FeatureManager.php
Normal file
259
src/Website/Hub/View/Modal/Admin/Entitlement/FeatureManager.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
306
src/Website/Hub/View/Modal/Admin/Entitlement/PackageManager.php
Normal file
306
src/Website/Hub/View/Modal/Admin/Entitlement/PackageManager.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
257
src/Website/Hub/View/Modal/Admin/GlobalSearch.php
Normal file
257
src/Website/Hub/View/Modal/Admin/GlobalSearch.php
Normal 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');
|
||||
}
|
||||
}
|
||||
84
src/Website/Hub/View/Modal/Admin/Honeypot.php
Normal file
84
src/Website/Hub/View/Modal/Admin/Honeypot.php
Normal 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
Loading…
Add table
Reference in a new issue