feat(components): add new Blade components for Flux UI including icons, charts, and form elements

This commit is contained in:
Snider 2026-01-26 21:21:53 +00:00
parent 294e73e189
commit b05e3a0c13
1263 changed files with 1204 additions and 149451 deletions

View file

@ -1,65 +0,0 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"

View file

@ -11,13 +11,13 @@ A modular monolith framework for Laravel with event-driven architecture, lazy mo
## Documentation
📚 **[Read the full documentation →](https://host-uk.github.io/core-php/)**
📚 **[Read the full documentation →](https://core.help/)**
- [Getting Started](https://host-uk.github.io/core-php/guide/getting-started)
- [Installation Guide](https://host-uk.github.io/core-php/guide/installation)
- [Architecture Overview](https://host-uk.github.io/core-php/architecture/lifecycle-events)
- [API Reference](https://host-uk.github.io/core-php/packages/api)
- [Security Guide](https://host-uk.github.io/core-php/security/overview)
- [Getting Started](https://core.help/guide/getting-started)
- [Installation Guide](https://core.help/guide/installation)
- [Architecture Overview](https://core.help/architecture/lifecycle-events)
- [API Reference](https://core.help/packages/api)
- [Security Guide](https://core.help/security/overview)
## Features

View file

@ -8,7 +8,8 @@ No pending tasks.
For completed features and implementation details, see each package's changelog:
- `packages/core-php/changelog/`
- `packages/core-admin/changelog/`
- `packages/core-api/changelog/`
- `packages/core-mcp/changelog/`
- `changelog/` (this repo)
- [core-admin changelog](https://github.com/host-uk/core-admin)
- [core-api changelog](https://github.com/host-uk/core-api)
- [core-mcp changelog](https://github.com/host-uk/core-mcp)
- [core-tenant changelog](https://github.com/host-uk/core-tenant)

View file

@ -1,96 +0,0 @@
<?php
declare(strict_types=1);
namespace Website\Demo;
use Core\Events\DomainResolving;
use Core\Events\WebRoutesRegistering;
use Core\Website\DomainResolver;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
/**
* Demo Mod - Example marketing site.
*
* Shows how to create a website module for the Core PHP framework.
* Uses the event-driven $listens pattern for lazy loading.
*/
class Boot extends ServiceProvider
{
/**
* Domain patterns this website responds to.
*
* @var array<string>
*/
public static array $domains = [
'/^core\.(test|localhost)$/',
];
/**
* Events this module listens to for lazy loading.
*
* @var array<class-string, string>
*/
public static array $listens = [
DomainResolving::class => 'onDomainResolving',
WebRoutesRegistering::class => 'onWebRoutes',
];
/**
* 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 public web routes.
*/
public function onWebRoutes(WebRoutesRegistering $event): void
{
$event->views('demo', __DIR__.'/View/Blade');
// Register routes for all configured domains
$domains = $this->domains();
if (empty($domains)) {
// No domain mapping - register globally (for demo/dev)
$event->routes(fn () => Route::middleware('web')
->group(__DIR__.'/Routes/web.php'));
} else {
foreach ($domains as $domain) {
$event->routes(fn () => Route::middleware('web')
->domain($domain)
->group(__DIR__.'/Routes/web.php'));
}
}
// Livewire components - names must match Livewire's auto-discovery from namespace
$event->livewire('website.demo.view.modal.landing', View\Modal\Landing::class);
$event->livewire('website.demo.view.modal.login', View\Modal\Login::class);
$event->livewire('website.demo.view.modal.install', View\Modal\Install::class);
}
}

View file

@ -1,70 +0,0 @@
<?php
declare(strict_types=1);
namespace Website\Demo\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\HttpFoundation\Response;
/**
* Middleware to ensure the application is installed.
*
* Redirects to the install wizard if:
* - Database tables don't exist
* - No users have been created
*/
class EnsureInstalled
{
/**
* Routes that should be accessible even when not installed.
*/
protected array $except = [
'install',
'install/*',
];
public function handle(Request $request, Closure $next): Response
{
// Skip check for install routes
if ($this->shouldSkip($request)) {
return $next($request);
}
// Check if app needs installation
if ($this->needsInstallation()) {
return redirect()->route('install');
}
return $next($request);
}
protected function shouldSkip(Request $request): bool
{
foreach ($this->except as $pattern) {
if ($request->is($pattern)) {
return true;
}
}
return false;
}
protected function needsInstallation(): bool
{
try {
// Check if users table exists and has at least one user
if (! Schema::hasTable('users')) {
return true;
}
// Check if any users exist
return \DB::table('users')->count() === 0;
} catch (\Exception $e) {
// Database connection failed - needs installation
return true;
}
}
}

View file

@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
use Website\Demo\Middleware\EnsureInstalled;
use Website\Demo\View\Modal\Install;
use Website\Demo\View\Modal\Landing;
use Website\Demo\View\Modal\Login;
/*
|--------------------------------------------------------------------------
| Demo Mod Routes
|--------------------------------------------------------------------------
*/
// Install wizard (always accessible)
Route::get('/install', Install::class)->name('install');
// Routes that require installation
Route::middleware(EnsureInstalled::class)->group(function () {
Route::get('/', Landing::class)->name('home');
// Authentication routes
Route::get('/login', Login::class)
->middleware('guest')
->name('login');
Route::match(['get', 'post'], '/logout', function () {
Auth::logout();
request()->session()->invalidate();
request()->session()->regenerateToken();
return redirect('/');
})->middleware('auth')->name('logout');
});

View file

@ -1,28 +0,0 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? config('app.name', 'Core PHP') }}</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
@fluxAppearance
@livewireStyles
</head>
<body class="font-sans antialiased bg-zinc-900 text-zinc-100 min-h-screen">
<!-- Background gradient -->
<div class="fixed inset-0 -z-10">
<div class="absolute inset-0 bg-gradient-to-b from-zinc-900 via-zinc-900 to-zinc-950"></div>
<div class="absolute top-0 left-1/2 -translate-x-1/2 w-[800px] h-[600px] bg-violet-500/10 blur-[120px] rounded-full"></div>
</div>
<div class="max-w-5xl mx-auto px-4 sm:px-6 py-12">
{{ $slot }}
</div>
@fluxScripts
@livewireScripts
</body>
</html>

View file

@ -1,246 +0,0 @@
<div class="flex min-h-[70vh] items-center justify-center">
<div class="w-full max-w-lg">
{{-- Header --}}
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-white mb-2">Install {{ config('app.name', 'Core PHP') }}</h1>
<p class="text-zinc-400">Let's get your application set up</p>
</div>
{{-- Progress Steps --}}
<div class="flex items-center justify-center mb-8">
@foreach ([1 => 'Requirements', 2 => 'Admin User', 3 => 'Complete'] as $num => $label)
<div class="flex items-center">
<div class="flex flex-col items-center">
<div @class([
'w-10 h-10 rounded-full flex items-center justify-center text-sm font-medium transition',
'bg-violet-600 text-white' => $step >= $num,
'bg-zinc-700 text-zinc-400' => $step < $num,
])>
@if ($step > $num)
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
@else
{{ $num }}
@endif
</div>
<span class="text-xs mt-1 {{ $step >= $num ? 'text-zinc-300' : 'text-zinc-500' }}">{{ $label }}</span>
</div>
@if ($num < 3)
<div @class([
'w-16 h-0.5 mx-2 mb-5',
'bg-violet-600' => $step > $num,
'bg-zinc-700' => $step <= $num,
])></div>
@endif
</div>
@endforeach
</div>
{{-- Error/Success Messages --}}
@if ($error)
<div class="mb-4 p-4 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm">
{{ $error }}
</div>
@endif
@if ($success)
<div class="mb-4 p-4 bg-green-500/10 border border-green-500/20 rounded-lg text-green-400 text-sm">
{{ $success }}
</div>
@endif
{{-- Step 1: Requirements --}}
@if ($step === 1)
<div class="bg-zinc-800/50 rounded-xl p-6">
<h2 class="text-lg font-semibold text-white mb-4">System Requirements</h2>
<div class="space-y-3">
@foreach ($checks as $key => $check)
<div class="flex items-center justify-between p-3 bg-zinc-900/50 rounded-lg">
<div class="flex items-center gap-3">
@if ($check['passed'])
<div class="w-6 h-6 rounded-full bg-green-500/20 flex items-center justify-center">
<svg class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</div>
@else
<div class="w-6 h-6 rounded-full bg-red-500/20 flex items-center justify-center">
<svg class="w-4 h-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</div>
@endif
<div>
<div class="text-sm font-medium text-white">{{ $check['label'] }}</div>
<div class="text-xs text-zinc-500">{{ $check['description'] }}</div>
</div>
</div>
<span class="text-sm {{ $check['passed'] ? 'text-green-400' : 'text-red-400' }}">
{{ $check['value'] }}
</span>
</div>
@endforeach
</div>
@if (!$checks['migrations']['passed'])
<button
wire:click="runMigrations"
wire:loading.attr="disabled"
class="mt-4 w-full px-4 py-2 bg-zinc-700 hover:bg-zinc-600 text-white rounded-lg text-sm font-medium transition disabled:opacity-50"
>
<span wire:loading.remove wire:target="runMigrations">Run Migrations</span>
<span wire:loading wire:target="runMigrations">Running migrations...</span>
</button>
@endif
<div class="mt-6 flex justify-end">
<button
wire:click="nextStep"
@disabled(!collect($checks)->every(fn ($c) => $c['passed']))
class="px-6 py-2 bg-violet-600 hover:bg-violet-500 disabled:bg-zinc-700 disabled:text-zinc-500 text-white rounded-lg font-medium transition disabled:cursor-not-allowed"
>
Continue
</button>
</div>
</div>
@endif
{{-- Step 2: Create Admin User --}}
@if ($step === 2)
<form wire:submit="createUser" class="bg-zinc-800/50 rounded-xl p-6 space-y-5">
<h2 class="text-lg font-semibold text-white mb-4">Create Admin Account</h2>
<div>
<label for="name" class="block text-sm font-medium text-zinc-300 mb-2">Name</label>
<input
wire:model="name"
type="text"
id="name"
class="w-full px-4 py-3 bg-zinc-900 border border-zinc-700 rounded-lg text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:border-transparent transition"
placeholder="Your name"
>
@error('name')
<p class="mt-2 text-sm text-red-400">{{ $message }}</p>
@enderror
</div>
<div>
<label for="email" class="block text-sm font-medium text-zinc-300 mb-2">Email address</label>
<input
wire:model="email"
type="email"
id="email"
class="w-full px-4 py-3 bg-zinc-900 border border-zinc-700 rounded-lg text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:border-transparent transition"
placeholder="admin@example.com"
>
@error('email')
<p class="mt-2 text-sm text-red-400">{{ $message }}</p>
@enderror
</div>
<div>
<label for="password" class="block text-sm font-medium text-zinc-300 mb-2">Password</label>
<input
wire:model="password"
type="password"
id="password"
class="w-full px-4 py-3 bg-zinc-900 border border-zinc-700 rounded-lg text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:border-transparent transition"
placeholder="Minimum 8 characters"
>
@error('password')
<p class="mt-2 text-sm text-red-400">{{ $message }}</p>
@enderror
</div>
<div>
<label for="password_confirmation" class="block text-sm font-medium text-zinc-300 mb-2">Confirm Password</label>
<input
wire:model="password_confirmation"
type="password"
id="password_confirmation"
class="w-full px-4 py-3 bg-zinc-900 border border-zinc-700 rounded-lg text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:border-transparent transition"
placeholder="Confirm your password"
>
</div>
<label class="flex items-center gap-3 cursor-pointer p-3 bg-zinc-900/50 rounded-lg">
<input
wire:model="createDemo"
type="checkbox"
class="w-4 h-4 rounded border-zinc-600 bg-zinc-900 text-violet-600 focus:ring-violet-500 focus:ring-offset-zinc-900"
>
<div>
<span class="text-sm text-zinc-300">Create demo user</span>
<p class="text-xs text-zinc-500">demo@example.com / password</p>
</div>
</label>
<div class="flex justify-between pt-2">
<button
type="button"
wire:click="previousStep"
class="px-6 py-2 bg-zinc-700 hover:bg-zinc-600 text-white rounded-lg font-medium transition"
>
Back
</button>
<button
type="submit"
wire:loading.attr="disabled"
class="px-6 py-2 bg-violet-600 hover:bg-violet-500 text-white rounded-lg font-medium transition disabled:opacity-50"
>
<span wire:loading.remove wire:target="createUser">Create Account</span>
<span wire:loading wire:target="createUser">Creating...</span>
</button>
</div>
</form>
@endif
{{-- Step 3: Complete --}}
@if ($step === 3)
<div class="bg-zinc-800/50 rounded-xl p-6 text-center">
<div class="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</div>
<h2 class="text-xl font-semibold text-white mb-2">Installation Complete!</h2>
<p class="text-zinc-400 mb-6">
{{ config('app.name', 'Core PHP') }} is ready to use.
</p>
<div class="bg-zinc-900/50 rounded-lg p-4 mb-6 text-left">
<h3 class="text-sm font-medium text-zinc-300 mb-2">Your credentials:</h3>
<div class="space-y-1 text-sm">
<div class="flex justify-between">
<span class="text-zinc-500">Email:</span>
<span class="text-white">{{ $email }}</span>
</div>
@if ($createDemo)
<div class="border-t border-zinc-800 my-2 pt-2">
<span class="text-zinc-500 text-xs">Demo account:</span>
</div>
<div class="flex justify-between">
<span class="text-zinc-500">Email:</span>
<span class="text-white">demo@example.com</span>
</div>
<div class="flex justify-between">
<span class="text-zinc-500">Password:</span>
<span class="text-white">password</span>
</div>
@endif
</div>
</div>
<button
wire:click="finish"
class="w-full px-6 py-3 bg-violet-600 hover:bg-violet-500 text-white rounded-lg font-medium transition"
>
Go to Dashboard
</button>
</div>
@endif
</div>
</div>

View file

@ -1,56 +0,0 @@
<div class="text-center">
{{-- Hero Section --}}
<div class="py-12 md:py-20">
<h1 class="text-4xl md:text-6xl font-bold text-white mb-6">
{{ config('app.name', 'Core PHP') }}
</h1>
<p class="text-xl text-zinc-400 max-w-2xl mx-auto mb-8">
A modular monolith framework for Laravel.
Build SaaS applications with event-driven architecture.
</p>
<div class="flex flex-wrap justify-center gap-4">
<a href="https://github.com/host-uk/core-php"
class="inline-flex items-center gap-2 px-6 py-3 bg-violet-600 hover:bg-violet-500 text-white rounded-lg font-medium transition">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
View on GitHub
</a>
<a href="/hub"
class="inline-flex items-center gap-2 px-6 py-3 bg-zinc-700 hover:bg-zinc-600 text-white rounded-lg font-medium transition">
Open Dashboard
</a>
</div>
</div>
{{-- Features Grid --}}
<div class="grid md:grid-cols-3 gap-8 py-12">
<div class="p-6 bg-zinc-800/50 rounded-xl">
<div class="w-12 h-12 bg-violet-600/20 rounded-lg flex items-center justify-center mb-4 mx-auto">
<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="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-white mb-2">Modular Architecture</h3>
<p class="text-zinc-400">Event-driven modules that load lazily. Only what you need, when you need it.</p>
</div>
<div class="p-6 bg-zinc-800/50 rounded-xl">
<div class="w-12 h-12 bg-violet-600/20 rounded-lg flex items-center justify-center mb-4 mx-auto">
<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="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-white mb-2">Multi-Website</h3>
<p class="text-zinc-400">Domain-scoped website modules. Each site isolated, all in one codebase.</p>
</div>
<div class="p-6 bg-zinc-800/50 rounded-xl">
<div class="w-12 h-12 bg-violet-600/20 rounded-lg flex items-center justify-center mb-4 mx-auto">
<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="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-white mb-2">Flux UI Ready</h3>
<p class="text-zinc-400">Built for Livewire 4 and Flux UI. Modern, composable components.</p>
</div>
</div>
</div>

View file

@ -1,43 +0,0 @@
<div class="flex min-h-[60vh] items-center justify-center">
<div class="w-full max-w-md">
{{-- Header --}}
<div class="text-center mb-8">
<flux:heading size="xl">Sign in to {{ config('app.name', 'Core PHP') }}</flux:heading>
<flux:subheading>Enter your credentials to continue</flux:subheading>
</div>
{{-- Login Form --}}
<form wire:submit="login" class="bg-zinc-800/50 rounded-xl p-6 space-y-6">
{{-- Email --}}
<flux:input
wire:model="email"
type="email"
label="Email address"
placeholder="you@example.com"
autocomplete="email"
/>
{{-- Password --}}
<flux:input
wire:model="password"
type="password"
label="Password"
placeholder="Enter your password"
autocomplete="current-password"
/>
{{-- Remember Me --}}
<flux:checkbox wire:model="remember" label="Remember me" />
{{-- Submit --}}
<flux:button type="submit" variant="primary" class="w-full">
Sign in
</flux:button>
</form>
{{-- Back to home --}}
<flux:link href="/" class="block text-center mt-6">
&larr; Back to home
</flux:link>
</div>
</div>

View file

@ -1,216 +0,0 @@
<?php
declare(strict_types=1);
namespace Website\Demo\View\Modal;
use Core\Mod\Tenant\Models\User;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* Installation Wizard Component.
*
* Guides users through initial application setup.
*/
class Install extends Component
{
public int $step = 1;
public array $checks = [];
// Step 2: Admin user details
public string $name = '';
public string $email = '';
public string $password = '';
public string $password_confirmation = '';
public bool $createDemo = true;
public ?string $error = null;
public ?string $success = null;
public function mount(): void
{
$this->runChecks();
// If already installed, redirect
if ($this->isInstalled()) {
$this->redirect('/', navigate: true);
}
}
public function runChecks(): void
{
$this->checks = [
'php' => [
'label' => 'PHP Version',
'description' => 'PHP 8.2 or higher required',
'passed' => version_compare(PHP_VERSION, '8.2.0', '>='),
'value' => PHP_VERSION,
],
'database' => [
'label' => 'Database Connection',
'description' => 'MySQL/MariaDB/SQLite connection',
'passed' => $this->checkDatabase(),
'value' => $this->getDatabaseInfo(),
],
'migrations' => [
'label' => 'Database Tables',
'description' => 'Core tables created',
'passed' => $this->checkMigrations(),
'value' => $this->checkMigrations() ? 'Ready' : 'Pending',
],
'storage' => [
'label' => 'Storage Writable',
'description' => 'storage/ directory is writable',
'passed' => is_writable(storage_path()),
'value' => is_writable(storage_path()) ? 'Writable' : 'Not writable',
],
'cache' => [
'label' => 'Cache Writable',
'description' => 'bootstrap/cache/ is writable',
'passed' => is_writable(base_path('bootstrap/cache')),
'value' => is_writable(base_path('bootstrap/cache')) ? 'Writable' : 'Not writable',
],
];
}
protected function checkDatabase(): bool
{
try {
DB::connection()->getPdo();
return true;
} catch (\Exception $e) {
return false;
}
}
protected function getDatabaseInfo(): string
{
try {
$driver = config('database.default');
$database = config("database.connections.{$driver}.database");
return ucfirst($driver).': '.$database;
} catch (\Exception $e) {
return 'Not configured';
}
}
protected function checkMigrations(): bool
{
try {
return Schema::hasTable('users');
} catch (\Exception $e) {
return false;
}
}
protected function isInstalled(): bool
{
try {
return Schema::hasTable('users') && DB::table('users')->count() > 0;
} catch (\Exception $e) {
return false;
}
}
public function runMigrations(): void
{
$this->error = null;
try {
Artisan::call('migrate', ['--force' => true]);
$this->runChecks();
$this->success = 'Migrations completed successfully!';
} catch (\Exception $e) {
$this->error = 'Migration failed: '.$e->getMessage();
}
}
public function nextStep(): void
{
$this->error = null;
$this->success = null;
if ($this->step === 1) {
// Validate all checks pass
$allPassed = collect($this->checks)->every(fn ($check) => $check['passed']);
if (! $allPassed) {
$this->error = 'Please resolve all requirements before continuing.';
return;
}
}
$this->step++;
}
public function previousStep(): void
{
$this->error = null;
$this->success = null;
$this->step = max(1, $this->step - 1);
}
public function createUser(): void
{
$this->error = null;
$this->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
]);
try {
// Create admin user
$user = User::create([
'name' => $this->name,
'email' => $this->email,
'password' => Hash::make($this->password),
'email_verified_at' => now(),
]);
// Create demo user if requested
if ($this->createDemo) {
User::create([
'name' => 'Demo User',
'email' => 'demo@example.com',
'password' => Hash::make('password'),
'email_verified_at' => now(),
]);
}
// Log in as the new admin
Auth::login($user);
$this->step = 3;
} catch (\Exception $e) {
$this->error = 'Failed to create user: '.$e->getMessage();
}
}
public function finish(): void
{
$this->redirect('/hub', navigate: true);
}
#[Layout('demo::layouts.app', ['title' => 'Install'])]
public function render()
{
return view('demo::web.install');
}
}

View file

@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
namespace Website\Demo\View\Modal;
use Livewire\Component;
/**
* Demo Landing Page.
*
* Simple landing page for the demo website.
*/
class Landing extends Component
{
public function render()
{
return view('demo::web.landing')
->layout('demo::layouts.app', [
'title' => config('app.name', 'Core PHP'),
]);
}
}

View file

@ -1,66 +0,0 @@
<?php
declare(strict_types=1);
namespace Website\Demo\View\Modal;
use Core\Helpers\LoginRateLimiter;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* Login Page Component.
*
* Handles user authentication with rate limiting.
*/
class Login extends Component
{
public string $email = '';
public string $password = '';
public bool $remember = false;
/**
* Attempt to authenticate the user.
*/
public function login(): void
{
$this->validate([
'email' => ['required', 'email'],
'password' => ['required', 'string'],
]);
$limiter = app(LoginRateLimiter::class);
if ($limiter->tooManyAttempts(request())) {
throw ValidationException::withMessages([
'email' => __('auth.throttle', [
'seconds' => $limiter->availableIn(request()),
]),
]);
}
if (! Auth::attempt(['email' => $this->email, 'password' => $this->password], $this->remember)) {
$limiter->increment(request());
throw ValidationException::withMessages([
'email' => __('auth.failed'),
]);
}
$limiter->clear(request());
session()->regenerate();
$this->redirect('/hub', navigate: true);
}
#[Layout('demo::layouts.app', ['title' => 'Sign In'])]
public function render()
{
return view('demo::web.login');
}
}

13
artisan
View file

@ -1,13 +0,0 @@
#!/usr/bin/env php
<?php
use Core\Boot;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
require __DIR__.'/vendor/autoload.php';
$status = Boot::app()->handleCommand(new ArgvInput);
exit($status);

View file

@ -1,19 +0,0 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
//
})
->withExceptions(function (Exceptions $exceptions) {
//
})->create();

View file

View file

@ -1,108 +1,62 @@
{
"name": "host-uk/core-app",
"type": "project",
"description": "Core PHP Framework - Demo Application",
"keywords": ["laravel", "modular", "monolith", "framework"],
"name": "host-uk/core",
"description": "Modular monolith framework for Laravel - event-driven architecture with lazy module loading",
"keywords": ["laravel", "modular", "monolith", "framework", "events", "modules"],
"license": "EUPL-1.2",
"authors": [
{
"name": "Host UK",
"email": "support@host.uk.com"
}
],
"require": {
"php": "^8.2",
"host-uk/core": "@dev",
"host-uk/core-admin": "@dev",
"host-uk/core-api": "@dev",
"host-uk/core-mcp": "@dev",
"laravel/framework": "^12.0",
"laravel/pennant": "^1.18",
"laravel/tinker": "^2.10.1",
"livewire/flux": "^2.0",
"livewire/flux-pro": "^2.10",
"livewire/livewire": "^3.0"
"laravel/framework": "^11.0|^12.0",
"laravel/pennant": "^1.0",
"livewire/livewire": "^3.0|^4.0"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.18",
"laravel/sail": "^1.41",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"orchestra/testbench": "*",
"phpunit/phpunit": "^11.5.3",
"orchestra/testbench": "^9.0|^10.0",
"phpunit/phpunit": "^11.5",
"spatie/laravel-activitylog": "^4.8"
},
"suggest": {
"spatie/laravel-activitylog": "Required for activity logging features (^4.0)"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Website\\": "app/Website/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
"Core\\": "src/Core/",
"Core\\Website\\": "src/Website/",
"Core\\Mod\\": "src/Mod/",
"Core\\Plug\\": "src/Plug/"
},
"files": [
"src/Core/Media/Thumbnail/helpers.php"
]
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/",
"Core\\Tests\\": "packages/core-php/tests/",
"Core\\Mod\\Mcp\\Tests\\": "packages/core-mcp/tests/",
"Core\\Mod\\Tenant\\Tests\\": "packages/core-php/src/Mod/Tenant/Tests/",
"Mod\\": "packages/core-php/tests/Fixtures/Mod/",
"Plug\\": "packages/core-php/tests/Fixtures/Plug/",
"Website\\": "packages/core-php/tests/Fixtures/Website/"
"Core\\Tests\\": "tests/",
"Mod\\": "tests/Fixtures/Mod/",
"Plug\\": "tests/Fixtures/Plug/",
"Website\\": "tests/Fixtures/Website/"
}
},
"repositories": [
{
"name": "flux-pro",
"type": "composer",
"url": "https://composer.fluxui.dev"
},
{
"type": "path",
"url": "packages/core-php",
"options": {
"symlink": true
}
},
{
"type": "path",
"url": "packages/core-admin",
"options": {
"symlink": true
}
},
{
"type": "path",
"url": "packages/core-api",
"options": {
"symlink": true
}
},
{
"type": "path",
"url": "packages/core-mcp",
"options": {
"symlink": true
}
}
],
"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"
],
"test": "vendor/bin/phpunit"
"test": "vendor/bin/phpunit",
"pint": "vendor/bin/pint"
},
"extra": {
"laravel": {
"dont-discover": []
"providers": [
"Core\\LifecycleEventProvider",
"Core\\Lang\\LangServiceProvider",
"Core\\Bouncer\\Gate\\Boot"
]
}
},
"config": {

View file

@ -1,126 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
|
*/
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| the application so that it's available within Artisan commands.
|
*/
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. The timezone
| is set to "UTC" by default as it is suitable for most use cases.
|
*/
'timezone' => 'UTC',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by Laravel's translation / localization methods. This option can be
| set to any locale for which you plan to have translation strings.
|
*/
'locale' => env('APP_LOCALE', 'en_GB'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en_GB'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is utilized by Laravel's encryption services and should be set
| to a random, 32 character string to ensure that all encrypted values
| are secure. You should do this prior to deploying the application.
|
*/
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],
/*
|--------------------------------------------------------------------------
| Maintenance Mode Driver
|--------------------------------------------------------------------------
|
| These configuration options determine the driver used to determine and
| manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines.
|
| Supported drivers: "file", "cache"
|
*/
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
];

View file

@ -1,115 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| which utilizes session storage plus the Eloquent user provider.
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| Supported: "session"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| If you have multiple user tables or models you may configure multiple
| providers to represent the model / table. These providers may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', Core\Mod\Tenant\Models\User::class),
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| These configuration options specify the behavior of Laravel's password
| reset functionality, including the table utilized for token storage
| and the user provider that is invoked to actually retrieve users.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the number of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];

View file

@ -1,117 +0,0 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache store that will be used by the
| framework. This connection is utilized if another isn't explicitly
| specified when running a cache operation inside the application.
|
*/
'default' => env('CACHE_STORE', 'database'),
/*
|--------------------------------------------------------------------------
| Cache Stores
|--------------------------------------------------------------------------
|
| Here you may define all of the cache "stores" for your application as
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
| Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "octane",
| "failover", "null"
|
*/
'stores' => [
'array' => [
'driver' => 'array',
'serialize' => false,
],
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
'sasl' => [
env('MEMCACHED_USERNAME'),
env('MEMCACHED_PASSWORD'),
],
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
],
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
'endpoint' => env('DYNAMODB_ENDPOINT'),
],
'octane' => [
'driver' => 'octane',
],
'failover' => [
'driver' => 'failover',
'stores' => [
'database',
'array',
],
],
],
/*
|--------------------------------------------------------------------------
| Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
| stores, there might be other applications using the same cache. For
| that reason, you may prefix every cache key to avoid collisions.
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
];

View file

@ -43,10 +43,8 @@ return [
*/
'module_paths' => [
// Application modules (user-created)
app_path('Core'),
app_path('Mod'),
app_path('Website'),
// app_path('Core'),
// app_path('Mod'),
],
/*
@ -96,4 +94,362 @@ return [
'default_style' => 'solid',
],
/*
|--------------------------------------------------------------------------
| Search Configuration
|--------------------------------------------------------------------------
|
| Configure the unified search feature including searchable API endpoints.
| Add your application's API endpoints here to include them in search results.
|
*/
'search' => [
'api_endpoints' => [
// Example endpoints - override in your application's config
// ['method' => 'GET', 'path' => '/api/v1/users', 'description' => 'List users'],
// ['method' => 'POST', 'path' => '/api/v1/users', 'description' => 'Create user'],
],
],
/*
|--------------------------------------------------------------------------
| Email Shield Configuration
|--------------------------------------------------------------------------
|
| Configure the Email Shield validation and statistics module.
| Statistics track daily email validation counts for monitoring and
| analysis. Old records are automatically pruned based on retention period.
|
| Schedule the prune command in your app/Console/Kernel.php:
| $schedule->command('email-shield:prune')->daily();
|
*/
'email_shield' => [
// Number of days to retain email shield statistics records.
// Records older than this will be deleted by the prune command.
// Set to 0 to disable automatic pruning.
'retention_days' => env('CORE_EMAIL_SHIELD_RETENTION_DAYS', 90),
],
/*
|--------------------------------------------------------------------------
| Admin Menu Configuration
|--------------------------------------------------------------------------
|
| Configure the admin menu caching behaviour. Menu items are cached per
| user/workspace combination to improve performance on repeated requests.
|
*/
'admin_menu' => [
// Whether to enable caching for static menu items.
// Set to false during development for instant menu updates.
'cache_enabled' => env('CORE_ADMIN_MENU_CACHE', true),
// Cache TTL in seconds (default: 5 minutes).
// Lower values mean more frequent cache misses but fresher menus.
'cache_ttl' => env('CORE_ADMIN_MENU_CACHE_TTL', 300),
],
/*
|--------------------------------------------------------------------------
| Storage Resilience Configuration
|--------------------------------------------------------------------------
|
| Configure how the application handles Redis failures. When Redis becomes
| unavailable, the system can either silently fall back to database storage
| or throw an exception.
|
*/
'storage' => [
// Whether to silently fall back to database when Redis fails.
// Set to false to throw exceptions on Redis failure.
'silent_fallback' => env('CORE_STORAGE_SILENT_FALLBACK', true),
// Log level for fallback events: 'debug', 'info', 'notice', 'warning', 'error', 'critical'
'fallback_log_level' => env('CORE_STORAGE_FALLBACK_LOG_LEVEL', 'warning'),
// Whether to dispatch RedisFallbackActivated events for monitoring/alerting
'dispatch_fallback_events' => env('CORE_STORAGE_DISPATCH_EVENTS', true),
/*
|----------------------------------------------------------------------
| Circuit Breaker Configuration
|----------------------------------------------------------------------
|
| The circuit breaker prevents cascading failures when Redis becomes
| unavailable. When failures exceed the threshold, the circuit opens
| and requests go directly to the fallback, avoiding repeated
| connection attempts that slow down the application.
|
*/
'circuit_breaker' => [
// Enable/disable the circuit breaker
'enabled' => env('CORE_STORAGE_CIRCUIT_BREAKER_ENABLED', true),
// Number of failures before opening the circuit
'failure_threshold' => env('CORE_STORAGE_CIRCUIT_BREAKER_FAILURES', 5),
// Seconds to wait before attempting recovery (half-open state)
'recovery_timeout' => env('CORE_STORAGE_CIRCUIT_BREAKER_RECOVERY', 30),
// Number of successful operations to close the circuit
'success_threshold' => env('CORE_STORAGE_CIRCUIT_BREAKER_SUCCESSES', 2),
// Cache driver for storing circuit breaker state (use non-Redis driver)
'state_driver' => env('CORE_STORAGE_CIRCUIT_BREAKER_DRIVER', 'file'),
],
/*
|----------------------------------------------------------------------
| Storage Metrics Configuration
|----------------------------------------------------------------------
|
| Storage metrics collect information about cache operations including
| hit/miss rates, latencies, and fallback activations. Use these
| metrics for monitoring cache health and performance tuning.
|
*/
'metrics' => [
// Enable/disable metrics collection
'enabled' => env('CORE_STORAGE_METRICS_ENABLED', true),
// Maximum latency samples to keep per driver (for percentile calculations)
'max_samples' => env('CORE_STORAGE_METRICS_MAX_SAMPLES', 1000),
// Whether to log metrics events
'log_enabled' => env('CORE_STORAGE_METRICS_LOG', true),
],
],
/*
|--------------------------------------------------------------------------
| Service Configuration
|--------------------------------------------------------------------------
|
| Configure service discovery and dependency resolution. Services are
| discovered by scanning module paths for classes implementing
| ServiceDefinition.
|
*/
'services' => [
// Whether to cache service discovery results
'cache_discovery' => env('CORE_SERVICES_CACHE_DISCOVERY', true),
],
/*
|--------------------------------------------------------------------------
| Language & Translation Configuration
|--------------------------------------------------------------------------
|
| Configure translation fallback chains and missing key validation.
| The fallback chain allows regional locales to fall back to their base
| locale before using the application's fallback locale.
|
| Example chain: en_GB -> en -> fallback_locale (from config/app.php)
|
*/
'lang' => [
// Enable locale chain fallback (e.g., en_GB -> en -> fallback)
// When true, regional locales like 'en_GB' will first try 'en' before
// falling back to the application's fallback_locale.
'fallback_chain' => env('CORE_LANG_FALLBACK_CHAIN', true),
// Warn about missing translation keys in development environments.
// Set to true to always enable, false to always disable, or leave
// null to auto-enable in local/development/testing environments.
'validate_keys' => env('CORE_LANG_VALIDATE_KEYS'),
// Log missing translation keys when validation is enabled.
'log_missing_keys' => env('CORE_LANG_LOG_MISSING_KEYS', true),
// Log level for missing translation key warnings.
// Options: 'debug', 'info', 'notice', 'warning', 'error', 'critical'
'missing_key_log_level' => env('CORE_LANG_MISSING_KEY_LOG_LEVEL', 'debug'),
// Enable ICU message format support.
// Requires the PHP intl extension for full functionality.
// When disabled, ICU patterns will use basic placeholder replacement.
'icu_enabled' => env('CORE_LANG_ICU_ENABLED', true),
],
/*
|--------------------------------------------------------------------------
| Bouncer Action Gate Configuration
|--------------------------------------------------------------------------
|
| Configure the action whitelisting system. Philosophy: "If it wasn't
| trained, it doesn't exist." Every controller action must be explicitly
| permitted. Unknown actions are blocked (production) or prompt for
| approval (training mode).
|
*/
'bouncer' => [
// Enable training mode to allow approving new actions interactively.
// In production, this should be false to enforce strict whitelisting.
// In development/staging, enable to train the system with valid actions.
'training_mode' => env('CORE_BOUNCER_TRAINING_MODE', false),
// Whether to enable the action gate middleware.
// Set to false to completely disable action whitelisting.
'enabled' => env('CORE_BOUNCER_ENABLED', true),
// Guards that should have action gating applied.
// Actions on routes using these middleware groups will be checked.
'guarded_middleware' => ['web', 'admin', 'api', 'client'],
// Routes matching these patterns will bypass the action gate.
// Use for login pages, public assets, health checks, etc.
'bypass_patterns' => [
'login',
'logout',
'register',
'password/*',
'sanctum/*',
'livewire/*',
'_debugbar/*',
'horizon/*',
'telescope/*',
],
// Number of days to retain action request logs.
// Set to 0 to disable automatic pruning.
'log_retention_days' => env('CORE_BOUNCER_LOG_RETENTION', 30),
// Whether to log allowed requests (can generate many records).
// Recommended: false in production, true during training.
'log_allowed_requests' => env('CORE_BOUNCER_LOG_ALLOWED', false),
/*
|----------------------------------------------------------------------
| Honeypot Configuration
|----------------------------------------------------------------------
|
| Configure the honeypot system that traps bots ignoring robots.txt.
| Paths listed in robots.txt as disallowed are monitored; any request
| indicates a bot that doesn't respect robots.txt.
|
*/
'honeypot' => [
// Whether to auto-block IPs that hit critical honeypot paths.
// When enabled, IPs hitting paths like /admin or /.env are blocked.
// Set to false to require manual review of all honeypot hits.
'auto_block_critical' => env('CORE_BOUNCER_HONEYPOT_AUTO_BLOCK', true),
// Rate limiting for honeypot logging to prevent DoS via log flooding.
// Maximum number of log entries per IP within the time window.
'rate_limit_max' => env('CORE_BOUNCER_HONEYPOT_RATE_LIMIT_MAX', 10),
// Rate limit time window in seconds (default: 60 = 1 minute).
'rate_limit_window' => env('CORE_BOUNCER_HONEYPOT_RATE_LIMIT_WINDOW', 60),
// Severity levels for honeypot paths.
// 'critical' - Active probing (admin panels, config files).
// 'warning' - General robots.txt violation.
'severity_levels' => [
'critical' => env('CORE_BOUNCER_HONEYPOT_SEVERITY_CRITICAL', 'critical'),
'warning' => env('CORE_BOUNCER_HONEYPOT_SEVERITY_WARNING', 'warning'),
],
// Paths that indicate critical/malicious probing.
// Requests to these paths result in 'critical' severity.
// Supports prefix matching (e.g., 'admin' matches '/admin', '/admin/login').
'critical_paths' => [
'admin',
'wp-admin',
'wp-login.php',
'administrator',
'phpmyadmin',
'.env',
'.git',
],
],
],
/*
|--------------------------------------------------------------------------
| Workspace Cache Configuration
|--------------------------------------------------------------------------
|
| Configure workspace-scoped caching for multi-tenant resources.
| Models using the BelongsToWorkspace trait can cache their collections
| with automatic invalidation when records are created, updated, or deleted.
|
| The cache system supports both tagged cache stores (Redis, Memcached)
| and non-tagged stores (file, database, array). Tagged stores provide
| more efficient cache invalidation.
|
*/
'workspace_cache' => [
// Whether to enable workspace-scoped caching.
// Set to false to completely disable caching (all queries hit the database).
'enabled' => env('CORE_WORKSPACE_CACHE_ENABLED', true),
// Default TTL in seconds for cached workspace queries.
// Individual queries can override this with their own TTL.
'ttl' => env('CORE_WORKSPACE_CACHE_TTL', 300),
// Cache key prefix to avoid collisions with other cache keys.
// Change this if you need to separate cache data between deployments.
'prefix' => env('CORE_WORKSPACE_CACHE_PREFIX', 'workspace_cache'),
// Whether to use cache tags if available.
// Tags provide more efficient cache invalidation (flush by workspace or model).
// Only works with tag-supporting stores (Redis, Memcached).
// Set to false to always use key-based cache management.
'use_tags' => env('CORE_WORKSPACE_CACHE_USE_TAGS', true),
],
/*
|--------------------------------------------------------------------------
| Activity Logging Configuration
|--------------------------------------------------------------------------
|
| Configure activity logging for audit trails across modules.
| Uses spatie/laravel-activitylog under the hood with workspace-aware
| enhancements for multi-tenant environments.
|
| Models can use the Core\Activity\Concerns\LogsActivity trait to
| automatically log create, update, and delete operations.
|
*/
'activity' => [
// Whether to enable activity logging globally.
// Set to false to completely disable activity logging.
'enabled' => env('CORE_ACTIVITY_ENABLED', true),
// The log name to use for activities.
// Different log names can be used to separate activities by context.
'log_name' => env('CORE_ACTIVITY_LOG_NAME', 'default'),
// Whether to include workspace_id in activity properties.
// Enable this in multi-tenant applications to scope activities per workspace.
'include_workspace' => env('CORE_ACTIVITY_INCLUDE_WORKSPACE', true),
// Default events to log when using the LogsActivity trait.
// Models can override this with the $activityLogEvents property.
'default_events' => ['created', 'updated', 'deleted'],
// Number of days to retain activity logs.
// Use the activity:prune command to clean up old logs.
// Set to 0 to disable automatic pruning.
'retention_days' => env('CORE_ACTIVITY_RETENTION_DAYS', 90),
// Custom Activity model class (optional).
// Set this to use a custom Activity model with additional scopes.
// Default: Core\Activity\Models\Activity::class
'activity_model' => env('CORE_ACTIVITY_MODEL', \Core\Activity\Models\Activity::class),
],
];

View file

@ -1,220 +0,0 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for database operations. This is
| the connection which will be utilized unless another connection
| is explicitly specified when you execute a query / statement.
|
*/
'default' => env('DB_CONNECTION', 'sqlite'),
/*
|--------------------------------------------------------------------------
| Database Connections
|--------------------------------------------------------------------------
|
| Below are all of the database connections defined for your application.
| An example configuration is provided for each database system which
| is supported by Laravel. You're free to add / remove connections.
|
*/
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
'transaction_mode' => 'DEFERRED',
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'mariadb' => [
'driver' => 'mariadb',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'),
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DB_URL'),
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
],
/*
|--------------------------------------------------------------------------
| MCP Read-Only Connection
|--------------------------------------------------------------------------
|
| This connection is used by the MCP QueryDatabase tool. It should be
| configured with a database user that has SELECT-only permissions.
|
| For MySQL, create a read-only user:
| CREATE USER 'mcp_readonly'@'localhost' IDENTIFIED BY 'password';
| GRANT SELECT ON your_database.* TO 'mcp_readonly'@'localhost';
| FLUSH PRIVILEGES;
|
| If MCP_DB_CONNECTION is not set, this falls back to the default connection.
| In production, always configure a dedicated read-only user.
|
*/
'mcp_readonly' => [
'driver' => env('MCP_DB_DRIVER', env('DB_CONNECTION', 'mysql')),
'url' => env('MCP_DB_URL'),
'host' => env('MCP_DB_HOST', env('DB_HOST', '127.0.0.1')),
'port' => env('MCP_DB_PORT', env('DB_PORT', '3306')),
'database' => env('MCP_DB_DATABASE', env('DB_DATABASE', 'laravel')),
'username' => env('MCP_DB_USERNAME', env('DB_USERNAME', 'root')),
'password' => env('MCP_DB_PASSWORD', env('DB_PASSWORD', '')),
'unix_socket' => env('MCP_DB_SOCKET', env('DB_SOCKET', '')),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
],
/*
|--------------------------------------------------------------------------
| Migration Repository Table
|--------------------------------------------------------------------------
|
| This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run on the database.
|
*/
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
/*
|--------------------------------------------------------------------------
| Redis Databases
|--------------------------------------------------------------------------
|
| Redis is an open source, fast, and advanced key-value store that also
| provides a richer body of commands than a typical key-value system
| such as Memcached. You may define your connection settings here.
|
*/
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
'persistent' => env('REDIS_PERSISTENT', false),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
],
];

View file

@ -1,80 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application for file storage.
|
*/
'default' => env('FILESYSTEM_DISK', 'local'),
/*
|--------------------------------------------------------------------------
| Filesystem Disks
|--------------------------------------------------------------------------
|
| Below you may configure as many filesystem disks as necessary, and you
| may even configure multiple disks for the same driver. Examples for
| most supported storage drivers are configured here for reference.
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
*/
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true,
'throw' => false,
'report' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
'report' => false,
],
],
/*
|--------------------------------------------------------------------------
| Symbolic Links
|--------------------------------------------------------------------------
|
| Here you may configure the symbolic links that will be created when the
| `storage:link` Artisan command is executed. The array keys should be
| the locations of the links and the values should be their targets.
|
*/
'links' => [
public_path('storage') => storage_path('app/public'),
],
];

View file

@ -1,132 +0,0 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
return [
/*
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that is utilized to write
| messages to your logs. The value provided here should match one of
| the channels present in the list of "channels" configured below.
|
*/
'default' => env('LOG_CHANNEL', 'stack'),
/*
|--------------------------------------------------------------------------
| Deprecations Log Channel
|--------------------------------------------------------------------------
|
| This option controls the log channel that should be used to log warnings
| regarding deprecated PHP and library features. This allows you to get
| your application ready for upcoming major versions of dependencies.
|
*/
'deprecations' => [
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
],
/*
|--------------------------------------------------------------------------
| Log Channels
|--------------------------------------------------------------------------
|
| Here you may configure the log channels for your application. Laravel
| utilizes the Monolog PHP logging library, which includes a variety
| of powerful log handlers and formatters that you're free to use.
|
| Available drivers: "single", "daily", "slack", "syslog",
| "errorlog", "monolog", "custom", "stack"
|
*/
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'),
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
],
'papertrail' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'),
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
],
'processors' => [PsrLogMessageProcessor::class],
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'handler_with' => [
'stream' => 'php://stderr',
],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
],
];

View file

@ -1,118 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Mailer
|--------------------------------------------------------------------------
|
| This option controls the default mailer that is used to send all email
| messages unless another mailer is explicitly specified when sending
| the message. All additional mailers can be configured within the
| "mailers" array. Examples of each type of mailer are provided.
|
*/
'default' => env('MAIL_MAILER', 'log'),
/*
|--------------------------------------------------------------------------
| Mailer Configurations
|--------------------------------------------------------------------------
|
| Here you may configure all of the mailers used by your application plus
| their respective settings. Several examples have been configured for
| you and you are free to add your own as your application requires.
|
| Laravel supports a variety of mail "transport" drivers that can be used
| when delivering an email. You may specify which one you're using for
| your mailers below. You may also add additional mailers if needed.
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| "postmark", "resend", "log", "array",
| "failover", "roundrobin"
|
*/
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'scheme' => env('MAIL_SCHEME'),
'url' => env('MAIL_URL'),
'host' => env('MAIL_HOST', '127.0.0.1'),
'port' => env('MAIL_PORT', 2525),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
],
'ses' => [
'transport' => 'ses',
],
'postmark' => [
'transport' => 'postmark',
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
// 'client' => [
// 'timeout' => 5,
// ],
],
'resend' => [
'transport' => 'resend',
],
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
],
'log' => [
'transport' => 'log',
'channel' => env('MAIL_LOG_CHANNEL'),
],
'array' => [
'transport' => 'array',
],
'failover' => [
'transport' => 'failover',
'mailers' => [
'smtp',
'log',
],
'retry_after' => 60,
],
'roundrobin' => [
'transport' => 'roundrobin',
'mailers' => [
'ses',
'postmark',
],
'retry_after' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Global "From" Address
|--------------------------------------------------------------------------
|
| You may wish for all emails sent by your application to be sent from
| the same address. Here you may specify a name and address that is
| used globally for all emails that are sent by your application.
|
*/
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', 'Example'),
],
];

View file

@ -1,160 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| MCP Database Security
|--------------------------------------------------------------------------
|
| Configuration for the MCP QueryDatabase tool security measures.
|
*/
'database' => [
/*
|--------------------------------------------------------------------------
| Read-Only Connection
|--------------------------------------------------------------------------
|
| The database connection to use for MCP query execution. This should
| be configured with a read-only database user for defence in depth.
|
| Set to null to use the default connection (not recommended for production).
|
*/
'connection' => env('MCP_DB_CONNECTION', 'mcp_readonly'),
/*
|--------------------------------------------------------------------------
| Query Whitelist
|--------------------------------------------------------------------------
|
| Enable or disable whitelist-based query validation. When enabled,
| queries must match at least one pattern in the whitelist to execute.
|
*/
'use_whitelist' => env('MCP_DB_USE_WHITELIST', true),
/*
|--------------------------------------------------------------------------
| Custom Whitelist Patterns
|--------------------------------------------------------------------------
|
| Additional regex patterns to allow. The default whitelist allows basic
| SELECT queries. Add patterns here for application-specific queries.
|
| Example:
| '/^\s*SELECT\s+.*\s+FROM\s+`?users`?\s+WHERE\s+id\s*=\s*\d+;?\s*$/i'
|
*/
'whitelist_patterns' => [
// Add custom patterns here
],
/*
|--------------------------------------------------------------------------
| Blocked Tables
|--------------------------------------------------------------------------
|
| Tables that cannot be queried even with valid SELECT queries.
| Use this to protect sensitive tables from MCP access.
|
*/
'blocked_tables' => [
'users',
'password_reset_tokens',
'sessions',
'personal_access_tokens',
'failed_jobs',
],
/*
|--------------------------------------------------------------------------
| Row Limit
|--------------------------------------------------------------------------
|
| Maximum number of rows that can be returned from a query.
| This prevents accidentally returning huge result sets.
|
*/
'max_rows' => env('MCP_DB_MAX_ROWS', 1000),
],
/*
|--------------------------------------------------------------------------
| Tool Usage Analytics
|--------------------------------------------------------------------------
|
| Configuration for MCP tool usage analytics and metrics tracking.
|
*/
'analytics' => [
/*
|--------------------------------------------------------------------------
| Enable Analytics
|--------------------------------------------------------------------------
|
| Enable or disable tool usage analytics. When disabled, no metrics
| will be recorded for tool executions.
|
*/
'enabled' => env('MCP_ANALYTICS_ENABLED', true),
/*
|--------------------------------------------------------------------------
| Data Retention
|--------------------------------------------------------------------------
|
| Number of days to retain analytics data before pruning.
| Use the mcp:prune-metrics command to clean up old data.
|
*/
'retention_days' => env('MCP_ANALYTICS_RETENTION_DAYS', 90),
/*
|--------------------------------------------------------------------------
| Batch Size
|--------------------------------------------------------------------------
|
| Number of metrics to accumulate before flushing to the database.
| Higher values improve write performance but may lose data on crashes.
|
*/
'batch_size' => env('MCP_ANALYTICS_BATCH_SIZE', 100),
],
/*
|--------------------------------------------------------------------------
| Log Retention
|--------------------------------------------------------------------------
|
| Configuration for MCP log retention and cleanup.
|
*/
'log_retention' => [
/*
|--------------------------------------------------------------------------
| Detailed Logs Retention
|--------------------------------------------------------------------------
|
| Number of days to retain detailed tool call logs.
|
*/
'days' => env('MCP_LOG_RETENTION_DAYS', 90),
/*
|--------------------------------------------------------------------------
| Statistics Retention
|--------------------------------------------------------------------------
|
| Number of days to retain aggregated statistics.
| Should typically be longer than detailed logs.
|
*/
'stats_days' => env('MCP_LOG_RETENTION_STATS_DAYS', 365),
],
];

View file

@ -1,129 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Queue Connection Name
|--------------------------------------------------------------------------
|
| Laravel's queue supports a variety of backends via a single, unified
| API, giving you convenient access to each backend using identical
| syntax for each. The default queue connection is defined below.
|
*/
'default' => env('QUEUE_CONNECTION', 'database'),
/*
|--------------------------------------------------------------------------
| Queue Connections
|--------------------------------------------------------------------------
|
| Here you may configure the connection options for every queue backend
| used by your application. An example configuration is provided for
| each backend supported by Laravel. You're also free to add more.
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
| "deferred", "background", "failover", "null"
|
*/
'connections' => [
'sync' => [
'driver' => 'sync',
],
'database' => [
'driver' => 'database',
'connection' => env('DB_QUEUE_CONNECTION'),
'table' => env('DB_QUEUE_TABLE', 'jobs'),
'queue' => env('DB_QUEUE', 'default'),
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
'after_commit' => false,
],
'beanstalkd' => [
'driver' => 'beanstalkd',
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
'queue' => env('BEANSTALKD_QUEUE', 'default'),
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
'block_for' => 0,
'after_commit' => false,
],
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
'queue' => env('SQS_QUEUE', 'default'),
'suffix' => env('SQS_SUFFIX'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'after_commit' => false,
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null,
'after_commit' => false,
],
'deferred' => [
'driver' => 'deferred',
],
'background' => [
'driver' => 'background',
],
'failover' => [
'driver' => 'failover',
'connections' => [
'database',
'deferred',
],
],
],
/*
|--------------------------------------------------------------------------
| Job Batching
|--------------------------------------------------------------------------
|
| The following options configure the database and table that store job
| batching information. These options can be updated to any database
| connection and table which has been defined by your application.
|
*/
'batching' => [
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'job_batches',
],
/*
|--------------------------------------------------------------------------
| Failed Queue Jobs
|--------------------------------------------------------------------------
|
| These options configure the behavior of failed queue job logging so you
| can control how and where failed jobs are stored. Laravel ships with
| support for storing failed jobs in a simple file or in a database.
|
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
*/
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'failed_jobs',
],
];

View file

@ -1,38 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Third Party Services
|--------------------------------------------------------------------------
|
| This file is for storing the credentials for third party services such
| as Mailgun, Postmark, AWS and more. This file provides the de facto
| location for this type of information, allowing packages to have
| a conventional file to locate the various service credentials.
|
*/
'postmark' => [
'key' => env('POSTMARK_API_KEY'),
],
'resend' => [
'key' => env('RESEND_API_KEY'),
],
'ses' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
'slack' => [
'notifications' => [
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
],
],
];

View file

@ -1,217 +0,0 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Session Driver
|--------------------------------------------------------------------------
|
| This option determines the default session driver that is utilized for
| incoming requests. Laravel supports a variety of storage options to
| persist session data. Database storage is a great default choice.
|
| Supported: "file", "cookie", "database", "memcached",
| "redis", "dynamodb", "array"
|
*/
'driver' => env('SESSION_DRIVER', 'database'),
/*
|--------------------------------------------------------------------------
| Session Lifetime
|--------------------------------------------------------------------------
|
| Here you may specify the number of minutes that you wish the session
| to be allowed to remain idle before it expires. If you want them
| to expire immediately when the browser is closed then you may
| indicate that via the expire_on_close configuration option.
|
*/
'lifetime' => (int) env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
/*
|--------------------------------------------------------------------------
| Session Encryption
|--------------------------------------------------------------------------
|
| This option allows you to easily specify that all of your session data
| should be encrypted before it's stored. All encryption is performed
| automatically by Laravel and you may use the session like normal.
|
*/
'encrypt' => env('SESSION_ENCRYPT', false),
/*
|--------------------------------------------------------------------------
| Session File Location
|--------------------------------------------------------------------------
|
| When utilizing the "file" session driver, the session files are placed
| on disk. The default storage location is defined here; however, you
| are free to provide another location where they should be stored.
|
*/
'files' => storage_path('framework/sessions'),
/*
|--------------------------------------------------------------------------
| Session Database Connection
|--------------------------------------------------------------------------
|
| When using the "database" or "redis" session drivers, you may specify a
| connection that should be used to manage these sessions. This should
| correspond to a connection in your database configuration options.
|
*/
'connection' => env('SESSION_CONNECTION'),
/*
|--------------------------------------------------------------------------
| Session Database Table
|--------------------------------------------------------------------------
|
| When using the "database" session driver, you may specify the table to
| be used to store sessions. Of course, a sensible default is defined
| for you; however, you're welcome to change this to another table.
|
*/
'table' => env('SESSION_TABLE', 'sessions'),
/*
|--------------------------------------------------------------------------
| Session Cache Store
|--------------------------------------------------------------------------
|
| When using one of the framework's cache driven session backends, you may
| define the cache store which should be used to store the session data
| between requests. This must match one of your defined cache stores.
|
| Affects: "dynamodb", "memcached", "redis"
|
*/
'store' => env('SESSION_STORE'),
/*
|--------------------------------------------------------------------------
| Session Sweeping Lottery
|--------------------------------------------------------------------------
|
| Some session drivers must manually sweep their storage location to get
| rid of old sessions from storage. Here are the chances that it will
| happen on a given request. By default, the odds are 2 out of 100.
|
*/
'lottery' => [2, 100],
/*
|--------------------------------------------------------------------------
| Session Cookie Name
|--------------------------------------------------------------------------
|
| Here you may change the name of the session cookie that is created by
| the framework. Typically, you should not need to change this value
| since doing so does not grant a meaningful security improvement.
|
*/
'cookie' => env(
'SESSION_COOKIE',
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
),
/*
|--------------------------------------------------------------------------
| Session Cookie Path
|--------------------------------------------------------------------------
|
| The session cookie path determines the path for which the cookie will
| be regarded as available. Typically, this will be the root path of
| your application, but you're free to change this when necessary.
|
*/
'path' => env('SESSION_PATH', '/'),
/*
|--------------------------------------------------------------------------
| Session Cookie Domain
|--------------------------------------------------------------------------
|
| This value determines the domain and subdomains the session cookie is
| available to. By default, the cookie will be available to the root
| domain without subdomains. Typically, this shouldn't be changed.
|
*/
'domain' => env('SESSION_DOMAIN'),
/*
|--------------------------------------------------------------------------
| HTTPS Only Cookies
|--------------------------------------------------------------------------
|
| By setting this option to true, session cookies will only be sent back
| to the server if the browser has a HTTPS connection. This will keep
| the cookie from being sent to you when it can't be done securely.
|
*/
'secure' => env('SESSION_SECURE_COOKIE'),
/*
|--------------------------------------------------------------------------
| HTTP Access Only
|--------------------------------------------------------------------------
|
| Setting this value to true will prevent JavaScript from accessing the
| value of the cookie and the cookie will only be accessible through
| the HTTP protocol. It's unlikely you should disable this option.
|
*/
'http_only' => env('SESSION_HTTP_ONLY', true),
/*
|--------------------------------------------------------------------------
| Same-Site Cookies
|--------------------------------------------------------------------------
|
| This option determines how your cookies behave when cross-site requests
| take place, and can be used to mitigate CSRF attacks. By default, we
| will set this value to "lax" to permit secure cross-site requests.
|
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
| Supported: "lax", "strict", "none", null
|
*/
'same_site' => env('SESSION_SAME_SITE', 'lax'),
/*
|--------------------------------------------------------------------------
| Partitioned Cookies
|--------------------------------------------------------------------------
|
| Setting this value to true will tie the cookie to the top-level site for
| a cross-site context. Partitioned cookies are accepted by the browser
| when flagged "secure" and the Same-Site attribute is set to "none".
|
*/
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
];

1
database/.gitignore vendored
View file

@ -1 +0,0 @@
*.sqlite*

View file

@ -1,44 +0,0 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
];
}
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
}

View file

@ -1,49 +0,0 @@
<?php
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('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
}
};

View file

@ -1,35 +0,0 @@
<?php
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('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->integer('expiration')->index();
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->integer('expiration')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};

View file

@ -1,57 +0,0 @@
<?php
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('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
Schema::dropIfExists('job_batches');
Schema::dropIfExists('failed_jobs');
}
};

View file

@ -1,25 +0,0 @@
<?php
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
use WithoutModelEvents;
/**
* Seed the application's database.
*/
public function run(): void
{
// User::factory(10)->create();
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]);
}
}

View file

@ -3,7 +3,7 @@ import { defineConfig } from 'vitepress'
export default defineConfig({
title: 'Core PHP Framework',
description: 'Modular monolith framework for Laravel',
base: '/core-php/',
base: '/',
ignoreDeadLinks: [
// Ignore localhost links

View file

@ -206,6 +206,6 @@ Special thanks to the open-source community!
---
For more information, visit:
- [Documentation](https://host-uk.github.io/core-php/)
- [Documentation](https://core.help/)
- [GitHub Repository](https://github.com/host-uk/core-php)
- [Issue Tracker](https://github.com/host-uk/core-php/issues)

View file

@ -461,6 +461,6 @@ By contributing, you agree that your contributions will be licensed under the EU
- Open a [Discussion](https://github.com/host-uk/core-php/discussions)
- Join our [Discord](https://discord.gg/host-uk)
- Read the [Documentation](https://host-uk.github.io/core-php/)
- Read the [Documentation](https://core.help/)
Thank you for contributing! 🎉

1
docs/public/CNAME Normal file
View file

@ -0,0 +1 @@
core.help

View file

@ -1,113 +0,0 @@
# Core Admin Package
Admin panel components, Livewire modals, and service management interface for the Core PHP Framework.
## Installation
```bash
composer require host-uk/core-admin
```
## Features
### Admin Menu System
Declarative menu registration with automatic permission checking:
```php
use Core\Front\Admin\Contracts\AdminMenuProvider;
class MyModuleMenu implements AdminMenuProvider
{
public function registerMenu(AdminMenuRegistry $registry): void
{
$registry->addItem('products', [
'label' => 'Products',
'icon' => 'cube',
'route' => 'admin.products.index',
'permission' => 'products.view',
]);
}
}
```
### Livewire Modals
Full-page Livewire components for admin interfaces:
```php
use Livewire\Component;
use Livewire\Attributes\Title;
#[Title('Product Manager')]
class ProductManager extends Component
{
public function render(): View
{
return view('admin.products.manager')
->layout('hub::admin.layouts.app');
}
}
```
### Form Components
Reusable form components with authorization:
- `<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 - See [LICENSE](../../LICENSE) for details.

View file

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

View file

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

View file

@ -1,25 +0,0 @@
{
"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",
"host-uk/core": "@dev"
},
"autoload": {
"psr-4": {
"Core\\Admin\\": "src/",
"Website\\Hub\\": "src/Website/Hub/"
}
},
"extra": {
"laravel": {
"providers": [
"Core\\Admin\\Boot"
]
}
},
"minimum-stability": "stable",
"prefer-stable": true
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,158 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Hub\Controllers;
use Core\Bouncer\BlocklistService;
use Core\Headers\DetectLocation;
use Core\Mod\Hub\Models\HoneypotHit;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\RateLimiter;
/**
* Honeypot endpoint that returns 418 I'm a Teapot.
*
* This endpoint is listed as disallowed in robots.txt. Any request to it
* indicates a crawler that doesn't respect robots.txt, which is often
* malicious or at least poorly behaved.
*/
class TeapotController
{
public function __invoke(Request $request): Response
{
// Log the hit
$userAgent = $request->userAgent();
$botName = HoneypotHit::detectBot($userAgent);
$path = $request->path();
$severity = HoneypotHit::severityForPath($path);
$ip = $request->ip();
// Rate limit honeypot logging to prevent DoS via log flooding.
// Each IP gets limited to N log entries per time window.
$rateLimitKey = 'honeypot:log:'.$ip;
$maxAttempts = (int) config('core.bouncer.honeypot.rate_limit_max', 10);
$decaySeconds = (int) config('core.bouncer.honeypot.rate_limit_window', 60);
if (! RateLimiter::tooManyAttempts($rateLimitKey, $maxAttempts)) {
RateLimiter::hit($rateLimitKey, $decaySeconds);
// Optional services - use app() since route skips web middleware
$geoIp = app(DetectLocation::class);
HoneypotHit::create([
'ip_address' => $ip,
'user_agent' => substr($userAgent ?? '', 0, 1000),
'referer' => substr($request->header('Referer', ''), 0, 2000),
'path' => $path,
'method' => $request->method(),
'headers' => $this->sanitizeHeaders($request->headers->all()),
'country' => $geoIp?->getCountryCode($ip),
'city' => $geoIp?->getCity($ip),
'is_bot' => $botName !== null,
'bot_name' => $botName,
'severity' => $severity,
]);
}
// Auto-block critical hits (active probing) if enabled in config.
// Skip localhost in dev to avoid blocking yourself.
$autoBlockEnabled = config('core.bouncer.honeypot.auto_block_critical', true);
$isLocalhost = in_array($ip, ['127.0.0.1', '::1'], true);
$isCritical = $severity === HoneypotHit::getSeverityCritical();
if ($autoBlockEnabled && $isCritical && ! $isLocalhost) {
app(BlocklistService::class)->block($ip, 'honeypot_critical');
}
// Return the 418 I'm a teapot response
return response($this->teapotBody(), 418, [
'Content-Type' => 'text/html; charset=utf-8',
'X-Powered-By' => 'Earl Grey',
'X-Severity' => $severity,
]);
}
/**
* Remove sensitive headers before storing.
*/
protected function sanitizeHeaders(array $headers): array
{
$sensitive = ['cookie', 'authorization', 'x-csrf-token', 'x-xsrf-token'];
foreach ($sensitive as $key) {
unset($headers[$key]);
}
return $headers;
}
/**
* The teapot response body.
*/
protected function teapotBody(): string
{
return <<<'HTML'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>418 I'm a Teapot</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-family: system-ui, -apple-system, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
text-align: center;
padding: 2rem;
}
.teapot {
font-size: 8rem;
margin-bottom: 1rem;
animation: wobble 2s ease-in-out infinite;
}
@keyframes wobble {
0%, 100% { transform: rotate(-5deg); }
50% { transform: rotate(5deg); }
}
h1 {
font-size: 3rem;
margin-bottom: 0.5rem;
}
p {
font-size: 1.25rem;
opacity: 0.9;
max-width: 500px;
}
.rfc {
margin-top: 2rem;
font-size: 0.875rem;
opacity: 0.7;
}
a {
color: inherit;
text-decoration: underline;
}
</style>
</head>
<body>
<div class="teapot">🫖</div>
<h1>418 I'm a Teapot</h1>
<p>The server refuses to brew coffee because it is, permanently, a teapot.</p>
<p class="rfc">
<a href="https://www.rfc-editor.org/rfc/rfc2324" target="_blank" rel="noopener">RFC 2324</a> &middot;
<a href="https://www.rfc-editor.org/rfc/rfc7168" target="_blank" rel="noopener">RFC 7168</a>
</p>
</body>
</html>
HTML;
}
}

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

@ -1,206 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Hub\Models;
use Illuminate\Database\Eloquent\Model;
class HoneypotHit extends Model
{
protected $fillable = [
'ip_address',
'user_agent',
'referer',
'path',
'method',
'headers',
'country',
'city',
'is_bot',
'bot_name',
'severity',
];
protected $casts = [
'headers' => 'array',
'is_bot' => 'boolean',
];
/**
* Severity levels for honeypot hits.
*
* These can be overridden via config('core.bouncer.honeypot.severity_levels').
*/
public const SEVERITY_WARNING = 'warning'; // Ignored robots.txt (/teapot)
public const SEVERITY_CRITICAL = 'critical'; // Active probing (/admin)
/**
* Default critical paths (used when config is not available).
*/
protected static array $defaultCriticalPaths = [
'admin',
'wp-admin',
'wp-login.php',
'administrator',
'phpmyadmin',
'.env',
'.git',
];
/**
* Get the severity level string for 'critical'.
*/
public static function getSeverityCritical(): string
{
return config('core.bouncer.honeypot.severity_levels.critical', self::SEVERITY_CRITICAL);
}
/**
* Get the severity level string for 'warning'.
*/
public static function getSeverityWarning(): string
{
return config('core.bouncer.honeypot.severity_levels.warning', self::SEVERITY_WARNING);
}
/**
* Get the list of critical paths.
*/
public static function getCriticalPaths(): array
{
return config('core.bouncer.honeypot.critical_paths', self::$defaultCriticalPaths);
}
/**
* Determine severity based on path.
*
* Uses configurable critical paths from config('core.bouncer.honeypot.critical_paths').
*/
public static function severityForPath(string $path): string
{
$criticalPaths = self::getCriticalPaths();
$path = ltrim($path, '/');
foreach ($criticalPaths as $critical) {
if (str_starts_with($path, $critical)) {
return self::getSeverityCritical();
}
}
return self::getSeverityWarning();
}
/**
* Known bad bot patterns.
*/
protected static array $botPatterns = [
'AhrefsBot' => 'Ahrefs',
'SemrushBot' => 'Semrush',
'MJ12bot' => 'Majestic',
'DotBot' => 'Moz',
'BLEXBot' => 'BLEXBot',
'PetalBot' => 'Petal',
'YandexBot' => 'Yandex',
'bingbot' => 'Bing',
'Googlebot' => 'Google',
'Bytespider' => 'ByteDance',
'GPTBot' => 'OpenAI',
'CCBot' => 'Common Crawl',
'ClaudeBot' => 'Anthropic',
'anthropic-ai' => 'Anthropic',
'DataForSeoBot' => 'DataForSEO',
'serpstatbot' => 'Serpstat',
'curl/' => 'cURL',
'python-requests' => 'Python',
'Go-http-client' => 'Go',
'wget' => 'Wget',
'scrapy' => 'Scrapy',
'HeadlessChrome' => 'HeadlessChrome',
'PhantomJS' => 'PhantomJS',
];
/**
* Detect if the user agent is a known bot.
*/
public static function detectBot(?string $userAgent): ?string
{
if (empty($userAgent)) {
return 'Unknown (no UA)';
}
foreach (self::$botPatterns as $pattern => $name) {
if (stripos($userAgent, $pattern) !== false) {
return $name;
}
}
return null;
}
/**
* Scope for recent hits.
*/
public function scopeRecent($query, int $hours = 24)
{
return $query->where('created_at', '>=', now()->subHours($hours));
}
/**
* Scope for a specific IP.
*/
public function scopeFromIp($query, string $ip)
{
return $query->where('ip_address', $ip);
}
/**
* Scope for bots only.
*/
public function scopeBots($query)
{
return $query->where('is_bot', true);
}
/**
* Scope for critical severity (blocklist candidates).
*/
public function scopeCritical($query)
{
return $query->where('severity', self::SEVERITY_CRITICAL);
}
/**
* Scope for warning severity.
*/
public function scopeWarning($query)
{
return $query->where('severity', self::SEVERITY_WARNING);
}
/**
* Get stats for the dashboard.
*/
public static function getStats(): array
{
return [
'total' => self::count(),
'today' => self::whereDate('created_at', today())->count(),
'this_week' => self::where('created_at', '>=', now()->subWeek())->count(),
'unique_ips' => self::distinct('ip_address')->count('ip_address'),
'bots' => self::where('is_bot', true)->count(),
'top_ips' => self::selectRaw('ip_address, COUNT(*) as hits')
->groupBy('ip_address')
->orderByDesc('hits')
->limit(10)
->get(),
'top_bots' => self::selectRaw('bot_name, COUNT(*) as hits')
->whereNotNull('bot_name')
->groupBy('bot_name')
->orderByDesc('hits')
->limit(10)
->get(),
];
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,96 +0,0 @@
<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>

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