Add core components and initial setup for the PHP framework

This commit is contained in:
Snider 2026-01-21 14:11:45 +00:00
parent d6fbabf4d9
commit b26c430cd6
1066 changed files with 173555 additions and 964 deletions

65
.env.example Normal file
View file

@ -0,0 +1,65 @@
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}"

13
.gitignore vendored
View file

@ -1,7 +1,16 @@
/vendor/
/.phpunit.cache/
/vendor
composer.lock
.DS_Store
.idea/
*.swp
*.swo
.env
.env.dev
auth.json
node_modules/
bootstrap/cache
public/build
/storage/*.key
/storage/pail
.phpunit.result.cache
.phpunit.cache

96
app/Website/Demo/Boot.php Normal file
View file

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

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

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

@ -0,0 +1,26 @@
<!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'])
@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>
@livewireScripts
</body>
</html>

View file

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

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

@ -0,0 +1,79 @@
<div class="flex min-h-[60vh] items-center justify-center">
<div class="w-full max-w-md">
{{-- Header --}}
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-white mb-2">Sign in to {{ config('app.name', 'Core PHP') }}</h1>
<p class="text-zinc-400">Enter your credentials to continue</p>
</div>
{{-- Login Form --}}
<form wire:submit="login" class="bg-zinc-800/50 rounded-xl p-6 space-y-6">
{{-- Email --}}
<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"
autocomplete="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="you@example.com"
>
@error('email')
<p class="mt-2 text-sm text-red-400">{{ $message }}</p>
@enderror
</div>
{{-- Password --}}
<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"
autocomplete="current-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="Enter your password"
>
@error('password')
<p class="mt-2 text-sm text-red-400">{{ $message }}</p>
@enderror
</div>
{{-- Remember Me --}}
<div class="flex items-center justify-between">
<label class="flex items-center gap-2 cursor-pointer">
<input
wire:model="remember"
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"
>
<span class="text-sm text-zinc-400">Remember me</span>
</label>
</div>
{{-- Submit --}}
<button
type="submit"
class="w-full px-6 py-3 bg-violet-600 hover:bg-violet-500 text-white rounded-lg font-medium transition flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
wire:loading.attr="disabled"
>
<span wire:loading.remove>Sign in</span>
<span wire:loading class="flex items-center gap-2">
<svg class="animate-spin h-5 w-5" 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>
Signing in...
</span>
</button>
</form>
{{-- Back to home --}}
<p class="text-center mt-6 text-zinc-500">
<a href="/" class="text-violet-400 hover:text-violet-300 transition">
&larr; Back to home
</a>
</p>
</div>
</div>

View file

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

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

@ -0,0 +1,66 @@
<?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 Executable file
View file

@ -0,0 +1,13 @@
#!/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,41 +1,109 @@
{
"name": "host-uk/core",
"description": "Modular monolith framework for Laravel - event-driven architecture with lazy module loading",
"keywords": ["laravel", "modular", "monolith", "framework", "events", "modules"],
"name": "host-uk/core-app",
"type": "project",
"description": "Core PHP Framework - Demo Application",
"keywords": ["laravel", "modular", "monolith", "framework"],
"license": "EUPL-1.2",
"authors": [
{
"name": "Host UK",
"email": "dev@host.uk.com"
}
],
"require": {
"php": "^8.2",
"laravel/framework": "^11.0|^12.0"
"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"
},
"require-dev": {
"orchestra/testbench": "^9.0|^10.0",
"phpunit/phpunit": "^11.0"
"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",
"phpunit/phpunit": "^11.5.3"
},
"autoload": {
"psr-4": {
"Core\\": "src/Core/"
"App\\": "app/",
"Website\\": "app/Website/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Core\\Tests\\": "tests/"
"Tests\\": "tests/"
}
},
"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"
},
"extra": {
"laravel": {
"providers": [
"Core\\CoreServiceProvider"
]
"dont-discover": []
}
},
"config": {
"sort-packages": true
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true

126
config/app.php Normal file
View file

@ -0,0 +1,126 @@
<?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'),
],
];

115
config/auth.php Normal file
View file

@ -0,0 +1,115 @@
<?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),
];

117
config/cache.php Normal file
View file

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

@ -2,6 +2,30 @@
return [
/*
|--------------------------------------------------------------------------
| Application Branding
|--------------------------------------------------------------------------
|
| These settings control the public-facing website branding.
| Override these in your application's config/core.php to customise.
|
*/
'app' => [
'name' => env('APP_NAME', 'Core PHP'),
'description' => env('APP_DESCRIPTION', 'A modular monolith framework'),
'tagline' => env('APP_TAGLINE', 'Build powerful applications with a clean, modular architecture.'),
'cta_text' => env('APP_CTA_TEXT', 'Join developers building with our framework.'),
'icon' => env('APP_ICON', 'cube'),
'color' => env('APP_COLOR', 'violet'),
'logo' => env('APP_LOGO'), // Path relative to public/, e.g. 'images/logo.svg'
'privacy_url' => env('APP_PRIVACY_URL'),
'terms_url' => env('APP_TERMS_URL'),
'powered_by' => env('APP_POWERED_BY'),
'powered_by_url' => env('APP_POWERED_BY_URL'),
],
/*
|--------------------------------------------------------------------------
| Module Paths
@ -23,4 +47,51 @@ return [
// app_path('Mod'),
],
/*
|--------------------------------------------------------------------------
| FontAwesome Configuration
|--------------------------------------------------------------------------
|
| Configure FontAwesome Pro detection and fallback behaviour.
|
*/
'fontawesome' => [
// Set to true if you have a FontAwesome Pro licence
'pro' => env('FONTAWESOME_PRO', false),
// Your FontAwesome Kit ID (optional)
'kit' => env('FONTAWESOME_KIT'),
],
/*
|--------------------------------------------------------------------------
| Pro Fallback Behaviour
|--------------------------------------------------------------------------
|
| How to handle Pro-only components when Pro packages aren't installed.
|
| Options:
| - 'error': Throw exception in dev, silent in production
| - 'fallback': Use free alternatives where possible
| - 'silent': Render nothing for Pro-only components
|
*/
'pro_fallback' => env('CORE_PRO_FALLBACK', 'error'),
/*
|--------------------------------------------------------------------------
| Icon Defaults
|--------------------------------------------------------------------------
|
| Default icon style when not specified. Only applies when not using
| auto-detection (brand/jelly lists).
|
*/
'icon' => [
'default_style' => 'solid',
],
];

183
config/database.php Normal file
View file

@ -0,0 +1,183 @@
<?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'),
],
],
/*
|--------------------------------------------------------------------------
| 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),
],
],
];

80
config/filesystems.php Normal file
View file

@ -0,0 +1,80 @@
<?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'),
],
];

132
config/logging.php Normal file
View file

@ -0,0 +1,132 @@
<?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'),
],
],
];

118
config/mail.php Normal file
View file

@ -0,0 +1,118 @@
<?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'),
],
];

129
config/queue.php Normal file
View file

@ -0,0 +1,129 @@
<?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',
],
];

38
config/services.php Normal file
View file

@ -0,0 +1,38 @@
<?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'),
],
],
];

217
config/session.php Normal file
View file

@ -0,0 +1,217 @@
<?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 Normal file
View file

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

View file

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

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

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 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

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

@ -0,0 +1,25 @@
<?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',
]);
}
}

1989
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

16
package.json Normal file
View file

@ -0,0 +1,16 @@
{
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.18",
"autoprefixer": "^10.4.20",
"laravel-vite-plugin": "^1.2.0",
"postcss": "^8.4.47",
"tailwindcss": "^4.0.0",
"vite": "^6.0.11"
}
}

View file

@ -0,0 +1,25 @@
{
"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

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Core\Admin;
use Core\ModuleRegistry;
use Illuminate\Support\ServiceProvider;
/**
* Core Admin Package Bootstrap.
*
* Registers package paths with the module scanner.
*/
class Boot extends ServiceProvider
{
public function register(): void
{
// Register our Website modules with the scanner
app(ModuleRegistry::class)->addPaths([
__DIR__.'/Website',
]);
}
public function boot(): void
{
// Load Hub translations
$this->loadTranslationsFrom(__DIR__.'/Mod/Hub/Lang', 'hub');
}
}

View file

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

View file

@ -0,0 +1,144 @@
<?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;
/**
* 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();
// 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)
// Skip localhost in dev to avoid blocking yourself
$isLocalhost = in_array($ip, ['127.0.0.1', '::1'], true);
if ($severity === HoneypotHit::SEVERITY_CRITICAL && ! $isLocalhost) {
app(BlocklistService::class)->block($ip, 'honeypot_critical');
}
// Return the 418 I'm a teapot response
return response($this->teapotBody(), 418, [
'Content-Type' => 'text/html; charset=utf-8',
'X-Powered-By' => 'Earl Grey',
'X-Severity' => $severity,
]);
}
/**
* Remove sensitive headers before storing.
*/
protected function sanitizeHeaders(array $headers): array
{
$sensitive = ['cookie', 'authorization', 'x-csrf-token', 'x-xsrf-token'];
foreach ($sensitive as $key) {
unset($headers[$key]);
}
return $headers;
}
/**
* The teapot response body.
*/
protected function teapotBody(): string
{
return <<<'HTML'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>418 I'm a Teapot</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-family: system-ui, -apple-system, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
text-align: center;
padding: 2rem;
}
.teapot {
font-size: 8rem;
margin-bottom: 1rem;
animation: wobble 2s ease-in-out infinite;
}
@keyframes wobble {
0%, 100% { transform: rotate(-5deg); }
50% { transform: rotate(5deg); }
}
h1 {
font-size: 3rem;
margin-bottom: 0.5rem;
}
p {
font-size: 1.25rem;
opacity: 0.9;
max-width: 500px;
}
.rfc {
margin-top: 2rem;
font-size: 0.875rem;
opacity: 0.7;
}
a {
color: inherit;
text-decoration: underline;
}
</style>
</head>
<body>
<div class="teapot">🫖</div>
<h1>418 I'm a Teapot</h1>
<p>The server refuses to brew coffee because it is, permanently, a teapot.</p>
<p class="rfc">
<a href="https://www.rfc-editor.org/rfc/rfc2324" target="_blank" rel="noopener">RFC 2324</a> &middot;
<a href="https://www.rfc-editor.org/rfc/rfc7168" target="_blank" rel="noopener">RFC 7168</a>
</p>
</body>
</html>
HTML;
}
}

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

@ -0,0 +1,174 @@
<?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.
*/
public const SEVERITY_WARNING = 'warning'; // Ignored robots.txt (/teapot)
public const SEVERITY_CRITICAL = 'critical'; // Active probing (/admin)
/**
* Determine severity based on path.
*/
public static function severityForPath(string $path): string
{
// Paths that indicate active malicious probing
$criticalPaths = [
'admin',
'wp-admin',
'wp-login.php',
'administrator',
'phpmyadmin',
'.env',
'.git',
];
$path = ltrim($path, '/');
foreach ($criticalPaths as $critical) {
if (str_starts_with($path, $critical)) {
return self::SEVERITY_CRITICAL;
}
}
return self::SEVERITY_WARNING;
}
/**
* Known bad bot patterns.
*/
protected static array $botPatterns = [
'AhrefsBot' => 'Ahrefs',
'SemrushBot' => 'Semrush',
'MJ12bot' => 'Majestic',
'DotBot' => 'Moz',
'BLEXBot' => 'BLEXBot',
'PetalBot' => 'Petal',
'YandexBot' => 'Yandex',
'bingbot' => 'Bing',
'Googlebot' => 'Google',
'Bytespider' => 'ByteDance',
'GPTBot' => 'OpenAI',
'CCBot' => 'Common Crawl',
'ClaudeBot' => 'Anthropic',
'anthropic-ai' => 'Anthropic',
'DataForSeoBot' => 'DataForSEO',
'serpstatbot' => 'Serpstat',
'curl/' => 'cURL',
'python-requests' => 'Python',
'Go-http-client' => 'Go',
'wget' => 'Wget',
'scrapy' => 'Scrapy',
'HeadlessChrome' => 'HeadlessChrome',
'PhantomJS' => 'PhantomJS',
];
/**
* Detect if the user agent is a known bot.
*/
public static function detectBot(?string $userAgent): ?string
{
if (empty($userAgent)) {
return 'Unknown (no UA)';
}
foreach (self::$botPatterns as $pattern => $name) {
if (stripos($userAgent, $pattern) !== false) {
return $name;
}
}
return null;
}
/**
* Scope for recent hits.
*/
public function scopeRecent($query, int $hours = 24)
{
return $query->where('created_at', '>=', now()->subHours($hours));
}
/**
* Scope for a specific IP.
*/
public function scopeFromIp($query, string $ip)
{
return $query->where('ip_address', $ip);
}
/**
* Scope for bots only.
*/
public function scopeBots($query)
{
return $query->where('is_bot', true);
}
/**
* Scope for critical severity (blocklist candidates).
*/
public function scopeCritical($query)
{
return $query->where('severity', self::SEVERITY_CRITICAL);
}
/**
* Scope for warning severity.
*/
public function scopeWarning($query)
{
return $query->where('severity', self::SEVERITY_WARNING);
}
/**
* Get stats for the dashboard.
*/
public static function getStats(): array
{
return [
'total' => self::count(),
'today' => self::whereDate('created_at', today())->count(),
'this_week' => self::where('created_at', '>=', now()->subWeek())->count(),
'unique_ips' => self::distinct('ip_address')->count('ip_address'),
'bots' => self::where('is_bot', true)->count(),
'top_ips' => self::selectRaw('ip_address, COUNT(*) as hits')
->groupBy('ip_address')
->orderByDesc('hits')
->limit(10)
->get(),
'top_bots' => self::selectRaw('bot_name, COUNT(*) as hits')
->whereNotNull('bot_name')
->groupBy('bot_name')
->orderByDesc('hits')
->limit(10)
->get(),
];
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace Website\Hub;
use Core\Events\DomainResolving;
use Core\Events\AdminPanelBooting;
use Core\Front\Admin\AdminMenuRegistry;
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
{
/**
* 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);
// Register menu provider
app(AdminMenuRegistry::class)->register($this);
// Register routes for configured domains
foreach ($this->domains() as $domain) {
$event->routes(fn () => Route::prefix('hub')
->name('hub.')
->domain($domain)
->group(__DIR__.'/Routes/admin.php'));
}
}
/**
* Provide admin menu items.
*/
public function adminMenuItems(): array
{
return [
// Dashboard - standalone group
[
'group' => 'dashboard',
'priority' => 10,
'item' => fn () => [
'label' => __('hub::hub.dashboard.title'),
'icon' => 'house',
'href' => route('hub.dashboard'),
'active' => request()->routeIs('hub.dashboard'),
],
],
// Workspaces
[
'group' => 'workspaces',
'priority' => 10,
'item' => fn () => [
'label' => __('hub::hub.workspaces.title'),
'icon' => 'folders',
'href' => route('hub.sites'),
'active' => request()->routeIs('hub.sites*'),
],
],
// Account - Profile
[
'group' => 'settings',
'priority' => 10,
'item' => fn () => [
'label' => __('hub::hub.quick_actions.profile.title'),
'icon' => 'user',
'href' => route('hub.account'),
'active' => request()->routeIs('hub.account') && !request()->routeIs('hub.account.*'),
],
],
// Account - Settings
[
'group' => 'settings',
'priority' => 20,
'item' => fn () => [
'label' => __('hub::hub.settings.title'),
'icon' => 'gear',
'href' => route('hub.account.settings'),
'active' => request()->routeIs('hub.account.settings'),
],
],
// Account - Usage
[
'group' => 'settings',
'priority' => 30,
'item' => fn () => [
'label' => __('hub::hub.usage.title'),
'icon' => 'chart-pie',
'href' => route('hub.account.usage'),
'active' => request()->routeIs('hub.account.usage'),
],
],
// Admin - Platform (Hades only)
[
'group' => 'admin',
'priority' => 10,
'admin' => true,
'item' => fn () => [
'label' => 'Platform',
'icon' => 'server',
'href' => route('hub.platform'),
'active' => request()->routeIs('hub.platform*'),
],
],
// Admin - Services (Hades only)
[
'group' => 'admin',
'priority' => 20,
'admin' => true,
'item' => fn () => [
'label' => 'Services',
'icon' => 'puzzle-piece',
'href' => route('hub.admin.services'),
'active' => request()->routeIs('hub.admin.services'),
],
],
];
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,153 @@
{{--
Global search component with ⌘K keyboard shortcut.
Include in your layout:
<admin:global-search />
--}}
<div
x-data="{
init() {
// Listen for ⌘K / Ctrl+K keyboard shortcut
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
$wire.openSearch();
}
});
}
}"
x-on:navigate-to-url.window="Livewire.navigate($event.detail.url)"
>
{{-- Search trigger button (optional - can be placed in navbar) --}}
@if(false)
<button
wire:click="openSearch"
type="button"
class="flex items-center gap-2 rounded-lg bg-zinc-100 px-3 py-2 text-sm text-zinc-500 transition hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-700"
>
<core:icon name="magnifying-glass" class="h-4 w-4" />
<span>{{ __('hub::hub.search.button') }}</span>
<kbd class="ml-2 hidden rounded bg-zinc-200 px-1.5 py-0.5 text-xs font-medium text-zinc-500 dark:bg-zinc-700 dark:text-zinc-400 sm:inline-block">
⌘K
</kbd>
</button>
@endif
{{-- Search modal --}}
<core:modal wire:model="open" class="max-w-xl" variant="bare">
<div
class="overflow-hidden rounded-xl bg-white shadow-2xl ring-1 ring-zinc-900/5 dark:bg-zinc-800 dark:ring-zinc-700"
x-on:keydown.arrow-up.prevent="$wire.navigateUp()"
x-on:keydown.arrow-down.prevent="$wire.navigateDown()"
x-on:keydown.enter.prevent="$wire.selectCurrent()"
x-on:keydown.escape.prevent="$wire.closeSearch()"
>
{{-- Search input --}}
<div class="relative">
<core:icon name="magnifying-glass" class="pointer-events-none absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-zinc-400" />
<input
wire:model.live.debounce.300ms="query"
type="text"
placeholder="{{ __('hub::hub.search.placeholder') }}"
class="w-full border-0 bg-transparent py-4 pl-12 pr-4 text-zinc-900 placeholder-zinc-400 focus:outline-none focus:ring-0 dark:text-white"
autofocus
/>
@if($query)
<button
wire:click="$set('query', '')"
type="button"
class="absolute right-4 top-1/2 -translate-y-1/2 rounded p-1 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300"
>
<core:icon name="x-mark" class="h-4 w-4" />
</button>
@endif
</div>
{{-- Results --}}
@if(strlen($query) >= 2)
<div class="max-h-96 overflow-y-auto border-t border-zinc-200 dark:border-zinc-700">
@php $currentIndex = 0; @endphp
@forelse($this->results as $type => $items)
@if(count($items) > 0)
{{-- Category header --}}
<div class="sticky top-0 bg-zinc-50 px-4 py-2 text-xs font-semibold uppercase tracking-wider text-zinc-500 dark:bg-zinc-800/50 dark:text-zinc-400">
{{ str($type)->title()->plural() }}
</div>
{{-- Results list --}}
@foreach($items as $item)
<button
wire:click="navigateTo({{ json_encode($item) }})"
type="button"
class="flex w-full items-center gap-3 px-4 py-3 text-left transition {{ $selectedIndex === $currentIndex ? 'bg-blue-50 dark:bg-blue-900/20' : 'hover:bg-zinc-50 dark:hover:bg-zinc-700/50' }}"
>
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-zinc-100 text-zinc-500 dark:bg-zinc-700 dark:text-zinc-400">
<core:icon name="{{ $item['icon'] }}" class="h-5 w-5" />
</div>
<div class="min-w-0 flex-1">
<div class="truncate font-medium text-zinc-900 dark:text-white">
{{ $item['title'] }}
</div>
<div class="truncate text-sm text-zinc-500 dark:text-zinc-400">
{{ $item['subtitle'] }}
</div>
</div>
@if($selectedIndex === $currentIndex)
<core:icon name="arrow-right" class="h-4 w-4 text-blue-500" />
@endif
</button>
@php $currentIndex++; @endphp
@endforeach
@endif
@empty
{{-- No results --}}
<div class="px-4 py-12 text-center">
<core:icon name="magnifying-glass" class="mx-auto h-10 w-10 text-zinc-300 dark:text-zinc-600" />
<p class="mt-3 text-sm text-zinc-500 dark:text-zinc-400">
{{ __('hub::hub.search.no_results', ['query' => $query]) }}
</p>
</div>
@endforelse
@if(collect($this->results)->flatten(1)->isEmpty() && strlen($query) >= 2)
<div class="px-4 py-12 text-center">
<core:icon name="magnifying-glass" class="mx-auto h-10 w-10 text-zinc-300 dark:text-zinc-600" />
<p class="mt-3 text-sm text-zinc-500 dark:text-zinc-400">
{{ __('hub::hub.search.no_results', ['query' => $query]) }}
</p>
</div>
@endif
</div>
{{-- Footer with keyboard hints --}}
<div class="flex items-center justify-between border-t border-zinc-200 bg-zinc-50 px-4 py-2 dark:border-zinc-700 dark:bg-zinc-800/50">
<div class="flex items-center gap-4 text-xs text-zinc-400">
<span class="flex items-center gap-1">
<kbd class="rounded bg-zinc-200 px-1.5 py-0.5 font-mono dark:bg-zinc-700"></kbd>
<kbd class="rounded bg-zinc-200 px-1.5 py-0.5 font-mono dark:bg-zinc-700"></kbd>
{{ __('hub::hub.search.navigate') }}
</span>
<span class="flex items-center gap-1">
<kbd class="rounded bg-zinc-200 px-1.5 py-0.5 font-mono dark:bg-zinc-700"></kbd>
{{ __('hub::hub.search.select') }}
</span>
<span class="flex items-center gap-1">
<kbd class="rounded bg-zinc-200 px-1.5 py-0.5 font-mono dark:bg-zinc-700">esc</kbd>
{{ __('hub::hub.search.close') }}
</span>
</div>
</div>
@else
{{-- Initial state --}}
<div class="border-t border-zinc-200 px-4 py-12 text-center dark:border-zinc-700">
<core:icon name="magnifying-glass" class="mx-auto h-10 w-10 text-zinc-300 dark:text-zinc-600" />
<p class="mt-3 text-sm text-zinc-500 dark:text-zinc-400">
{{ __('hub::hub.search.start_typing') }}
</p>
</div>
@endif
</div>
</core:modal>
</div>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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