Add core components and initial setup for the PHP framework
This commit is contained in:
parent
d6fbabf4d9
commit
b26c430cd6
1066 changed files with 173555 additions and 964 deletions
65
.env.example
Normal file
65
.env.example
Normal 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
13
.gitignore
vendored
|
|
@ -1,7 +1,16 @@
|
||||||
/vendor/
|
/vendor
|
||||||
/.phpunit.cache/
|
|
||||||
composer.lock
|
composer.lock
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.idea/
|
.idea/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.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
96
app/Website/Demo/Boot.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
70
app/Website/Demo/Middleware/EnsureInstalled.php
Normal file
70
app/Website/Demo/Middleware/EnsureInstalled.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/Website/Demo/Routes/web.php
Normal file
38
app/Website/Demo/Routes/web.php
Normal 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');
|
||||||
|
});
|
||||||
26
app/Website/Demo/View/Blade/layouts/app.blade.php
Normal file
26
app/Website/Demo/View/Blade/layouts/app.blade.php
Normal 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>
|
||||||
246
app/Website/Demo/View/Blade/web/install.blade.php
Normal file
246
app/Website/Demo/View/Blade/web/install.blade.php
Normal 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>
|
||||||
56
app/Website/Demo/View/Blade/web/landing.blade.php
Normal file
56
app/Website/Demo/View/Blade/web/landing.blade.php
Normal 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>
|
||||||
79
app/Website/Demo/View/Blade/web/login.blade.php
Normal file
79
app/Website/Demo/View/Blade/web/login.blade.php
Normal 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">
|
||||||
|
← Back to home
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
216
app/Website/Demo/View/Modal/Install.php
Normal file
216
app/Website/Demo/View/Modal/Install.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/Website/Demo/View/Modal/Landing.php
Normal file
23
app/Website/Demo/View/Modal/Landing.php
Normal 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'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
66
app/Website/Demo/View/Modal/Login.php
Normal file
66
app/Website/Demo/View/Modal/Login.php
Normal 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
13
artisan
Executable 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);
|
||||||
104
composer.json
104
composer.json
|
|
@ -1,41 +1,109 @@
|
||||||
{
|
{
|
||||||
"name": "host-uk/core",
|
"name": "host-uk/core-app",
|
||||||
"description": "Modular monolith framework for Laravel - event-driven architecture with lazy module loading",
|
"type": "project",
|
||||||
"keywords": ["laravel", "modular", "monolith", "framework", "events", "modules"],
|
"description": "Core PHP Framework - Demo Application",
|
||||||
|
"keywords": ["laravel", "modular", "monolith", "framework"],
|
||||||
"license": "EUPL-1.2",
|
"license": "EUPL-1.2",
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Host UK",
|
|
||||||
"email": "dev@host.uk.com"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"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": {
|
"require-dev": {
|
||||||
"orchestra/testbench": "^9.0|^10.0",
|
"fakerphp/faker": "^1.23",
|
||||||
"phpunit/phpunit": "^11.0"
|
"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": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"Core\\": "src/Core/"
|
"App\\": "app/",
|
||||||
|
"Website\\": "app/Website/",
|
||||||
|
"Database\\Factories\\": "database/factories/",
|
||||||
|
"Database\\Seeders\\": "database/seeders/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"autoload-dev": {
|
"autoload-dev": {
|
||||||
"psr-4": {
|
"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": {
|
"extra": {
|
||||||
"laravel": {
|
"laravel": {
|
||||||
"providers": [
|
"dont-discover": []
|
||||||
"Core\\CoreServiceProvider"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"sort-packages": true
|
"optimize-autoloader": true,
|
||||||
|
"preferred-install": "dist",
|
||||||
|
"sort-packages": true,
|
||||||
|
"allow-plugins": {
|
||||||
|
"php-http/discovery": true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"minimum-stability": "stable",
|
"minimum-stability": "stable",
|
||||||
"prefer-stable": true
|
"prefer-stable": true
|
||||||
|
|
|
||||||
126
config/app.php
Normal file
126
config/app.php
Normal 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
115
config/auth.php
Normal 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
117
config/cache.php
Normal 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-'),
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -2,6 +2,30 @@
|
||||||
|
|
||||||
return [
|
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
|
| Module Paths
|
||||||
|
|
@ -23,4 +47,51 @@ return [
|
||||||
// app_path('Mod'),
|
// 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
183
config/database.php
Normal 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
80
config/filesystems.php
Normal 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
132
config/logging.php
Normal 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
118
config/mail.php
Normal 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
129
config/queue.php
Normal 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
38
config/services.php
Normal 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
217
config/session.php
Normal 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
1
database/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
*.sqlite*
|
||||||
44
database/factories/UserFactory.php
Normal file
44
database/factories/UserFactory.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
database/migrations/0001_01_01_000000_create_users_table.php
Normal file
49
database/migrations/0001_01_01_000000_create_users_table.php
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
35
database/migrations/0001_01_01_000001_create_cache_table.php
Normal file
35
database/migrations/0001_01_01_000001_create_cache_table.php
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
57
database/migrations/0001_01_01_000002_create_jobs_table.php
Normal file
57
database/migrations/0001_01_01_000002_create_jobs_table.php
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
25
database/seeders/DatabaseSeeder.php
Normal file
25
database/seeders/DatabaseSeeder.php
Normal 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
1989
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
16
package.json
Normal file
16
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
25
packages/core-admin/composer.json
Normal file
25
packages/core-admin/composer.json
Normal 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
|
||||||
|
}
|
||||||
30
packages/core-admin/src/Boot.php
Normal file
30
packages/core-admin/src/Boot.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
265
packages/core-admin/src/Mod/Hub/Boot.php
Normal file
265
packages/core-admin/src/Mod/Hub/Boot.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
144
packages/core-admin/src/Mod/Hub/Controllers/TeapotController.php
Normal file
144
packages/core-admin/src/Mod/Hub/Controllers/TeapotController.php
Normal 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> ·
|
||||||
|
<a href="https://www.rfc-editor.org/rfc/rfc7168" target="_blank" rel="noopener">RFC 7168</a>
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
HTML;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
1030
packages/core-admin/src/Mod/Hub/Lang/en_GB/hub.php
Normal file
1030
packages/core-admin/src/Mod/Hub/Lang/en_GB/hub.php
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('honeypot_hits', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('ip_address', 45);
|
||||||
|
$table->string('user_agent', 1000)->nullable();
|
||||||
|
$table->string('referer', 2000)->nullable();
|
||||||
|
$table->string('path', 255);
|
||||||
|
$table->string('method', 10);
|
||||||
|
$table->json('headers')->nullable();
|
||||||
|
$table->string('country', 2)->nullable();
|
||||||
|
$table->string('city', 100)->nullable();
|
||||||
|
$table->boolean('is_bot')->default(false);
|
||||||
|
$table->string('bot_name', 100)->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index('ip_address');
|
||||||
|
$table->index('created_at');
|
||||||
|
$table->index('is_bot');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('honeypot_hits');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('platform_services', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('code', 50)->unique(); // 'bio', 'social' - matches module's service key
|
||||||
|
$table->string('module', 50); // 'WebPage', 'Social' - source module name
|
||||||
|
$table->string('name', 100); // 'Bio' - display name
|
||||||
|
$table->string('tagline', 200)->nullable(); // 'Link-in-bio pages' - short marketing tagline
|
||||||
|
$table->text('description')->nullable(); // Marketing description
|
||||||
|
$table->string('icon', 50)->nullable(); // Font Awesome icon name
|
||||||
|
$table->string('color', 20)->nullable(); // Tailwind color name
|
||||||
|
$table->string('marketing_domain', 100)->nullable(); // 'lthn.test', 'social.host.test'
|
||||||
|
$table->string('marketing_url', 255)->nullable(); // Full marketing page URL override
|
||||||
|
$table->string('docs_url', 255)->nullable(); // Documentation URL
|
||||||
|
$table->boolean('is_enabled')->default(true); // Global enable/disable
|
||||||
|
$table->boolean('is_public')->default(true); // Show in public service catalogue
|
||||||
|
$table->boolean('is_featured')->default(false); // Feature in marketing
|
||||||
|
$table->string('entitlement_code', 50)->nullable(); // 'core.srv.bio' - links to entitlement system
|
||||||
|
$table->integer('sort_order')->default(50);
|
||||||
|
$table->json('metadata')->nullable(); // Extensible for future needs
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index('is_enabled');
|
||||||
|
$table->index('is_public');
|
||||||
|
$table->index('sort_order');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('platform_services');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('platform_services', function (Blueprint $table) {
|
||||||
|
// Mod class to handle marketing_domain routing
|
||||||
|
// e.g., 'Mod\LtHn\Boot' for lthn.test
|
||||||
|
$table->string('website_class', 150)->nullable()->after('marketing_domain');
|
||||||
|
|
||||||
|
$table->index('marketing_domain');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('platform_services', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['marketing_domain']);
|
||||||
|
$table->dropColumn('website_class');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
174
packages/core-admin/src/Mod/Hub/Models/HoneypotHit.php
Normal file
174
packages/core-admin/src/Mod/Hub/Models/HoneypotHit.php
Normal 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
149
packages/core-admin/src/Mod/Hub/Models/Service.php
Normal file
149
packages/core-admin/src/Mod/Hub/Models/Service.php
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Hub\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class Service extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'platform_services';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'code',
|
||||||
|
'module',
|
||||||
|
'name',
|
||||||
|
'tagline',
|
||||||
|
'description',
|
||||||
|
'icon',
|
||||||
|
'color',
|
||||||
|
'marketing_domain',
|
||||||
|
'website_class',
|
||||||
|
'marketing_url',
|
||||||
|
'docs_url',
|
||||||
|
'is_enabled',
|
||||||
|
'is_public',
|
||||||
|
'is_featured',
|
||||||
|
'entitlement_code',
|
||||||
|
'sort_order',
|
||||||
|
'metadata',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_enabled' => 'boolean',
|
||||||
|
'is_public' => 'boolean',
|
||||||
|
'is_featured' => 'boolean',
|
||||||
|
'metadata' => 'array',
|
||||||
|
'sort_order' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope: only enabled services.
|
||||||
|
*/
|
||||||
|
public function scopeEnabled(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('is_enabled', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope: only public services (visible in catalogue).
|
||||||
|
*/
|
||||||
|
public function scopePublic(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('is_public', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope: only featured services.
|
||||||
|
*/
|
||||||
|
public function scopeFeatured(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('is_featured', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope: order by sort_order, then name.
|
||||||
|
*/
|
||||||
|
public function scopeOrdered(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->orderBy('sort_order')->orderBy('name');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope: services with a marketing domain configured.
|
||||||
|
*/
|
||||||
|
public function scopeWithMarketingDomain(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->whereNotNull('marketing_domain')
|
||||||
|
->whereNotNull('website_class');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a service by its code.
|
||||||
|
*/
|
||||||
|
public static function findByCode(string $code): ?self
|
||||||
|
{
|
||||||
|
return self::where('code', $code)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get domain → website_class mappings for enabled services.
|
||||||
|
*
|
||||||
|
* Used by DomainResolver for routing marketing domains.
|
||||||
|
*
|
||||||
|
* @return array<string, string> domain => website_class
|
||||||
|
*/
|
||||||
|
public static function getDomainMappings(): array
|
||||||
|
{
|
||||||
|
return self::enabled()
|
||||||
|
->withMarketingDomain()
|
||||||
|
->pluck('website_class', 'marketing_domain')
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the marketing URL, falling back to marketing_domain if no override set.
|
||||||
|
*/
|
||||||
|
public function getMarketingUrlAttribute(?string $value): ?string
|
||||||
|
{
|
||||||
|
if ($value) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->marketing_domain) {
|
||||||
|
$scheme = app()->environment('local') ? 'http' : 'https';
|
||||||
|
|
||||||
|
return "{$scheme}://{$this->marketing_domain}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a specific metadata key exists.
|
||||||
|
*/
|
||||||
|
public function hasMeta(string $key): bool
|
||||||
|
{
|
||||||
|
return isset($this->metadata[$key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific metadata value.
|
||||||
|
*/
|
||||||
|
public function getMeta(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
return $this->metadata[$key] ?? $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a metadata value.
|
||||||
|
*/
|
||||||
|
public function setMeta(string $key, mixed $value): void
|
||||||
|
{
|
||||||
|
$metadata = $this->metadata ?? [];
|
||||||
|
$metadata[$key] = $value;
|
||||||
|
$this->metadata = $metadata;
|
||||||
|
}
|
||||||
|
}
|
||||||
255
packages/core-admin/src/Mod/Hub/Tests/Feature/HubRoutesTest.php
Normal file
255
packages/core-admin/src/Mod/Hub/Tests/Feature/HubRoutesTest.php
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hub Routes Tests (TASK-010 Phase 2)
|
||||||
|
*
|
||||||
|
* Comprehensive tests for all authenticated hub routes.
|
||||||
|
* Each test asserts meaningful HTML content, not just status codes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use Core\Mod\Tenant\Models\User;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->user = User::factory()->create([
|
||||||
|
'account_type' => 'hades',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Hub Routes (Guest)', function () {
|
||||||
|
it('redirects guests from hub home to login', function () {
|
||||||
|
$this->get('/hub')
|
||||||
|
->assertRedirect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects guests from hub dashboard to login', function () {
|
||||||
|
$this->get('/hub/dashboard')
|
||||||
|
->assertRedirect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects guests from SocialHost to login', function () {
|
||||||
|
$this->get('/hub/social')
|
||||||
|
->assertRedirect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects guests from profile to login', function () {
|
||||||
|
$this->get('/hub/profile')
|
||||||
|
->assertRedirect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects guests from settings to login', function () {
|
||||||
|
$this->get('/hub/settings')
|
||||||
|
->assertRedirect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects guests from billing to login', function () {
|
||||||
|
$this->get('/hub/billing')
|
||||||
|
->assertRedirect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects guests from analytics to login', function () {
|
||||||
|
$this->get('/hub/analytics')
|
||||||
|
->assertRedirect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects guests from bio to login', function () {
|
||||||
|
$this->get('/hub/bio')
|
||||||
|
->assertRedirect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects guests from notify to login', function () {
|
||||||
|
$this->get('/hub/notify')
|
||||||
|
->assertRedirect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects guests from trust to login', function () {
|
||||||
|
$this->get('/hub/trust')
|
||||||
|
->assertRedirect();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Hub Home (Authenticated)', function () {
|
||||||
|
it('renders hub home with welcome banner', function () {
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->get('/hub')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Dashboard')
|
||||||
|
->assertSee('Your creator toolkit at a glance');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays service cards on hub home', function () {
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->get('/hub')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('BioHost')
|
||||||
|
->assertSee('SocialHost');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Hub Profile (Authenticated)', function () {
|
||||||
|
it('renders profile page with user information', function () {
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->get('/hub/profile')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee($this->user->name)
|
||||||
|
->assertSee($this->user->email);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays tier badge on profile', function () {
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->get('/hub/profile')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Settings');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Hub Settings (Authenticated)', function () {
|
||||||
|
it('renders settings page with profile form', function () {
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->get('/hub/settings')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Account Settings')
|
||||||
|
->assertSee('Profile Information');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays save button on settings', function () {
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->get('/hub/settings')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Save Profile');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Billing Dashboard (Authenticated)', function () {
|
||||||
|
it('renders billing dashboard with current plan', function () {
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->get('/hub/billing')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Billing')
|
||||||
|
->assertSee('Current Plan');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays plan upgrade option', function () {
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->get('/hub/billing')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Upgrade');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SocialHost Dashboard (Authenticated)', function () {
|
||||||
|
it('renders social dashboard with analytics heading', function () {
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->get('/hub/social')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Dashboard')
|
||||||
|
->assertSee('social accounts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays period selector on social dashboard', function () {
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->get('/hub/social')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('7 days')
|
||||||
|
->assertSee('30 days');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AnalyticsHost Index (Authenticated)', function () {
|
||||||
|
it('renders analytics index with page header', function () {
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->get('/hub/analytics')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Analytics')
|
||||||
|
->assertSee('Privacy-focused');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays add website button on analytics', function () {
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->get('/hub/analytics')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Add Mod');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BioHost Index (Authenticated)', function () {
|
||||||
|
it('renders bio index with page header', function () {
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->get('/hub/bio')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Bio');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays new bio page button', function () {
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->get('/hub/bio')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('New');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('NotifyHost Index (Authenticated)', function () {
|
||||||
|
it('renders notify index with page header', function () {
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->get('/hub/notify')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Notify');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays add website button on notify', function () {
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->get('/hub/notify')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Add');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TrustHost Index (Authenticated)', function () {
|
||||||
|
it('renders trust index with page header', function () {
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->get('/hub/trust')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Trust');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays add campaign button on trust', function () {
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->get('/hub/trust')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Add');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Dev API Routes (Hades only)', function () {
|
||||||
|
it('allows Hades users to access dev logs API', function () {
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->getJson('/hub/api/dev/logs')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonIsArray();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows Hades users to access dev routes API', function () {
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->getJson('/hub/api/dev/routes')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonIsArray();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows Hades users to access dev session API', function () {
|
||||||
|
$this->actingAs($this->user)
|
||||||
|
->getJson('/hub/api/dev/session')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonStructure(['id', 'ip', 'user_agent']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies non-Hades users access to dev APIs', function () {
|
||||||
|
$regularUser = User::factory()->create([
|
||||||
|
'account_type' => 'apollo',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($regularUser)
|
||||||
|
->getJson('/hub/api/dev/logs')
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
191
packages/core-admin/src/Website/Hub/Boot.php
Normal file
191
packages/core-admin/src/Website/Hub/Boot.php
Normal 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'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
74
packages/core-admin/src/Website/Hub/Routes/admin.php
Normal file
74
packages/core-admin/src/Website/Hub/Routes/admin.php
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Host Hub Routes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Core routes for the Host Hub admin/customer panel.
|
||||||
|
| Note: The 'hub' prefix and 'hub.' name prefix are added by Boot.php
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
Route::get('/', \Website\Hub\View\Modal\Admin\Dashboard::class)->name('dashboard');
|
||||||
|
Route::redirect('/dashboard', '/hub')->name('dashboard.redirect');
|
||||||
|
Route::get('/content/{workspace}/{type}', \Website\Hub\View\Modal\Admin\Content::class)->name('content')
|
||||||
|
->where('type', 'posts|pages|media');
|
||||||
|
Route::get('/content-manager/{workspace}/{view?}', \Website\Hub\View\Modal\Admin\ContentManager::class)->name('content-manager')
|
||||||
|
->where('view', 'dashboard|kanban|calendar|list|webhooks');
|
||||||
|
Route::get('/content-editor/{workspace}/new/{contentType?}', \Website\Hub\View\Modal\Admin\ContentEditor::class)->name('content-editor.create');
|
||||||
|
Route::get('/content-editor/{workspace}/{id}', \Website\Hub\View\Modal\Admin\ContentEditor::class)->name('content-editor.edit')
|
||||||
|
->where('id', '[0-9]+');
|
||||||
|
// /hub/workspaces redirects to current workspace settings (workspace switcher handles selection)
|
||||||
|
Route::get('/workspaces', \Website\Hub\View\Modal\Admin\Sites::class)->name('sites');
|
||||||
|
Route::redirect('/sites', '/hub/workspaces');
|
||||||
|
Route::get('/console', \Website\Hub\View\Modal\Admin\Console::class)->name('console');
|
||||||
|
Route::get('/databases', \Website\Hub\View\Modal\Admin\Databases::class)->name('databases');
|
||||||
|
// Account section
|
||||||
|
Route::get('/account', \Website\Hub\View\Modal\Admin\Profile::class)->name('account');
|
||||||
|
Route::get('/account/settings', \Website\Hub\View\Modal\Admin\Settings::class)->name('account.settings');
|
||||||
|
Route::get('/account/usage', \Website\Hub\View\Modal\Admin\AccountUsage::class)->name('account.usage');
|
||||||
|
Route::redirect('/profile', '/hub/account');
|
||||||
|
Route::redirect('/settings', '/hub/account/settings');
|
||||||
|
Route::redirect('/usage', '/hub/account/usage');
|
||||||
|
Route::redirect('/boosts', '/hub/account/usage?tab=boosts');
|
||||||
|
Route::redirect('/ai-services', '/hub/account/usage?tab=ai');
|
||||||
|
// Route::get('/config/{path?}', \Core\Config\View\Modal\Admin\WorkspaceConfig::class)
|
||||||
|
// ->where('path', '.*')
|
||||||
|
// ->name('workspace.config');
|
||||||
|
// Route::redirect('/workspace/config', '/hub/config');
|
||||||
|
Route::get('/workspaces/{workspace}/{tab?}', \Website\Hub\View\Modal\Admin\SiteSettings::class)
|
||||||
|
->where('tab', 'services|general|deployment|environment|ssl|backups|danger')
|
||||||
|
->name('sites.settings');
|
||||||
|
Route::get('/deployments', \Website\Hub\View\Modal\Admin\Deployments::class)->name('deployments');
|
||||||
|
Route::get('/platform', \Website\Hub\View\Modal\Admin\Platform::class)->name('platform');
|
||||||
|
Route::get('/platform/user/{id}', \Website\Hub\View\Modal\Admin\PlatformUser::class)->name('platform.user')
|
||||||
|
->where('id', '[0-9]+');
|
||||||
|
Route::get('/prompts', \Website\Hub\View\Modal\Admin\PromptManager::class)->name('prompts');
|
||||||
|
|
||||||
|
// Entitlement management (admin only)
|
||||||
|
Route::get('/entitlements', \Website\Hub\View\Modal\Admin\Entitlement\Dashboard::class)->name('entitlements');
|
||||||
|
Route::get('/entitlements/packages', \Website\Hub\View\Modal\Admin\Entitlement\PackageManager::class)->name('entitlements.packages');
|
||||||
|
Route::get('/entitlements/features', \Website\Hub\View\Modal\Admin\Entitlement\FeatureManager::class)->name('entitlements.features');
|
||||||
|
|
||||||
|
// Waitlist management (admin only - Hades tier)
|
||||||
|
Route::get('/admin/waitlist', \Website\Hub\View\Modal\Admin\WaitlistManager::class)->name('admin.waitlist');
|
||||||
|
|
||||||
|
// Workspace management (admin only - Hades tier)
|
||||||
|
// Route::get('/admin/workspaces', \Core\Mod\Tenant\View\Modal\Admin\WorkspaceManager::class)->name('admin.workspaces');
|
||||||
|
// Route::get('/admin/workspaces/{id}', \Core\Mod\Tenant\View\Modal\Admin\WorkspaceDetails::class)->name('admin.workspaces.details')
|
||||||
|
// ->where('id', '[0-9]+');
|
||||||
|
|
||||||
|
// Service management (admin only - Hades tier)
|
||||||
|
Route::get('/admin/services', \Website\Hub\View\Modal\Admin\ServiceManager::class)->name('admin.services');
|
||||||
|
|
||||||
|
// Services - workspace admin for Bio, Social, Analytics, Notify, Trust, Support, Commerce
|
||||||
|
Route::get('/services/{service?}/{tab?}', \Website\Hub\View\Modal\Admin\ServicesAdmin::class)
|
||||||
|
->where('service', 'bio|social|analytics|notify|trust|support|commerce')
|
||||||
|
->where('tab', 'dashboard|pages|channels|projects|accounts|posts|websites|goals|subscribers|campaigns|notifications|inbox|settings|orders|subscriptions|coupons')
|
||||||
|
->name('services');
|
||||||
|
|
||||||
|
// Security - Honeypot monitoring
|
||||||
|
Route::get('/honeypot', \Website\Hub\View\Modal\Admin\Honeypot::class)->name('honeypot');
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
<div>
|
||||||
|
<!-- Page header -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-2xl md:text-3xl text-gray-800 dark:text-gray-100 font-bold">{{ __('hub::hub.boosts.title') }}</h1>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 mt-1">{{ __('hub::hub.boosts.subtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
@if(count($boostOptions) > 0)
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
@foreach($boostOptions as $boost)
|
||||||
|
<div class="bg-white dark:bg-gray-800 shadow-xs rounded-xl p-6">
|
||||||
|
<div class="flex items-start justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ $boost['feature_name'] }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{{ $boost['description'] }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
@switch($boost['boost_type'])
|
||||||
|
@case('add_limit')
|
||||||
|
<core:badge color="blue">+{{ number_format($boost['limit_value']) }}</core:badge>
|
||||||
|
@break
|
||||||
|
@case('unlimited')
|
||||||
|
<core:badge color="purple">{{ __('hub::hub.boosts.types.unlimited') }}</core:badge>
|
||||||
|
@break
|
||||||
|
@case('enable')
|
||||||
|
<core:badge color="green">{{ __('hub::hub.boosts.types.enable') }}</core:badge>
|
||||||
|
@break
|
||||||
|
@endswitch
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between pt-4 border-t border-gray-100 dark:border-gray-700/60">
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
@switch($boost['duration_type'])
|
||||||
|
@case('cycle_bound')
|
||||||
|
<core:icon name="clock" class="size-4 mr-1" />
|
||||||
|
{{ __('hub::hub.boosts.duration.cycle_bound') }}
|
||||||
|
@break
|
||||||
|
@case('duration')
|
||||||
|
<core:icon name="calendar" class="size-4 mr-1" />
|
||||||
|
{{ __('hub::hub.boosts.duration.limited') }}
|
||||||
|
@break
|
||||||
|
@case('permanent')
|
||||||
|
<core:icon name="infinity" class="size-4 mr-1" />
|
||||||
|
{{ __('hub::hub.boosts.duration.permanent') }}
|
||||||
|
@break
|
||||||
|
@endswitch
|
||||||
|
</div>
|
||||||
|
<core:button wire:click="purchaseBoost('{{ $boost['blesta_id'] }}')" size="sm" variant="primary">
|
||||||
|
{{ __('hub::hub.boosts.actions.purchase') }}
|
||||||
|
</core:button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="bg-white dark:bg-gray-800 shadow-xs rounded-xl">
|
||||||
|
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<core:icon name="rocket" class="size-8 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>{{ __('hub::hub.boosts.empty.title') }}</p>
|
||||||
|
<p class="text-sm mt-1">{{ __('hub::hub.boosts.empty.hint') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<!-- Info Section -->
|
||||||
|
<div class="bg-blue-500/10 dark:bg-blue-500/20 rounded-xl p-6">
|
||||||
|
<h3 class="font-semibold text-blue-900 dark:text-blue-100 mb-2">
|
||||||
|
<core:icon name="circle-info" class="size-5 mr-2" />
|
||||||
|
{{ __('hub::hub.boosts.info.title') }}
|
||||||
|
</h3>
|
||||||
|
<ul class="text-sm text-blue-800 dark:text-blue-200 space-y-2 ml-7">
|
||||||
|
<li><strong>{{ __('hub::hub.boosts.labels.cycle_bound') }}</strong> {{ __('hub::hub.boosts.info.cycle_bound') }}</li>
|
||||||
|
<li><strong>{{ __('hub::hub.boosts.labels.duration_based') }}</strong> {{ __('hub::hub.boosts.info.duration_based') }}</li>
|
||||||
|
<li><strong>{{ __('hub::hub.boosts.labels.permanent') }}</strong> {{ __('hub::hub.boosts.info.permanent') }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Back Link -->
|
||||||
|
<div class="flex justify-start">
|
||||||
|
<core:button href="{{ route('hub.usage') }}" variant="ghost">
|
||||||
|
<core:icon name="arrow-left" class="mr-2" />
|
||||||
|
{{ __('hub::hub.boosts.actions.back') }}
|
||||||
|
</core:button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,505 @@
|
||||||
|
@php
|
||||||
|
$user = auth()->user();
|
||||||
|
$showDevBar = $user && method_exists($user, 'isHades') && $user->isHades();
|
||||||
|
|
||||||
|
// Performance metrics
|
||||||
|
$queryCount = count(DB::getQueryLog());
|
||||||
|
$startTime = defined('LARAVEL_START') ? LARAVEL_START : microtime(true);
|
||||||
|
$loadTime = number_format((microtime(true) - $startTime) * 1000, 2);
|
||||||
|
$memoryUsage = number_format(memory_get_peak_usage(true) / 1024 / 1024, 1);
|
||||||
|
|
||||||
|
// Check available dev tools
|
||||||
|
$hasHorizon = class_exists(\Laravel\Horizon\Horizon::class);
|
||||||
|
$hasPulse = class_exists(\Laravel\Pulse\Pulse::class);
|
||||||
|
$hasTelescope = class_exists(\Laravel\Telescope\Telescope::class) && config('telescope.enabled', false);
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if($showDevBar)
|
||||||
|
<div
|
||||||
|
x-data="{
|
||||||
|
expanded: false,
|
||||||
|
activePanel: null,
|
||||||
|
logs: [],
|
||||||
|
routes: [],
|
||||||
|
routeFilter: '',
|
||||||
|
session: {},
|
||||||
|
loadingLogs: false,
|
||||||
|
loadingRoutes: false,
|
||||||
|
|
||||||
|
togglePanel(panel) {
|
||||||
|
if (this.activePanel === panel) {
|
||||||
|
this.activePanel = null;
|
||||||
|
} else {
|
||||||
|
this.activePanel = panel;
|
||||||
|
if (panel === 'logs' && this.logs.length === 0) this.loadLogs();
|
||||||
|
if (panel === 'routes' && this.routes.length === 0) this.loadRoutes();
|
||||||
|
if (panel === 'session') this.loadSession();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadLogs() {
|
||||||
|
this.loadingLogs = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/hub/api/dev/logs');
|
||||||
|
this.logs = await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
this.logs = [{ level: 'error', message: 'Failed to load logs', time: new Date().toISOString() }];
|
||||||
|
}
|
||||||
|
this.loadingLogs = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadRoutes() {
|
||||||
|
this.loadingRoutes = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/hub/api/dev/routes');
|
||||||
|
this.routes = await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
this.routes = [];
|
||||||
|
}
|
||||||
|
this.loadingRoutes = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadSession() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/hub/api/dev/session');
|
||||||
|
this.session = await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
this.session = { error: 'Failed to load session' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async clearCache(type) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/hub/api/dev/clear/' + type, { method: 'POST', headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' }});
|
||||||
|
const data = await res.json();
|
||||||
|
alert(data.message || 'Done!');
|
||||||
|
} catch (e) {
|
||||||
|
alert('Failed: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
class="fixed bottom-0 left-0 right-0 z-50"
|
||||||
|
>
|
||||||
|
<!-- Expandable Panel Area -->
|
||||||
|
<div
|
||||||
|
x-show="activePanel"
|
||||||
|
x-transition:enter="transition ease-out duration-200"
|
||||||
|
x-transition:enter-start="opacity-0 translate-y-4"
|
||||||
|
x-transition:enter-end="opacity-100 translate-y-0"
|
||||||
|
x-transition:leave="transition ease-in duration-150"
|
||||||
|
x-transition:leave-start="opacity-100 translate-y-0"
|
||||||
|
x-transition:leave-end="opacity-0 translate-y-4"
|
||||||
|
class="border-t border-violet-500/50 shadow-2xl"
|
||||||
|
style="background-color: #0a0a0f; max-height: 53vh; overflow-y: auto;"
|
||||||
|
>
|
||||||
|
<!-- Logs Panel -->
|
||||||
|
<div x-show="activePanel === 'logs'" class="p-4">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h3 class="text-violet-400 font-semibold text-sm">Recent Logs</h3>
|
||||||
|
<button @click="loadLogs()" class="text-xs text-gray-400 hover:text-white">
|
||||||
|
<i class="fa-solid fa-refresh" :class="{ 'animate-spin': loadingLogs }"></i> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1 font-mono text-xs">
|
||||||
|
<template x-if="loadingLogs">
|
||||||
|
<div class="text-gray-500">Loading...</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="!loadingLogs && logs.length === 0">
|
||||||
|
<div class="text-gray-500">No recent logs</div>
|
||||||
|
</template>
|
||||||
|
<template x-for="log in logs" :key="log.time">
|
||||||
|
<div class="flex items-start gap-2 py-1 border-b border-gray-800">
|
||||||
|
<span
|
||||||
|
class="px-1.5 py-0.5 rounded text-[10px] uppercase font-bold"
|
||||||
|
:class="{
|
||||||
|
'bg-red-500/20 text-red-400': log.level === 'error',
|
||||||
|
'bg-yellow-500/20 text-yellow-400': log.level === 'warning',
|
||||||
|
'bg-blue-500/20 text-blue-400': log.level === 'info',
|
||||||
|
'bg-gray-500/20 text-gray-400': !['error', 'warning', 'info'].includes(log.level)
|
||||||
|
}"
|
||||||
|
x-text="log.level"
|
||||||
|
></span>
|
||||||
|
<span class="text-gray-500" x-text="log.time"></span>
|
||||||
|
<span class="text-gray-300 flex-1 truncate" x-text="log.message"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Routes Panel -->
|
||||||
|
<div x-show="activePanel === 'routes'" class="p-4">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h3 class="text-violet-400 font-semibold text-sm">Routes</h3>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Filter routes..."
|
||||||
|
class="bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-white w-48"
|
||||||
|
x-model="routeFilter"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1 font-mono text-xs max-h-48 overflow-y-auto">
|
||||||
|
<template x-if="loadingRoutes">
|
||||||
|
<div class="text-gray-500">Loading...</div>
|
||||||
|
</template>
|
||||||
|
<template x-for="route in routes.filter(r => !routeFilter || r.uri.includes(routeFilter) || (r.name && r.name.includes(routeFilter)))" :key="route.uri + route.method">
|
||||||
|
<div class="flex items-center gap-2 py-1 border-b border-gray-800">
|
||||||
|
<span
|
||||||
|
class="px-1.5 py-0.5 rounded text-[10px] uppercase font-bold w-14 text-center"
|
||||||
|
:class="{
|
||||||
|
'bg-green-500/20 text-green-400': route.method === 'GET',
|
||||||
|
'bg-blue-500/20 text-blue-400': route.method === 'POST',
|
||||||
|
'bg-yellow-500/20 text-yellow-400': route.method === 'PUT' || route.method === 'PATCH',
|
||||||
|
'bg-red-500/20 text-red-400': route.method === 'DELETE',
|
||||||
|
}"
|
||||||
|
x-text="route.method"
|
||||||
|
></span>
|
||||||
|
<span class="text-gray-300" x-text="route.uri"></span>
|
||||||
|
<span class="text-gray-500 text-[10px]" x-text="route.name || ''"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Session Panel -->
|
||||||
|
<div x-show="activePanel === 'session'" class="p-4">
|
||||||
|
<h3 class="text-violet-400 font-semibold text-sm mb-3">Session & Request</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-4 text-xs font-mono">
|
||||||
|
<div>
|
||||||
|
<div class="text-gray-500 mb-1">Session ID</div>
|
||||||
|
<div class="text-gray-300 truncate" x-text="session.id || '-'"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-gray-500 mb-1">User Agent</div>
|
||||||
|
<div class="text-gray-300 truncate" x-text="session.user_agent || '-'"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-gray-500 mb-1">IP Address</div>
|
||||||
|
<div class="text-gray-300" x-text="session.ip || '-'"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-gray-500 mb-1">PHP Version</div>
|
||||||
|
<div class="text-gray-300">{{ PHP_VERSION }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-gray-500 mb-1">Laravel Version</div>
|
||||||
|
<div class="text-gray-300">{{ app()->version() }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-gray-500 mb-1">Environment</div>
|
||||||
|
<div class="text-gray-300">{{ app()->environment() }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cache Panel -->
|
||||||
|
<div x-show="activePanel === 'cache'" class="p-4">
|
||||||
|
<h3 class="text-violet-400 font-semibold text-sm mb-3">Cache Management</h3>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button @click="clearCache('cache')" class="px-3 py-1.5 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded text-xs transition-colors">
|
||||||
|
<i class="fa-solid fa-trash mr-1"></i> Clear Cache
|
||||||
|
</button>
|
||||||
|
<button @click="clearCache('config')" class="px-3 py-1.5 bg-yellow-500/20 hover:bg-yellow-500/30 text-yellow-400 rounded text-xs transition-colors">
|
||||||
|
<i class="fa-solid fa-gear mr-1"></i> Clear Config
|
||||||
|
</button>
|
||||||
|
<button @click="clearCache('view')" class="px-3 py-1.5 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 rounded text-xs transition-colors">
|
||||||
|
<i class="fa-solid fa-eye mr-1"></i> Clear Views
|
||||||
|
</button>
|
||||||
|
<button @click="clearCache('route')" class="px-3 py-1.5 bg-green-500/20 hover:bg-green-500/30 text-green-400 rounded text-xs transition-colors">
|
||||||
|
<i class="fa-solid fa-route mr-1"></i> Clear Routes
|
||||||
|
</button>
|
||||||
|
<button @click="clearCache('all')" class="px-3 py-1.5 bg-violet-500/20 hover:bg-violet-500/30 text-violet-400 rounded text-xs transition-colors">
|
||||||
|
<i class="fa-solid fa-bomb mr-1"></i> Clear All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-500 text-xs mt-3">
|
||||||
|
<i class="fa-solid fa-info-circle mr-1"></i>
|
||||||
|
These actions run artisan cache commands on the server.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Appearance Panel -->
|
||||||
|
<div x-show="activePanel === 'appearance'" class="p-4" x-data="{
|
||||||
|
iconStyle: localStorage.getItem('icon-style') || 'fa-notdog fa-solid',
|
||||||
|
iconSize: localStorage.getItem('icon-size') || 'fa-lg',
|
||||||
|
setStyle(style) {
|
||||||
|
this.iconStyle = style;
|
||||||
|
localStorage.setItem('icon-style', style);
|
||||||
|
document.cookie = 'icon-style=' + style + '; path=/; SameSite=Lax';
|
||||||
|
location.reload();
|
||||||
|
},
|
||||||
|
setSize(size) {
|
||||||
|
this.iconSize = size;
|
||||||
|
localStorage.setItem('icon-size', size);
|
||||||
|
document.cookie = 'icon-size=' + size + '; path=/; SameSite=Lax';
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}">
|
||||||
|
<!-- Classic Families -->
|
||||||
|
<h3 class="text-violet-400 font-semibold text-sm mb-2">Classic</h3>
|
||||||
|
<div class="grid grid-cols-4 md:grid-cols-5 lg:grid-cols-10 gap-2 mb-4">
|
||||||
|
<button @click="setStyle('fa-solid')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-solid' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||||
|
<i class="fa-solid fa-house text-xl text-gray-300"></i>
|
||||||
|
<span class="text-[10px] text-gray-400">Solid</span>
|
||||||
|
</button>
|
||||||
|
<button @click="setStyle('fa-regular')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-regular' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||||
|
<i class="fa-regular fa-house text-xl text-gray-300"></i>
|
||||||
|
<span class="text-[10px] text-gray-400">Regular</span>
|
||||||
|
</button>
|
||||||
|
<button @click="setStyle('fa-light')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-light' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||||
|
<i class="fa-light fa-house text-xl text-gray-300"></i>
|
||||||
|
<span class="text-[10px] text-gray-400">Light</span>
|
||||||
|
</button>
|
||||||
|
<button @click="setStyle('fa-thin')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-thin' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||||
|
<i class="fa-thin fa-house text-xl text-gray-300"></i>
|
||||||
|
<span class="text-[10px] text-gray-400">Thin</span>
|
||||||
|
</button>
|
||||||
|
<button @click="setStyle('fa-duotone')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-duotone' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||||
|
<i class="fa-duotone fa-house text-xl text-gray-300"></i>
|
||||||
|
<span class="text-[10px] text-gray-400">Duotone</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sharp Families -->
|
||||||
|
<h3 class="text-violet-400 font-semibold text-sm mb-2">Sharp</h3>
|
||||||
|
<div class="grid grid-cols-4 md:grid-cols-5 lg:grid-cols-10 gap-2 mb-4">
|
||||||
|
<button @click="setStyle('fa-sharp fa-solid')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-sharp fa-solid' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||||
|
<i class="fa-sharp fa-solid fa-house text-xl text-gray-300"></i>
|
||||||
|
<span class="text-[10px] text-gray-400">Solid</span>
|
||||||
|
</button>
|
||||||
|
<button @click="setStyle('fa-sharp fa-regular')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-sharp fa-regular' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||||
|
<i class="fa-sharp fa-regular fa-house text-xl text-gray-300"></i>
|
||||||
|
<span class="text-[10px] text-gray-400">Regular</span>
|
||||||
|
</button>
|
||||||
|
<button @click="setStyle('fa-sharp fa-light')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-sharp fa-light' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||||
|
<i class="fa-sharp fa-light fa-house text-xl text-gray-300"></i>
|
||||||
|
<span class="text-[10px] text-gray-400">Light</span>
|
||||||
|
</button>
|
||||||
|
<button @click="setStyle('fa-sharp fa-thin')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-sharp fa-thin' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||||
|
<i class="fa-sharp fa-thin fa-house text-xl text-gray-300"></i>
|
||||||
|
<span class="text-[10px] text-gray-400">Thin</span>
|
||||||
|
</button>
|
||||||
|
<button @click="setStyle('fa-sharp-duotone-solid')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-sharp-duotone-solid' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||||
|
<i class="fa-sharp-duotone-solid fa-house text-xl text-gray-300"></i>
|
||||||
|
<span class="text-[10px] text-gray-400">Duo Solid</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Specialty Families -->
|
||||||
|
<h3 class="text-violet-400 font-semibold text-sm mb-2">Specialty</h3>
|
||||||
|
<div class="grid grid-cols-4 md:grid-cols-5 lg:grid-cols-10 gap-2 mb-4">
|
||||||
|
<button @click="setStyle('fa-jelly fa-regular')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-jelly fa-regular' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||||
|
<i class="fa-jelly fa-regular fa-house text-xl text-gray-300"></i>
|
||||||
|
<span class="text-[10px] text-gray-400">Jelly</span>
|
||||||
|
</button>
|
||||||
|
<button @click="setStyle('fa-jelly-fill fa-regular')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-jelly-fill fa-regular' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||||
|
<i class="fa-jelly-fill fa-regular fa-house text-xl text-gray-300"></i>
|
||||||
|
<span class="text-[10px] text-gray-400">Jelly Fill</span>
|
||||||
|
</button>
|
||||||
|
<button @click="setStyle('fa-jelly-duo fa-regular')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-jelly-duo fa-regular' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||||
|
<i class="fa-jelly-duo fa-regular fa-house text-xl text-gray-300"></i>
|
||||||
|
<span class="text-[10px] text-gray-400">Jelly Duo</span>
|
||||||
|
</button>
|
||||||
|
<button @click="setStyle('fa-notdog fa-solid')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-notdog fa-solid' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||||
|
<i class="fa-notdog fa-solid fa-house text-xl text-gray-300"></i>
|
||||||
|
<span class="text-[10px] text-gray-400">Notdog</span>
|
||||||
|
</button>
|
||||||
|
<button @click="setStyle('fa-notdog-duo fa-solid')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-notdog-duo fa-solid' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||||
|
<i class="fa-notdog-duo fa-solid fa-house text-xl text-gray-300"></i>
|
||||||
|
<span class="text-[10px] text-gray-400">Notdog Duo</span>
|
||||||
|
</button>
|
||||||
|
<button @click="setStyle('fa-slab fa-regular')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-slab fa-regular' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||||
|
<i class="fa-slab fa-regular fa-house text-xl text-gray-300"></i>
|
||||||
|
<span class="text-[10px] text-gray-400">Slab</span>
|
||||||
|
</button>
|
||||||
|
<button @click="setStyle('fa-slab-press fa-regular')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-slab-press fa-regular' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||||
|
<i class="fa-slab-press fa-regular fa-house text-xl text-gray-300"></i>
|
||||||
|
<span class="text-[10px] text-gray-400">Slab Press</span>
|
||||||
|
</button>
|
||||||
|
<button @click="setStyle('fa-utility fa-semibold')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-utility fa-semibold' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||||
|
<i class="fa-utility fa-semibold fa-house text-xl text-gray-300"></i>
|
||||||
|
<span class="text-[10px] text-gray-400">Utility</span>
|
||||||
|
</button>
|
||||||
|
<button @click="setStyle('fa-utility-fill fa-semibold')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-utility-fill fa-semibold' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||||
|
<i class="fa-utility-fill fa-semibold fa-house text-xl text-gray-300"></i>
|
||||||
|
<span class="text-[10px] text-gray-400">Utility Fill</span>
|
||||||
|
</button>
|
||||||
|
<button @click="setStyle('fa-utility-duo fa-semibold')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-utility-duo fa-semibold' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||||
|
<i class="fa-utility-duo fa-semibold fa-house text-xl text-gray-300"></i>
|
||||||
|
<span class="text-[10px] text-gray-400">Utility Duo</span>
|
||||||
|
</button>
|
||||||
|
<button @click="setStyle('fa-whiteboard fa-semibold')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-whiteboard fa-semibold' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||||
|
<i class="fa-whiteboard fa-semibold fa-house text-xl text-gray-300"></i>
|
||||||
|
<span class="text-[10px] text-gray-400">Whiteboard</span>
|
||||||
|
</button>
|
||||||
|
<button @click="setStyle('fa-chisel fa-regular')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-chisel fa-regular' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||||
|
<i class="fa-chisel fa-regular fa-house text-xl text-gray-300"></i>
|
||||||
|
<span class="text-[10px] text-gray-400">Chisel</span>
|
||||||
|
</button>
|
||||||
|
<button @click="setStyle('fa-etch fa-solid')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-etch fa-solid' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||||
|
<i class="fa-etch fa-solid fa-house text-xl text-gray-300"></i>
|
||||||
|
<span class="text-[10px] text-gray-400">Etch</span>
|
||||||
|
</button>
|
||||||
|
<button @click="setStyle('fa-thumbprint fa-light')" class="flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors" :class="iconStyle === 'fa-thumbprint fa-light' ? 'border-violet-500 bg-violet-500/10' : 'border-gray-700 hover:border-gray-600'">
|
||||||
|
<i class="fa-thumbprint fa-light fa-house text-xl text-gray-300"></i>
|
||||||
|
<span class="text-[10px] text-gray-400">Thumbprint</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Icon Size -->
|
||||||
|
<h3 class="text-violet-400 font-semibold text-sm mb-2">Size</h3>
|
||||||
|
<div class="flex flex-wrap gap-2 mb-3">
|
||||||
|
<button @click="setSize('')" class="px-3 py-1.5 rounded-lg border text-xs transition-colors" :class="iconSize === '' ? 'border-violet-500 bg-violet-500/10 text-violet-300' : 'border-gray-700 text-gray-400 hover:border-gray-600'">
|
||||||
|
Default
|
||||||
|
</button>
|
||||||
|
<button @click="setSize('fa-2xs')" class="px-3 py-1.5 rounded-lg border text-xs transition-colors" :class="iconSize === 'fa-2xs' ? 'border-violet-500 bg-violet-500/10 text-violet-300' : 'border-gray-700 text-gray-400 hover:border-gray-600'">
|
||||||
|
<i class="fa-solid fa-house fa-2xs mr-1"></i> 2XS
|
||||||
|
</button>
|
||||||
|
<button @click="setSize('fa-xs')" class="px-3 py-1.5 rounded-lg border text-xs transition-colors" :class="iconSize === 'fa-xs' ? 'border-violet-500 bg-violet-500/10 text-violet-300' : 'border-gray-700 text-gray-400 hover:border-gray-600'">
|
||||||
|
<i class="fa-solid fa-house fa-xs mr-1"></i> XS
|
||||||
|
</button>
|
||||||
|
<button @click="setSize('fa-sm')" class="px-3 py-1.5 rounded-lg border text-xs transition-colors" :class="iconSize === 'fa-sm' ? 'border-violet-500 bg-violet-500/10 text-violet-300' : 'border-gray-700 text-gray-400 hover:border-gray-600'">
|
||||||
|
<i class="fa-solid fa-house fa-sm mr-1"></i> SM
|
||||||
|
</button>
|
||||||
|
<button @click="setSize('fa-lg')" class="px-3 py-1.5 rounded-lg border text-xs transition-colors" :class="iconSize === 'fa-lg' ? 'border-violet-500 bg-violet-500/10 text-violet-300' : 'border-gray-700 text-gray-400 hover:border-gray-600'">
|
||||||
|
<i class="fa-solid fa-house fa-lg mr-1"></i> LG
|
||||||
|
</button>
|
||||||
|
<button @click="setSize('fa-xl')" class="px-3 py-1.5 rounded-lg border text-xs transition-colors" :class="iconSize === 'fa-xl' ? 'border-violet-500 bg-violet-500/10 text-violet-300' : 'border-gray-700 text-gray-400 hover:border-gray-600'">
|
||||||
|
<i class="fa-solid fa-house fa-xl mr-1"></i> XL
|
||||||
|
</button>
|
||||||
|
<button @click="setSize('fa-2xl')" class="px-3 py-1.5 rounded-lg border text-xs transition-colors" :class="iconSize === 'fa-2xl' ? 'border-violet-500 bg-violet-500/10 text-violet-300' : 'border-gray-700 text-gray-400 hover:border-gray-600'">
|
||||||
|
<i class="fa-solid fa-house fa-2xl mr-1"></i> 2XL
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-gray-500 text-xs">
|
||||||
|
<i class="fa-solid fa-info-circle mr-1"></i>
|
||||||
|
Current: <code class="text-violet-400" x-text="iconStyle"></code>
|
||||||
|
<span x-show="iconSize"> + <code class="text-violet-400" x-text="iconSize"></code></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Bar -->
|
||||||
|
<div class="border-t border-violet-500/50 text-white text-xs font-mono shadow-lg" style="background-color: #0a0a0f;">
|
||||||
|
<div class="flex items-center justify-between px-4 py-2">
|
||||||
|
<!-- Left: Environment & User Info -->
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="px-2 py-0.5 bg-red-500/20 text-red-400 rounded text-[10px] font-semibold uppercase">
|
||||||
|
{{ app()->environment() }}
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-600">|</span>
|
||||||
|
<span class="text-violet-300">
|
||||||
|
<i class="fa-solid fa-bolt mr-1"></i>Hades
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="hidden sm:flex items-center gap-2 text-gray-400">
|
||||||
|
<i class="fa-solid fa-user text-xs"></i>
|
||||||
|
<span>{{ $user->name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Panel Toggle Buttons (positioned left of center) -->
|
||||||
|
<div class="flex items-center gap-2 ml-8">
|
||||||
|
<button
|
||||||
|
@click="togglePanel('logs')"
|
||||||
|
class="flex items-center justify-center w-9 h-9 rounded-lg transition-colors"
|
||||||
|
:class="activePanel === 'logs' ? 'bg-violet-500/30 text-violet-300' : 'hover:bg-gray-800 text-gray-400 hover:text-white'"
|
||||||
|
title="View Logs"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-scroll text-lg"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="togglePanel('routes')"
|
||||||
|
class="flex items-center justify-center w-9 h-9 rounded-lg transition-colors"
|
||||||
|
:class="activePanel === 'routes' ? 'bg-violet-500/30 text-violet-300' : 'hover:bg-gray-800 text-gray-400 hover:text-white'"
|
||||||
|
title="View Routes"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-route text-lg"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="togglePanel('session')"
|
||||||
|
class="flex items-center justify-center w-9 h-9 rounded-lg transition-colors"
|
||||||
|
:class="activePanel === 'session' ? 'bg-violet-500/30 text-violet-300' : 'hover:bg-gray-800 text-gray-400 hover:text-white'"
|
||||||
|
title="Session Info"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-fingerprint text-lg"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="togglePanel('cache')"
|
||||||
|
class="flex items-center justify-center w-9 h-9 rounded-lg transition-colors"
|
||||||
|
:class="activePanel === 'cache' ? 'bg-violet-500/30 text-violet-300' : 'hover:bg-gray-800 text-gray-400 hover:text-white'"
|
||||||
|
title="Cache Management"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-database text-lg"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="togglePanel('appearance')"
|
||||||
|
class="flex items-center justify-center w-9 h-9 rounded-lg transition-colors"
|
||||||
|
:class="activePanel === 'appearance' ? 'bg-violet-500/30 text-violet-300' : 'hover:bg-gray-800 text-gray-400 hover:text-white'"
|
||||||
|
title="Appearance & Icons"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-palette text-lg"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span class="text-gray-700 mx-2">|</span>
|
||||||
|
|
||||||
|
<!-- External Dev Tools -->
|
||||||
|
@if($hasHorizon)
|
||||||
|
<a href="/horizon" target="_blank" class="flex items-center justify-center w-9 h-9 rounded-lg hover:bg-green-500/20 text-gray-400 hover:text-green-400 transition-colors" title="Laravel Horizon">
|
||||||
|
<i class="fa-solid fa-chart-line text-lg"></i>
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($hasPulse)
|
||||||
|
<a href="/pulse" target="_blank" class="flex items-center justify-center w-9 h-9 rounded-lg hover:bg-pink-500/20 text-gray-400 hover:text-pink-400 transition-colors" title="Laravel Pulse">
|
||||||
|
<i class="fa-solid fa-heart-pulse text-lg"></i>
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($hasTelescope)
|
||||||
|
<a href="/telescope" target="_blank" class="flex items-center justify-center w-9 h-9 rounded-lg hover:bg-indigo-500/20 text-gray-400 hover:text-indigo-400 transition-colors" title="Laravel Telescope">
|
||||||
|
<i class="fa-solid fa-satellite-dish text-lg"></i>
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: Performance Stats & Close -->
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="hidden md:flex items-center gap-3 text-gray-400">
|
||||||
|
<span title="Database queries">
|
||||||
|
<i class="fa-solid fa-database text-violet-400"></i>
|
||||||
|
{{ $queryCount }}q
|
||||||
|
</span>
|
||||||
|
<span title="Page load time">
|
||||||
|
<i class="fa-solid fa-clock text-violet-400"></i>
|
||||||
|
{{ $loadTime }}ms
|
||||||
|
</span>
|
||||||
|
<span title="Peak memory usage">
|
||||||
|
<i class="fa-solid fa-memory text-violet-400"></i>
|
||||||
|
{{ $memoryUsage }}MB
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="$el.closest('.fixed').classList.add('hidden')"
|
||||||
|
class="flex items-center justify-center w-6 h-6 bg-gray-700/50 hover:bg-red-500/30 hover:text-red-400 rounded transition-colors"
|
||||||
|
title="Hide dev bar (refresh to restore)"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-xmark"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add bottom padding to content when dev bar is visible -->
|
||||||
|
<style>
|
||||||
|
body { padding-bottom: 2.75rem; }
|
||||||
|
[x-cloak] { display: none !important; }
|
||||||
|
</style>
|
||||||
|
@endif
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
<admin:sidebar logo="/images/host-uk-raven.svg" logoText="Host Hub" :logoRoute="route('hub.dashboard')">
|
||||||
|
<admin:sidemenu />
|
||||||
|
</admin:sidebar>
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
<div>
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="sm:flex sm:justify-between sm:items-center mb-8">
|
||||||
|
<div class="mb-4 sm:mb-0">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<core:heading size="xl">{{ __('hub::hub.content_manager.title') }}</core:heading>
|
||||||
|
@if($currentWorkspace)
|
||||||
|
<core:badge color="violet" icon="server">
|
||||||
|
{{ $currentWorkspace->name }}
|
||||||
|
</core:badge>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<core:subheading>{{ __('hub::hub.content_manager.subtitle') }}</core:subheading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
@if($syncMessage)
|
||||||
|
<core:text size="sm" class="{{ str_contains($syncMessage, 'failed') ? 'text-red-500' : 'text-green-500' }}">
|
||||||
|
{{ $syncMessage }}
|
||||||
|
</core:text>
|
||||||
|
@endif
|
||||||
|
<core:button href="{{ route('hub.content-editor.create', ['workspace' => $workspaceSlug, 'contentType' => 'hostuk']) }}" variant="primary" icon="plus">
|
||||||
|
{{ __('hub::hub.content_manager.actions.new_content') }}
|
||||||
|
</core:button>
|
||||||
|
<core:button wire:click="syncAll" wire:loading.attr="disabled" icon="arrow-path" :loading="$syncing">
|
||||||
|
{{ __('hub::hub.content_manager.actions.sync_all') }}
|
||||||
|
</core:button>
|
||||||
|
<core:button wire:click="purgeCache" variant="ghost" icon="trash">
|
||||||
|
{{ __('hub::hub.content_manager.actions.purge_cdn') }}
|
||||||
|
</core:button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- View Tabs -->
|
||||||
|
<admin:tabs :tabs="$this->tabs" :selected="$view" />
|
||||||
|
|
||||||
|
<!-- Tab Content -->
|
||||||
|
@if($view === 'dashboard')
|
||||||
|
@include('hub::admin.content-manager.dashboard')
|
||||||
|
@elseif($view === 'kanban')
|
||||||
|
@include('hub::admin.content-manager.kanban')
|
||||||
|
@elseif($view === 'calendar')
|
||||||
|
@include('hub::admin.content-manager.calendar')
|
||||||
|
@elseif($view === 'list')
|
||||||
|
@include('hub::admin.content-manager.list')
|
||||||
|
@elseif($view === 'webhooks')
|
||||||
|
@include('hub::admin.content-manager.webhooks')
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<!-- Command Palette (Cmd+K) -->
|
||||||
|
<core:command class="hidden">
|
||||||
|
<core:command.input placeholder="{{ __('hub::hub.content_manager.command.placeholder') }}" />
|
||||||
|
<core:command.items>
|
||||||
|
<core:command.item icon="arrow-path" wire:click="syncAll">{{ __('hub::hub.content_manager.command.sync_all') }}</core:command.item>
|
||||||
|
<core:command.item icon="trash" wire:click="purgeCache">{{ __('hub::hub.content_manager.command.purge_cache') }}</core:command.item>
|
||||||
|
<core:command.item icon="arrow-top-right-on-square" href="{{ route('hub.content', ['workspace' => $workspaceSlug, 'type' => 'posts']) }}">{{ __('hub::hub.content_manager.command.open_wordpress') }}</core:command.item>
|
||||||
|
</core:command.items>
|
||||||
|
<core:command.empty>{{ __('hub::hub.content_manager.command.no_results') }}</core:command.empty>
|
||||||
|
</core:command>
|
||||||
|
|
||||||
|
<!-- Preview Slide-over -->
|
||||||
|
<core:modal name="content-preview" variant="flyout" class="max-w-2xl">
|
||||||
|
@if($this->selectedItem)
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<core:heading size="lg">{{ $this->selectedItem->title }}</core:heading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Meta Badges -->
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<x-content.status-badge :status="$this->selectedItem->status" />
|
||||||
|
<x-content.type-badge :type="$this->selectedItem->type" />
|
||||||
|
<x-content.sync-badge :status="$this->selectedItem->sync_status">
|
||||||
|
{{ __('hub::hub.content_manager.preview.sync_label') }}: {{ ucfirst($this->selectedItem->sync_status) }}
|
||||||
|
</x-content.sync-badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Author -->
|
||||||
|
@if($this->selectedItem->author)
|
||||||
|
<div class="flex items-center gap-3 p-3 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg">
|
||||||
|
@if($this->selectedItem->author->avatar_url)
|
||||||
|
<core:avatar src="{{ $this->selectedItem->author->avatar_url }}" />
|
||||||
|
@else
|
||||||
|
<core:avatar>{{ substr($this->selectedItem->author->name, 0, 1) }}</core:avatar>
|
||||||
|
@endif
|
||||||
|
<div>
|
||||||
|
<core:heading size="sm">{{ $this->selectedItem->author->name }}</core:heading>
|
||||||
|
<core:subheading size="xs">{{ __('hub::hub.content_manager.preview.author') }}</core:subheading>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<!-- Excerpt -->
|
||||||
|
@if($this->selectedItem->excerpt)
|
||||||
|
<div>
|
||||||
|
<core:label>{{ __('hub::hub.content_manager.preview.excerpt') }}</core:label>
|
||||||
|
<core:text class="mt-1">{{ $this->selectedItem->excerpt }}</core:text>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<!-- Content Preview -->
|
||||||
|
<div>
|
||||||
|
<core:label>{{ __('hub::hub.content_manager.preview.content_clean_html') }}</core:label>
|
||||||
|
<div class="mt-2 prose dark:prose-invert prose-sm max-w-none p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg max-h-96 overflow-y-auto">
|
||||||
|
{!! $this->selectedItem->content_html_clean ?: $this->selectedItem->content_html_original !!}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Categories & Tags -->
|
||||||
|
@if($this->selectedItem->categories->isNotEmpty() || $this->selectedItem->tags->isNotEmpty())
|
||||||
|
<div>
|
||||||
|
<core:label>{{ __('hub::hub.content_manager.preview.taxonomies') }}</core:label>
|
||||||
|
<div class="flex flex-wrap gap-2 mt-2">
|
||||||
|
@foreach($this->selectedItem->categories as $category)
|
||||||
|
<core:badge color="violet">{{ $category->name }}</core:badge>
|
||||||
|
@endforeach
|
||||||
|
@foreach($this->selectedItem->tags as $tag)
|
||||||
|
<core:badge color="zinc">#{{ $tag->name }}</core:badge>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<!-- Structured JSON -->
|
||||||
|
@if($this->selectedItem->content_json)
|
||||||
|
<div>
|
||||||
|
<core:label>{{ __('hub::hub.content_manager.preview.structured_content') }}</core:label>
|
||||||
|
<div class="mt-2 text-xs font-mono p-4 bg-zinc-900 text-zinc-100 rounded-lg max-h-64 overflow-y-auto">
|
||||||
|
<pre>{{ json_encode($this->selectedItem->content_json, JSON_PRETTY_PRINT) }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<!-- Timestamps -->
|
||||||
|
<core:separator />
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<core:text class="text-zinc-500">{{ __('hub::hub.content_manager.preview.created') }}:</core:text>
|
||||||
|
<core:text>{{ $this->selectedItem->wp_created_at?->format('M j, Y H:i') ?? '-' }}</core:text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<core:text class="text-zinc-500">{{ __('hub::hub.content_manager.preview.modified') }}:</core:text>
|
||||||
|
<core:text>{{ $this->selectedItem->wp_modified_at?->format('M j, Y H:i') ?? '-' }}</core:text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<core:text class="text-zinc-500">{{ __('hub::hub.content_manager.preview.last_synced') }}:</core:text>
|
||||||
|
<core:text>{{ $this->selectedItem->synced_at?->diffForHumans() ?? __('hub::hub.content_manager.preview.never') }}</core:text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<core:text class="text-zinc-500">{{ __('hub::hub.content_manager.preview.wordpress_id') }}:</core:text>
|
||||||
|
<core:text>#{{ $this->selectedItem->wp_id }}</core:text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</core:modal>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
<!-- Calendar View -->
|
||||||
|
<core:card class="p-6">
|
||||||
|
@php
|
||||||
|
$now = now();
|
||||||
|
$startOfMonth = $now->copy()->startOfMonth();
|
||||||
|
$endOfMonth = $now->copy()->endOfMonth();
|
||||||
|
$startDay = $startOfMonth->dayOfWeek;
|
||||||
|
$daysInMonth = $now->daysInMonth;
|
||||||
|
|
||||||
|
// Group events by date
|
||||||
|
$eventsByDate = collect($this->calendarEvents)->groupBy('date');
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<core:heading>{{ $now->format('F Y') }}</core:heading>
|
||||||
|
<core:subheading>{{ __('hub::hub.content_manager.calendar.content_schedule') }}</core:subheading>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<div class="w-2 h-2 rounded-full bg-green-500"></div>
|
||||||
|
<core:text size="xs">{{ __('hub::hub.content_manager.calendar.legend.published') }}</core:text>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<div class="w-2 h-2 rounded-full bg-yellow-500"></div>
|
||||||
|
<core:text size="xs">{{ __('hub::hub.content_manager.calendar.legend.draft') }}</core:text>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<div class="w-2 h-2 rounded-full bg-blue-500"></div>
|
||||||
|
<core:text size="xs">{{ __('hub::hub.content_manager.calendar.legend.scheduled') }}</core:text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<!-- Weekday Headers -->
|
||||||
|
<div class="grid grid-cols-7 gap-1 mb-2">
|
||||||
|
@foreach([
|
||||||
|
__('hub::hub.content_manager.calendar.days.sun'),
|
||||||
|
__('hub::hub.content_manager.calendar.days.mon'),
|
||||||
|
__('hub::hub.content_manager.calendar.days.tue'),
|
||||||
|
__('hub::hub.content_manager.calendar.days.wed'),
|
||||||
|
__('hub::hub.content_manager.calendar.days.thu'),
|
||||||
|
__('hub::hub.content_manager.calendar.days.fri'),
|
||||||
|
__('hub::hub.content_manager.calendar.days.sat')
|
||||||
|
] as $day)
|
||||||
|
<div class="text-center text-xs font-medium text-zinc-500 dark:text-zinc-400 py-2">
|
||||||
|
{{ $day }}
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Calendar Grid -->
|
||||||
|
<div class="grid grid-cols-7 gap-1">
|
||||||
|
{{-- Empty cells for days before start of month --}}
|
||||||
|
@for($i = 0; $i < $startDay; $i++)
|
||||||
|
<div class="aspect-square p-1 bg-zinc-50 dark:bg-zinc-800/30 rounded-lg"></div>
|
||||||
|
@endfor
|
||||||
|
|
||||||
|
{{-- Days of the month --}}
|
||||||
|
@for($day = 1; $day <= $daysInMonth; $day++)
|
||||||
|
@php
|
||||||
|
$dateStr = $now->copy()->setDay($day)->format('Y-m-d');
|
||||||
|
$dayEvents = $eventsByDate->get($dateStr, collect());
|
||||||
|
$isToday = $now->copy()->setDay($day)->isToday();
|
||||||
|
@endphp
|
||||||
|
<div class="aspect-square p-1 {{ $isToday ? 'bg-violet-50 dark:bg-violet-500/10 ring-2 ring-violet-500' : 'bg-zinc-50 dark:bg-zinc-800/30' }} rounded-lg overflow-hidden">
|
||||||
|
<div class="text-xs font-medium {{ $isToday ? 'text-violet-600 dark:text-violet-400' : 'text-zinc-600 dark:text-zinc-400' }} mb-1">
|
||||||
|
{{ $day }}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-0.5 max-h-16 overflow-hidden">
|
||||||
|
@foreach($dayEvents->take(3) as $event)
|
||||||
|
<button wire:click="selectItem({{ $event['id'] }})"
|
||||||
|
class="w-full text-left text-xs px-1 py-0.5 rounded truncate cursor-pointer
|
||||||
|
{{ $event['status'] === 'publish' ? 'bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400' :
|
||||||
|
($event['status'] === 'future' ? 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400' :
|
||||||
|
'bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400') }}">
|
||||||
|
{{ Str::limit($event['title'], 15) }}
|
||||||
|
</button>
|
||||||
|
@endforeach
|
||||||
|
@if($dayEvents->count() > 3)
|
||||||
|
<div class="text-xs text-zinc-400 dark:text-zinc-500 px-1">
|
||||||
|
{{ __('hub::hub.content_manager.calendar.more', ['count' => $dayEvents->count() - 3]) }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endfor
|
||||||
|
|
||||||
|
{{-- Empty cells for days after end of month --}}
|
||||||
|
@php
|
||||||
|
$remainingCells = 7 - (($startDay + $daysInMonth) % 7);
|
||||||
|
if ($remainingCells == 7) $remainingCells = 0;
|
||||||
|
@endphp
|
||||||
|
@for($i = 0; $i < $remainingCells; $i++)
|
||||||
|
<div class="aspect-square p-1 bg-zinc-50 dark:bg-zinc-800/30 rounded-lg"></div>
|
||||||
|
@endfor
|
||||||
|
</div>
|
||||||
|
</core:card>
|
||||||
|
|
@ -0,0 +1,240 @@
|
||||||
|
<!-- Stats Grid -->
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 mb-8">
|
||||||
|
<core:card class="p-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="p-2 rounded-lg bg-violet-100 dark:bg-violet-500/20">
|
||||||
|
<core:icon name="document-text" class="text-violet-600 dark:text-violet-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<core:heading size="xl">{{ $this->stats['total'] }}</core:heading>
|
||||||
|
<core:subheading size="sm">{{ __('hub::hub.content_manager.dashboard.total_content') }}</core:subheading>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</core:card>
|
||||||
|
|
||||||
|
<core:card class="p-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="p-2 rounded-lg bg-blue-100 dark:bg-blue-500/20">
|
||||||
|
<core:icon name="newspaper" class="text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<core:heading size="xl">{{ $this->stats['posts'] }}</core:heading>
|
||||||
|
<core:subheading size="sm">{{ __('hub::hub.content_manager.dashboard.posts') }}</core:subheading>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</core:card>
|
||||||
|
|
||||||
|
<core:card class="p-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="p-2 rounded-lg bg-green-100 dark:bg-green-500/20">
|
||||||
|
<core:icon name="check-circle" class="text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<core:heading size="xl">{{ $this->stats['published'] }}</core:heading>
|
||||||
|
<core:subheading size="sm">{{ __('hub::hub.content_manager.dashboard.published') }}</core:subheading>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</core:card>
|
||||||
|
|
||||||
|
<core:card class="p-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="p-2 rounded-lg bg-yellow-100 dark:bg-yellow-500/20">
|
||||||
|
<core:icon name="pencil" class="text-yellow-600 dark:text-yellow-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<core:heading size="xl">{{ $this->stats['drafts'] }}</core:heading>
|
||||||
|
<core:subheading size="sm">{{ __('hub::hub.content_manager.dashboard.drafts') }}</core:subheading>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</core:card>
|
||||||
|
|
||||||
|
<core:card class="p-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="p-2 rounded-lg bg-cyan-100 dark:bg-cyan-500/20">
|
||||||
|
<core:icon name="arrow-path" class="text-cyan-600 dark:text-cyan-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<core:heading size="xl">{{ $this->stats['synced'] }}</core:heading>
|
||||||
|
<core:subheading size="sm">{{ __('hub::hub.content_manager.dashboard.synced') }}</core:subheading>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</core:card>
|
||||||
|
|
||||||
|
<core:card class="p-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="p-2 rounded-lg bg-red-100 dark:bg-red-500/20">
|
||||||
|
<core:icon name="exclamation-circle" class="text-red-600 dark:text-red-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<core:heading size="xl">{{ $this->stats['failed'] }}</core:heading>
|
||||||
|
<core:subheading size="sm">{{ __('hub::hub.content_manager.dashboard.failed') }}</core:subheading>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</core:card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts Row -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||||
|
<!-- Content Over Time Chart -->
|
||||||
|
<core:card class="p-6">
|
||||||
|
<div class="mb-4">
|
||||||
|
<core:heading>{{ __('hub::hub.content_manager.dashboard.content_created') }}</core:heading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="h-64">
|
||||||
|
<core:chart :value="$this->chartData" class="h-full">
|
||||||
|
<core:chart.viewport class="h-48">
|
||||||
|
<core:chart.svg>
|
||||||
|
<core:chart.line field="count" class="text-violet-500" />
|
||||||
|
<core:chart.area field="count" class="text-violet-500/20" />
|
||||||
|
</core:chart.svg>
|
||||||
|
|
||||||
|
<core:chart.cursor>
|
||||||
|
<core:chart.tooltip>
|
||||||
|
<core:chart.tooltip.heading field="date" :format="['month' => 'short', 'day' => 'numeric']" />
|
||||||
|
<core:chart.tooltip.value field="count" label="{{ __('hub::hub.content_manager.dashboard.tooltip_posts') }}" />
|
||||||
|
</core:chart.tooltip>
|
||||||
|
</core:chart.cursor>
|
||||||
|
</core:chart.viewport>
|
||||||
|
|
||||||
|
<core:chart.axis axis="x" field="date" :format="['month' => 'short', 'day' => 'numeric']" />
|
||||||
|
</core:chart>
|
||||||
|
</div>
|
||||||
|
</core:card>
|
||||||
|
|
||||||
|
<!-- Content by Type Chart -->
|
||||||
|
<core:card class="p-6">
|
||||||
|
<div class="mb-4">
|
||||||
|
<core:heading>{{ __('hub::hub.content_manager.dashboard.content_by_type') }}</core:heading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="space-y-4">
|
||||||
|
@php
|
||||||
|
$total = $this->stats['posts'] + $this->stats['pages'];
|
||||||
|
$postsPercent = $total > 0 ? round(($this->stats['posts'] / $total) * 100) : 0;
|
||||||
|
$pagesPercent = $total > 0 ? round(($this->stats['pages'] / $total) * 100) : 0;
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between text-sm mb-1">
|
||||||
|
<span class="font-medium text-zinc-700 dark:text-zinc-300">{{ __('hub::hub.content_manager.dashboard.posts') }}</span>
|
||||||
|
<span class="text-zinc-500">{{ $this->stats['posts'] }} ({{ $postsPercent }}%)</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-zinc-200 dark:bg-zinc-700 rounded-full h-2">
|
||||||
|
<div class="bg-violet-500 h-2 rounded-full transition-all duration-300" style="width: {{ $postsPercent }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between text-sm mb-1">
|
||||||
|
<span class="font-medium text-zinc-700 dark:text-zinc-300">{{ __('hub::hub.content_manager.dashboard.pages') }}</span>
|
||||||
|
<span class="text-zinc-500">{{ $this->stats['pages'] }} ({{ $pagesPercent }}%)</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-zinc-200 dark:bg-zinc-700 rounded-full h-2">
|
||||||
|
<div class="bg-cyan-500 h-2 rounded-full transition-all duration-300" style="width: {{ $pagesPercent }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<core:separator class="my-6" />
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4 text-center">
|
||||||
|
<div>
|
||||||
|
<core:heading size="xl">{{ $this->stats['categories'] }}</core:heading>
|
||||||
|
<core:subheading size="sm">{{ __('hub::hub.content_manager.dashboard.categories') }}</core:subheading>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<core:heading size="xl">{{ $this->stats['tags'] }}</core:heading>
|
||||||
|
<core:subheading size="sm">{{ __('hub::hub.content_manager.dashboard.tags') }}</core:subheading>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</core:card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sync Status Overview -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
||||||
|
<core:card class="p-6">
|
||||||
|
<div class="mb-4">
|
||||||
|
<core:heading>{{ __('hub::hub.content_manager.dashboard.sync_status') }}</core:heading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-3 h-3 rounded-full bg-green-500"></div>
|
||||||
|
<core:text>{{ __('hub::hub.content_manager.dashboard.synced') }}</core:text>
|
||||||
|
</div>
|
||||||
|
<core:badge color="green">{{ $this->stats['synced'] }}</core:badge>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-3 h-3 rounded-full bg-yellow-500"></div>
|
||||||
|
<core:text>{{ __('hub::hub.content_manager.dashboard.pending') }}</core:text>
|
||||||
|
</div>
|
||||||
|
<core:badge color="yellow">{{ $this->stats['pending'] }}</core:badge>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-3 h-3 rounded-full bg-orange-500"></div>
|
||||||
|
<core:text>{{ __('hub::hub.content_manager.dashboard.stale') }}</core:text>
|
||||||
|
</div>
|
||||||
|
<core:badge color="orange">{{ $this->stats['stale'] }}</core:badge>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-3 h-3 rounded-full bg-red-500"></div>
|
||||||
|
<core:text>{{ __('hub::hub.content_manager.dashboard.failed') }}</core:text>
|
||||||
|
</div>
|
||||||
|
<core:badge color="red">{{ $this->stats['failed'] }}</core:badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</core:card>
|
||||||
|
|
||||||
|
<core:card class="p-6">
|
||||||
|
<div class="mb-4">
|
||||||
|
<core:heading>{{ __('hub::hub.content_manager.dashboard.taxonomies') }}</core:heading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<core:icon name="folder" class="text-violet-500" />
|
||||||
|
<core:text>{{ __('hub::hub.content_manager.dashboard.categories') }}</core:text>
|
||||||
|
</div>
|
||||||
|
<core:badge>{{ $this->stats['categories'] }}</core:badge>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<core:icon name="hashtag" class="text-blue-500" />
|
||||||
|
<core:text>{{ __('hub::hub.content_manager.dashboard.tags') }}</core:text>
|
||||||
|
</div>
|
||||||
|
<core:badge>{{ $this->stats['tags'] }}</core:badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</core:card>
|
||||||
|
|
||||||
|
<core:card class="p-6">
|
||||||
|
<div class="mb-4">
|
||||||
|
<core:heading>{{ __('hub::hub.content_manager.dashboard.webhooks_today') }}</core:heading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<core:icon name="bolt" class="text-cyan-500" />
|
||||||
|
<core:text>{{ __('hub::hub.content_manager.dashboard.received') }}</core:text>
|
||||||
|
</div>
|
||||||
|
<core:badge color="cyan">{{ $this->stats['webhooks_today'] }}</core:badge>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<core:icon name="exclamation-circle" class="text-red-500" />
|
||||||
|
<core:text>{{ __('hub::hub.content_manager.dashboard.failed') }}</core:text>
|
||||||
|
</div>
|
||||||
|
<core:badge color="red">{{ $this->stats['webhooks_failed'] }}</core:badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</core:card>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
<!-- Kanban Board -->
|
||||||
|
<core:kanban class="overflow-x-auto pb-4" style="min-height: 600px;">
|
||||||
|
@foreach($this->kanbanColumns as $column)
|
||||||
|
<core:kanban.column>
|
||||||
|
<core:kanban.column.header
|
||||||
|
:heading="$column['name']"
|
||||||
|
:count="$column['items']->count()"
|
||||||
|
:badge="$column['items']->count()"
|
||||||
|
badge:color="{{ $column['color'] }}"
|
||||||
|
>
|
||||||
|
<x-slot:actions>
|
||||||
|
@if($column['status'] === 'draft')
|
||||||
|
<core:button size="sm" variant="ghost" icon="plus" />
|
||||||
|
@endif
|
||||||
|
</x-slot:actions>
|
||||||
|
</core:kanban.column.header>
|
||||||
|
|
||||||
|
<core:kanban.column.cards class="max-h-[calc(100vh-300px)] overflow-y-auto">
|
||||||
|
@forelse($column['items'] as $item)
|
||||||
|
<core:kanban.card as="button" wire:click="selectItem({{ $item->id }})">
|
||||||
|
<x-slot:header>
|
||||||
|
<x-content.type-badge :type="$item->type" />
|
||||||
|
<x-content.sync-badge :status="$item->sync_status" />
|
||||||
|
</x-slot:header>
|
||||||
|
|
||||||
|
<core:heading size="sm" class="line-clamp-2">{{ $item->title }}</core:heading>
|
||||||
|
|
||||||
|
@if($item->excerpt)
|
||||||
|
<core:text size="sm" class="line-clamp-2 mt-1">
|
||||||
|
{{ Str::limit($item->excerpt, 80) }}
|
||||||
|
</core:text>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<x-slot:footer>
|
||||||
|
@if($item->categories && $item->categories->isNotEmpty())
|
||||||
|
@foreach($item->categories->take(2) as $category)
|
||||||
|
<core:badge size="sm" color="zinc">{{ $category->name }}</core:badge>
|
||||||
|
@endforeach
|
||||||
|
@if($item->categories->count() > 2)
|
||||||
|
<core:badge size="sm" color="zinc">+{{ $item->categories->count() - 2 }}</core:badge>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<core:text size="xs" class="text-zinc-400">
|
||||||
|
{{ $item->wp_created_at?->format('M j') ?? '-' }}
|
||||||
|
</core:text>
|
||||||
|
</x-slot:footer>
|
||||||
|
</core:kanban.card>
|
||||||
|
@empty
|
||||||
|
<div class="text-center py-8 text-zinc-400 dark:text-zinc-500">
|
||||||
|
<core:icon name="inbox" class="size-8 mx-auto mb-2 opacity-50" />
|
||||||
|
<core:text size="sm">{{ __('hub::hub.content_manager.kanban.no_items') }}</core:text>
|
||||||
|
</div>
|
||||||
|
@endforelse
|
||||||
|
</core:kanban.column.cards>
|
||||||
|
</core:kanban.column>
|
||||||
|
@endforeach
|
||||||
|
</core:kanban>
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
<!-- Filters Bar -->
|
||||||
|
<core:card class="mb-6">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-4 p-4">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<!-- Search -->
|
||||||
|
<core:input
|
||||||
|
wire:model.live.debounce.300ms="search"
|
||||||
|
placeholder="{{ __('hub::hub.content_manager.list.search_placeholder') }}"
|
||||||
|
icon="magnifying-glass"
|
||||||
|
class="w-64"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Type Filter -->
|
||||||
|
<core:select wire:model.live="type" placeholder="{{ __('hub::hub.content_manager.list.filters.all_types') }}" class="w-32">
|
||||||
|
<core:select.option value="">{{ __('hub::hub.content_manager.list.filters.all_types') }}</core:select.option>
|
||||||
|
<core:select.option value="post">{{ __('hub::hub.content_manager.list.filters.posts') }}</core:select.option>
|
||||||
|
<core:select.option value="page">{{ __('hub::hub.content_manager.list.filters.pages') }}</core:select.option>
|
||||||
|
</core:select>
|
||||||
|
|
||||||
|
<!-- Status Filter -->
|
||||||
|
<core:select wire:model.live="status" placeholder="{{ __('hub::hub.content_manager.list.filters.all_status') }}" class="w-36">
|
||||||
|
<core:select.option value="">{{ __('hub::hub.content_manager.list.filters.all_status') }}</core:select.option>
|
||||||
|
<core:select.option value="publish">{{ __('hub::hub.content_manager.list.filters.published') }}</core:select.option>
|
||||||
|
<core:select.option value="draft">{{ __('hub::hub.content_manager.list.filters.draft') }}</core:select.option>
|
||||||
|
<core:select.option value="pending">{{ __('hub::hub.content_manager.list.filters.pending') }}</core:select.option>
|
||||||
|
<core:select.option value="future">{{ __('hub::hub.content_manager.list.filters.scheduled') }}</core:select.option>
|
||||||
|
<core:select.option value="private">{{ __('hub::hub.content_manager.list.filters.private') }}</core:select.option>
|
||||||
|
</core:select>
|
||||||
|
|
||||||
|
<!-- Sync Status Filter -->
|
||||||
|
<core:select wire:model.live="syncStatus" placeholder="{{ __('hub::hub.content_manager.list.filters.all_sync') }}" class="w-36">
|
||||||
|
<core:select.option value="">{{ __('hub::hub.content_manager.list.filters.all_sync') }}</core:select.option>
|
||||||
|
<core:select.option value="synced">{{ __('hub::hub.content_manager.list.filters.synced') }}</core:select.option>
|
||||||
|
<core:select.option value="pending">{{ __('hub::hub.content_manager.list.filters.pending') }}</core:select.option>
|
||||||
|
<core:select.option value="stale">{{ __('hub::hub.content_manager.list.filters.stale') }}</core:select.option>
|
||||||
|
<core:select.option value="failed">{{ __('hub::hub.content_manager.list.filters.failed') }}</core:select.option>
|
||||||
|
</core:select>
|
||||||
|
|
||||||
|
<!-- Content Type Filter -->
|
||||||
|
<core:select wire:model.live="contentType" placeholder="{{ __('hub::hub.content_manager.list.filters.all_sources') }}" class="w-36">
|
||||||
|
<core:select.option value="">{{ __('hub::hub.content_manager.list.filters.all_sources') }}</core:select.option>
|
||||||
|
<core:select.option value="native">{{ __('hub::hub.content_manager.list.filters.native') }}</core:select.option>
|
||||||
|
<core:select.option value="hostuk">{{ __('hub::hub.content_manager.list.filters.host_uk') }}</core:select.option>
|
||||||
|
<core:select.option value="satellite">{{ __('hub::hub.content_manager.list.filters.satellite') }}</core:select.option>
|
||||||
|
@if(config('services.content.wordpress_enabled'))
|
||||||
|
<core:select.option value="wordpress">{{ __('hub::hub.content_manager.list.filters.wordpress_legacy') }}</core:select.option>
|
||||||
|
@endif
|
||||||
|
</core:select>
|
||||||
|
|
||||||
|
<!-- Category Filter -->
|
||||||
|
@if(count($this->categories) > 0)
|
||||||
|
<core:select wire:model.live="category" placeholder="{{ __('hub::hub.content_manager.list.filters.all_categories') }}" class="w-40">
|
||||||
|
<core:select.option value="">{{ __('hub::hub.content_manager.list.filters.all_categories') }}</core:select.option>
|
||||||
|
@foreach($this->categories as $slug => $name)
|
||||||
|
<core:select.option value="{{ $slug }}">{{ $name }}</core:select.option>
|
||||||
|
@endforeach
|
||||||
|
</core:select>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<!-- Clear Filters -->
|
||||||
|
@if($search || $type || $status || $syncStatus || $category || $contentType)
|
||||||
|
<core:button wire:click="clearFilters" variant="ghost" size="sm" icon="x-mark">
|
||||||
|
{{ __('hub::hub.content_manager.list.filters.clear') }}
|
||||||
|
</core:button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</core:card>
|
||||||
|
|
||||||
|
<!-- Content Table -->
|
||||||
|
<core:card>
|
||||||
|
<core:table :paginate="$this->content">
|
||||||
|
<core:table.columns>
|
||||||
|
<core:table.column
|
||||||
|
:sortable="true"
|
||||||
|
:sorted="$sort === 'title'"
|
||||||
|
:direction="$sort === 'title' ? $dir : null"
|
||||||
|
wire:click="setSort('title')"
|
||||||
|
>
|
||||||
|
{{ __('hub::hub.content_manager.list.columns.title') }}
|
||||||
|
</core:table.column>
|
||||||
|
<core:table.column class="hidden md:table-cell">{{ __('hub::hub.content_manager.list.columns.type') }}</core:table.column>
|
||||||
|
<core:table.column class="hidden md:table-cell">{{ __('hub::hub.content_manager.list.columns.status') }}</core:table.column>
|
||||||
|
<core:table.column class="hidden lg:table-cell">{{ __('hub::hub.content_manager.list.columns.sync') }}</core:table.column>
|
||||||
|
<core:table.column class="hidden lg:table-cell">{{ __('hub::hub.content_manager.list.columns.categories') }}</core:table.column>
|
||||||
|
<core:table.column
|
||||||
|
class="hidden xl:table-cell"
|
||||||
|
:sortable="true"
|
||||||
|
:sorted="$sort === 'wp_created_at'"
|
||||||
|
:direction="$sort === 'wp_created_at' ? $dir : null"
|
||||||
|
wire:click="setSort('wp_created_at')"
|
||||||
|
>
|
||||||
|
{{ __('hub::hub.content_manager.list.columns.created') }}
|
||||||
|
</core:table.column>
|
||||||
|
<core:table.column
|
||||||
|
class="hidden xl:table-cell"
|
||||||
|
:sortable="true"
|
||||||
|
:sorted="$sort === 'synced_at'"
|
||||||
|
:direction="$sort === 'synced_at' ? $dir : null"
|
||||||
|
wire:click="setSort('synced_at')"
|
||||||
|
>
|
||||||
|
{{ __('hub::hub.content_manager.list.columns.last_synced') }}
|
||||||
|
</core:table.column>
|
||||||
|
<core:table.column align="end"></core:table.column>
|
||||||
|
</core:table.columns>
|
||||||
|
|
||||||
|
<core:table.rows>
|
||||||
|
@forelse($this->content as $item)
|
||||||
|
<core:table.row :key="$item->id">
|
||||||
|
<core:table.cell variant="strong">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<button wire:click="selectItem({{ $item->id }})" class="font-medium hover:text-violet-600 dark:hover:text-violet-400 truncate text-left">
|
||||||
|
{{ $item->title }}
|
||||||
|
</button>
|
||||||
|
<core:text size="xs" class="truncate">{{ $item->slug }}</core:text>
|
||||||
|
</div>
|
||||||
|
</core:table.cell>
|
||||||
|
|
||||||
|
<core:table.cell class="hidden md:table-cell">
|
||||||
|
<x-content.type-badge :type="$item->type" />
|
||||||
|
</core:table.cell>
|
||||||
|
|
||||||
|
<core:table.cell class="hidden md:table-cell">
|
||||||
|
<x-content.status-badge :status="$item->status" />
|
||||||
|
</core:table.cell>
|
||||||
|
|
||||||
|
<core:table.cell class="hidden lg:table-cell">
|
||||||
|
<x-content.sync-badge :status="$item->sync_status" />
|
||||||
|
</core:table.cell>
|
||||||
|
|
||||||
|
<core:table.cell class="hidden lg:table-cell">
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
@foreach($item->categories->take(2) as $category)
|
||||||
|
<core:badge color="violet" size="sm">{{ $category->name }}</core:badge>
|
||||||
|
@endforeach
|
||||||
|
@if($item->categories->count() > 2)
|
||||||
|
<core:badge color="zinc" size="sm">+{{ $item->categories->count() - 2 }}</core:badge>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</core:table.cell>
|
||||||
|
|
||||||
|
<core:table.cell class="hidden xl:table-cell">
|
||||||
|
<core:text size="sm">{{ $item->wp_created_at?->format('M j, Y') ?? '-' }}</core:text>
|
||||||
|
</core:table.cell>
|
||||||
|
|
||||||
|
<core:table.cell class="hidden xl:table-cell">
|
||||||
|
<core:text size="sm">{{ $item->synced_at?->diffForHumans() ?? __('hub::hub.content_manager.list.never') }}</core:text>
|
||||||
|
</core:table.cell>
|
||||||
|
|
||||||
|
<core:table.cell align="end">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
@if($item->usesFluxEditor())
|
||||||
|
<core:button href="{{ route('hub.content-editor.edit', ['workspace' => $workspaceSlug, 'id' => $item->id]) }}" variant="ghost" size="sm" icon="pencil" title="{{ __('hub::hub.content_manager.list.edit') }}" />
|
||||||
|
@endif
|
||||||
|
<core:button wire:click="selectItem({{ $item->id }})" variant="ghost" size="sm" icon="eye" title="{{ __('hub::hub.content_manager.list.preview') }}" />
|
||||||
|
</div>
|
||||||
|
</core:table.cell>
|
||||||
|
</core:table.row>
|
||||||
|
@empty
|
||||||
|
<core:table.row>
|
||||||
|
<core:table.cell colspan="8" class="text-center py-12">
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<core:icon name="inbox" class="size-12 text-zinc-300 dark:text-zinc-600 mb-3" />
|
||||||
|
<core:text>{{ __('hub::hub.content_manager.list.no_content') }}</core:text>
|
||||||
|
@if($search || $type || $status || $syncStatus || $category)
|
||||||
|
<core:button wire:click="clearFilters" variant="ghost" size="sm" class="mt-2">
|
||||||
|
{{ __('hub::hub.content_manager.list.filters.clear_filters') }}
|
||||||
|
</core:button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</core:table.cell>
|
||||||
|
</core:table.row>
|
||||||
|
@endforelse
|
||||||
|
</core:table.rows>
|
||||||
|
</core:table>
|
||||||
|
</core:card>
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
<!-- Webhooks Overview -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
<core:card class="p-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="p-2 rounded-lg bg-cyan-100 dark:bg-cyan-500/20">
|
||||||
|
<core:icon name="bolt" class="text-cyan-600 dark:text-cyan-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<core:heading size="xl">{{ $this->stats['webhooks_today'] }}</core:heading>
|
||||||
|
<core:subheading size="sm">{{ __('hub::hub.content_manager.webhooks.today') }}</core:subheading>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</core:card>
|
||||||
|
|
||||||
|
<core:card class="p-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="p-2 rounded-lg bg-green-100 dark:bg-green-500/20">
|
||||||
|
<core:icon name="check" class="text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<core:heading size="xl">{{ $this->webhookLogs->where('status', 'completed')->count() }}</core:heading>
|
||||||
|
<core:subheading size="sm">{{ __('hub::hub.content_manager.webhooks.completed') }}</core:subheading>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</core:card>
|
||||||
|
|
||||||
|
<core:card class="p-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="p-2 rounded-lg bg-yellow-100 dark:bg-yellow-500/20">
|
||||||
|
<core:icon name="clock" class="text-yellow-600 dark:text-yellow-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<core:heading size="xl">{{ $this->webhookLogs->where('status', 'pending')->count() }}</core:heading>
|
||||||
|
<core:subheading size="sm">{{ __('hub::hub.content_manager.webhooks.pending') }}</core:subheading>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</core:card>
|
||||||
|
|
||||||
|
<core:card class="p-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="p-2 rounded-lg bg-red-100 dark:bg-red-500/20">
|
||||||
|
<core:icon name="exclamation-circle" class="text-red-600 dark:text-red-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<core:heading size="xl">{{ $this->stats['webhooks_failed'] }}</core:heading>
|
||||||
|
<core:subheading size="sm">{{ __('hub::hub.content_manager.webhooks.failed') }}</core:subheading>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</core:card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Webhook Logs Table -->
|
||||||
|
<core:card>
|
||||||
|
<core:table :paginate="$this->webhookLogs">
|
||||||
|
<core:table.columns>
|
||||||
|
<core:table.column>{{ __('hub::hub.content_manager.webhooks.columns.id') }}</core:table.column>
|
||||||
|
<core:table.column>{{ __('hub::hub.content_manager.webhooks.columns.event') }}</core:table.column>
|
||||||
|
<core:table.column class="hidden md:table-cell">{{ __('hub::hub.content_manager.webhooks.columns.content') }}</core:table.column>
|
||||||
|
<core:table.column>{{ __('hub::hub.content_manager.webhooks.columns.status') }}</core:table.column>
|
||||||
|
<core:table.column class="hidden lg:table-cell">{{ __('hub::hub.content_manager.webhooks.columns.source_ip') }}</core:table.column>
|
||||||
|
<core:table.column class="hidden lg:table-cell">{{ __('hub::hub.content_manager.webhooks.columns.received') }}</core:table.column>
|
||||||
|
<core:table.column class="hidden xl:table-cell">{{ __('hub::hub.content_manager.webhooks.columns.processed') }}</core:table.column>
|
||||||
|
<core:table.column align="end"></core:table.column>
|
||||||
|
</core:table.columns>
|
||||||
|
|
||||||
|
<core:table.rows>
|
||||||
|
@forelse($this->webhookLogs as $log)
|
||||||
|
<core:table.row :key="$log->id">
|
||||||
|
<core:table.cell>
|
||||||
|
<core:text class="text-zinc-500">#{{ $log->id }}</core:text>
|
||||||
|
</core:table.cell>
|
||||||
|
|
||||||
|
<core:table.cell variant="strong">
|
||||||
|
{{ $log->event_type }}
|
||||||
|
</core:table.cell>
|
||||||
|
|
||||||
|
<core:table.cell class="hidden md:table-cell">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<core:badge color="blue" size="sm">{{ $log->content_type }}</core:badge>
|
||||||
|
<core:text size="sm" class="text-zinc-500">#{{ $log->wp_id }}</core:text>
|
||||||
|
</div>
|
||||||
|
</core:table.cell>
|
||||||
|
|
||||||
|
<core:table.cell>
|
||||||
|
<x-content.webhook-badge :status="$log->status" />
|
||||||
|
</core:table.cell>
|
||||||
|
|
||||||
|
<core:table.cell class="hidden lg:table-cell">
|
||||||
|
<core:text size="sm" class="font-mono text-zinc-500">{{ $log->source_ip }}</core:text>
|
||||||
|
</core:table.cell>
|
||||||
|
|
||||||
|
<core:table.cell class="hidden lg:table-cell">
|
||||||
|
<core:text size="sm">{{ $log->created_at->diffForHumans() }}</core:text>
|
||||||
|
</core:table.cell>
|
||||||
|
|
||||||
|
<core:table.cell class="hidden xl:table-cell">
|
||||||
|
<core:text size="sm">{{ $log->processed_at?->diffForHumans() ?? '-' }}</core:text>
|
||||||
|
</core:table.cell>
|
||||||
|
|
||||||
|
<core:table.cell align="end">
|
||||||
|
<core:dropdown>
|
||||||
|
<core:button variant="ghost" size="sm" icon="ellipsis-horizontal" />
|
||||||
|
|
||||||
|
<core:menu>
|
||||||
|
@if($log->status === 'failed')
|
||||||
|
<core:menu.item wire:click="retryWebhook({{ $log->id }})" icon="arrow-path">
|
||||||
|
{{ __('hub::hub.content_manager.webhooks.actions.retry') }}
|
||||||
|
</core:menu.item>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<core:menu.item x-on:click="$dispatch('show-payload', { payload: {{ json_encode($log->payload) }} })" icon="code-bracket">
|
||||||
|
{{ __('hub::hub.content_manager.webhooks.actions.view_payload') }}
|
||||||
|
</core:menu.item>
|
||||||
|
|
||||||
|
@if($log->error_message)
|
||||||
|
<core:menu.separator />
|
||||||
|
<div class="px-3 py-2 text-xs text-red-600 dark:text-red-400">
|
||||||
|
<strong>{{ __('hub::hub.content_manager.webhooks.error') }}:</strong> {{ Str::limit($log->error_message, 80) }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</core:menu>
|
||||||
|
</core:dropdown>
|
||||||
|
</core:table.cell>
|
||||||
|
</core:table.row>
|
||||||
|
@empty
|
||||||
|
<core:table.row>
|
||||||
|
<core:table.cell colspan="8" class="text-center py-12">
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<core:icon name="bolt" class="size-12 text-zinc-300 dark:text-zinc-600 mb-3" />
|
||||||
|
<core:text>{{ __('hub::hub.content_manager.webhooks.no_logs') }}</core:text>
|
||||||
|
<core:text size="sm" class="text-zinc-500 mt-1">
|
||||||
|
{{ __('hub::hub.content_manager.webhooks.no_logs_description') }}
|
||||||
|
</core:text>
|
||||||
|
</div>
|
||||||
|
</core:table.cell>
|
||||||
|
</core:table.row>
|
||||||
|
@endforelse
|
||||||
|
</core:table.rows>
|
||||||
|
</core:table>
|
||||||
|
</core:card>
|
||||||
|
|
||||||
|
<!-- Webhook Endpoint Info -->
|
||||||
|
<core:card class="mt-6 p-6">
|
||||||
|
<core:heading size="sm" class="mb-2">{{ __('hub::hub.content_manager.webhooks.endpoint.title') }}</core:heading>
|
||||||
|
<div class="bg-zinc-50 dark:bg-zinc-800 px-4 py-3 rounded-lg font-mono text-sm text-violet-600 dark:text-violet-400 overflow-x-auto">
|
||||||
|
POST {{ url('/api/v1/webhook/content') }}
|
||||||
|
</div>
|
||||||
|
<core:text size="sm" class="text-zinc-500 mt-3">
|
||||||
|
{{ __('hub::hub.content_manager.webhooks.endpoint.description', ['header' => 'X-WP-Signature']) }}
|
||||||
|
</core:text>
|
||||||
|
</core:card>
|
||||||
|
|
||||||
|
<!-- Payload Modal -->
|
||||||
|
<div x-data="{ payload: null }"
|
||||||
|
x-on:show-payload.window="payload = $event.detail.payload; $dispatch('modal-show', { name: 'webhook-payload' })">
|
||||||
|
<core:modal name="webhook-payload" class="max-w-2xl">
|
||||||
|
<div class="mb-4">
|
||||||
|
<core:heading>{{ __('hub::hub.content_manager.webhooks.payload_modal.title') }}</core:heading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="font-mono text-xs bg-zinc-900 text-zinc-100 p-4 rounded-lg overflow-auto max-h-96">
|
||||||
|
<pre x-text="JSON.stringify(payload, null, 2)"></pre>
|
||||||
|
</div>
|
||||||
|
</core:modal>
|
||||||
|
</div>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
<div>
|
||||||
|
<core:heading size="xl" class="mb-2">Deployments & System Status</core:heading>
|
||||||
|
<core:subheading class="mb-6">Monitor system health and recent deployments</core:subheading>
|
||||||
|
|
||||||
|
{{-- Current Deployment Info --}}
|
||||||
|
<core:card class="p-6 mb-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-violet-500/10 flex items-center justify-center">
|
||||||
|
<core:icon name="rocket-launch" class="w-5 h-5 text-violet-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<core:heading size="lg">Current Deployment</core:heading>
|
||||||
|
<core:subheading>Branch: <code class="text-violet-600 dark:text-violet-400">{{ $this->gitInfo['branch'] }}</code></core:subheading>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<core:button wire:click="refresh" wire:loading.attr="disabled" variant="ghost" icon="arrow-path" size="sm">
|
||||||
|
Refresh
|
||||||
|
</core:button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div class="p-4 rounded-lg bg-zinc-50 dark:bg-zinc-800/50">
|
||||||
|
<core:text size="sm" class="text-zinc-500 mb-1">Commit</core:text>
|
||||||
|
<code class="text-sm font-mono text-zinc-800 dark:text-zinc-200">{{ $this->gitInfo['commit'] }}</code>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 rounded-lg bg-zinc-50 dark:bg-zinc-800/50">
|
||||||
|
<core:text size="sm" class="text-zinc-500 mb-1">Message</core:text>
|
||||||
|
<core:text class="font-medium truncate" title="{{ $this->gitInfo['message'] }}">{{ \Illuminate\Support\Str::limit($this->gitInfo['message'], 30) }}</core:text>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 rounded-lg bg-zinc-50 dark:bg-zinc-800/50">
|
||||||
|
<core:text size="sm" class="text-zinc-500 mb-1">Author</core:text>
|
||||||
|
<core:text class="font-medium">{{ $this->gitInfo['author'] }}</core:text>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 rounded-lg bg-zinc-50 dark:bg-zinc-800/50">
|
||||||
|
<core:text size="sm" class="text-zinc-500 mb-1">Deployed</core:text>
|
||||||
|
<core:text class="font-medium">{{ $this->gitInfo['date'] ?? 'Unknown' }}</core:text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</core:card>
|
||||||
|
|
||||||
|
{{-- Stats Grid --}}
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
@foreach($this->stats as $stat)
|
||||||
|
<core:card class="p-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-{{ $stat['color'] }}-500/10 flex items-center justify-center">
|
||||||
|
<core:icon name="{{ $stat['icon'] }}" class="w-5 h-5 text-{{ $stat['color'] }}-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<core:text size="sm" class="text-zinc-500">{{ $stat['label'] }}</core:text>
|
||||||
|
<core:text class="text-lg font-semibold">{{ $stat['value'] }}</core:text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</core:card>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{{-- Service Health --}}
|
||||||
|
<core:card class="p-6">
|
||||||
|
<core:heading size="lg" class="mb-4">Service Health</core:heading>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
@foreach($this->services as $service)
|
||||||
|
<div class="flex items-center justify-between p-3 rounded-lg bg-zinc-50 dark:bg-zinc-800/50">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<core:icon name="{{ $service['icon'] }}" class="w-5 h-5 text-zinc-500" />
|
||||||
|
<div>
|
||||||
|
<core:text class="font-medium">{{ $service['name'] }}</core:text>
|
||||||
|
@if(isset($service['details']))
|
||||||
|
<core:text size="sm" class="text-zinc-500">
|
||||||
|
@if(isset($service['details']['version']))
|
||||||
|
v{{ $service['details']['version'] }}
|
||||||
|
@endif
|
||||||
|
@if(isset($service['details']['memory']))
|
||||||
|
· {{ $service['details']['memory'] }}
|
||||||
|
@endif
|
||||||
|
@if(isset($service['details']['pending']))
|
||||||
|
· {{ $service['details']['pending'] }} pending
|
||||||
|
@endif
|
||||||
|
@if(isset($service['details']['used_percent']))
|
||||||
|
· {{ $service['details']['used_percent'] }} used
|
||||||
|
@endif
|
||||||
|
</core:text>
|
||||||
|
@endif
|
||||||
|
@if(isset($service['error']))
|
||||||
|
<core:text size="sm" class="text-red-500">{{ $service['error'] }}</core:text>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
@if($service['status'] === 'healthy')
|
||||||
|
<span class="w-2 h-2 rounded-full bg-green-500"></span>
|
||||||
|
<core:text size="sm" class="text-green-600 dark:text-green-400">Healthy</core:text>
|
||||||
|
@elseif($service['status'] === 'warning')
|
||||||
|
<span class="w-2 h-2 rounded-full bg-amber-500"></span>
|
||||||
|
<core:text size="sm" class="text-amber-600 dark:text-amber-400">Warning</core:text>
|
||||||
|
@elseif($service['status'] === 'unknown')
|
||||||
|
<span class="w-2 h-2 rounded-full bg-zinc-400"></span>
|
||||||
|
<core:text size="sm" class="text-zinc-500">Unknown</core:text>
|
||||||
|
@else
|
||||||
|
<span class="w-2 h-2 rounded-full bg-red-500"></span>
|
||||||
|
<core:text size="sm" class="text-red-600 dark:text-red-400">Unhealthy</core:text>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 pt-4 border-t border-zinc-200 dark:border-zinc-700">
|
||||||
|
<core:button wire:click="clearCache" variant="subtle" icon="trash" size="sm">
|
||||||
|
Clear Application Cache
|
||||||
|
</core:button>
|
||||||
|
</div>
|
||||||
|
</core:card>
|
||||||
|
|
||||||
|
{{-- Recent Commits --}}
|
||||||
|
<core:card class="p-6">
|
||||||
|
<core:heading size="lg" class="mb-4">Recent Commits</core:heading>
|
||||||
|
|
||||||
|
@if(count($this->recentCommits) > 0)
|
||||||
|
<div class="space-y-2">
|
||||||
|
@foreach($this->recentCommits as $commit)
|
||||||
|
<div class="flex items-start gap-3 p-3 rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800/50 transition-colors">
|
||||||
|
<code class="text-xs font-mono text-violet-600 dark:text-violet-400 bg-violet-500/10 px-2 py-1 rounded mt-0.5">{{ $commit['hash'] }}</code>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<core:text class="truncate" title="{{ $commit['message'] }}">{{ $commit['message'] }}</core:text>
|
||||||
|
<core:text size="sm" class="text-zinc-500">{{ $commit['author'] }} · {{ $commit['date'] }}</core:text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="flex flex-col items-center py-8 text-center">
|
||||||
|
<core:icon name="code-bracket" class="w-12 h-12 text-zinc-300 dark:text-zinc-600 mb-3" />
|
||||||
|
<core:text class="text-zinc-500">No commit history available</core:text>
|
||||||
|
<core:text size="sm" class="text-zinc-400">Git may not be available in this environment</core:text>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</core:card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Future Coolify Integration Notice --}}
|
||||||
|
<core:card class="p-6 mt-6 border-dashed">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center flex-shrink-0">
|
||||||
|
<core:icon name="rocket-launch" class="w-5 h-5 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<core:heading size="sm">Coming Soon: Deployment Management</core:heading>
|
||||||
|
<core:text size="sm" class="text-zinc-600 dark:text-zinc-400 mt-1">
|
||||||
|
Full deployment management with Coolify integration is planned. You'll be able to trigger deployments, view build logs, rollback to previous versions, and monitor deployment health.
|
||||||
|
</core:text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</core:card>
|
||||||
|
</div>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -0,0 +1,452 @@
|
||||||
|
<div>
|
||||||
|
{{-- Header --}}
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="w-16 h-16 rounded-xl bg-violet-500/20 flex items-center justify-center">
|
||||||
|
<core:icon name="key" class="text-2xl text-violet-600 dark:text-violet-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-100">Entitlements</h1>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 mt-1">Manage what workspaces can access and how much they can use</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-violet-500/20 text-violet-600 dark:text-violet-400">
|
||||||
|
<core:icon name="crown" class="mr-1.5" />
|
||||||
|
Hades Only
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Flash messages --}}
|
||||||
|
@if(session('success'))
|
||||||
|
<div class="mb-6 p-4 rounded-lg bg-green-500/20 text-green-700 dark:text-green-400">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<core:icon name="check-circle" class="mr-2" />
|
||||||
|
{{ session('success') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if(session('error'))
|
||||||
|
<div class="mb-6 p-4 rounded-lg bg-red-500/20 text-red-700 dark:text-red-400">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<core:icon name="circle-xmark" class="mr-2" />
|
||||||
|
{{ session('error') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Tabs --}}
|
||||||
|
<div class="border-b border-gray-200 dark:border-gray-700 mb-6">
|
||||||
|
<nav class="flex gap-6" aria-label="Tabs">
|
||||||
|
@foreach([
|
||||||
|
'overview' => ['label' => 'Overview', 'icon' => 'gauge'],
|
||||||
|
'packages' => ['label' => 'Packages', 'icon' => 'box'],
|
||||||
|
'features' => ['label' => 'Features', 'icon' => 'puzzle-piece'],
|
||||||
|
] as $tabKey => $info)
|
||||||
|
<button
|
||||||
|
wire:click="setTab('{{ $tabKey }}')"
|
||||||
|
class="flex items-center gap-2 py-3 px-1 border-b-2 text-sm font-medium transition {{ $tab === $tabKey ? 'border-violet-500 text-violet-600 dark:text-violet-400' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200' }}"
|
||||||
|
>
|
||||||
|
<core:icon name="{{ $info['icon'] }}" />
|
||||||
|
{{ $info['label'] }}
|
||||||
|
</button>
|
||||||
|
@endforeach
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Tab Content --}}
|
||||||
|
<div class="min-h-[500px]">
|
||||||
|
{{-- Overview Tab --}}
|
||||||
|
@if($tab === 'overview')
|
||||||
|
<div class="space-y-6">
|
||||||
|
{{-- Explanation --}}
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-4">How Entitlements Work</h3>
|
||||||
|
<div class="prose prose-sm dark:prose-invert max-w-none">
|
||||||
|
<p class="text-gray-600 dark:text-gray-400">
|
||||||
|
The entitlement system controls what workspaces can access and how much they can use. Think of it as a flexible permissions and quota system.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-3 gap-6 mt-6 not-prose">
|
||||||
|
{{-- Features --}}
|
||||||
|
<div class="p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
|
||||||
|
<div class="flex items-center gap-3 mb-3">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
|
||||||
|
<core:icon name="puzzle-piece" class="text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<h4 class="font-semibold text-gray-800 dark:text-gray-100">Features</h4>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||||
|
The atomic building blocks. Each feature is something you can check: "Can they do X?" or "How many X can they have?"
|
||||||
|
</p>
|
||||||
|
<div class="space-y-1 text-xs">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="inline-flex items-center px-1.5 py-0.5 rounded bg-gray-500/20 text-gray-600 dark:text-gray-400">boolean</span>
|
||||||
|
<span class="text-gray-500">On/off access (e.g., core.srv.bio)</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="inline-flex items-center px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-600 dark:text-blue-400">limit</span>
|
||||||
|
<span class="text-gray-500">Quota (e.g., bio.pages = 10)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Packages --}}
|
||||||
|
<div class="p-4 rounded-lg bg-purple-500/10 border border-purple-500/20">
|
||||||
|
<div class="flex items-center gap-3 mb-3">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
|
||||||
|
<core:icon name="box" class="text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<h4 class="font-semibold text-gray-800 dark:text-gray-100">Packages</h4>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||||
|
Bundles of features sold as products. A "Pro" package might include 50 bio pages, social access, and analytics.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-1 text-xs">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="inline-flex items-center px-1.5 py-0.5 rounded bg-purple-500/20 text-purple-600 dark:text-purple-400">base</span>
|
||||||
|
<span class="text-gray-500">One per workspace (plans)</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="inline-flex items-center px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-600 dark:text-blue-400">addon</span>
|
||||||
|
<span class="text-gray-500">Stackable extras</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Boosts --}}
|
||||||
|
<div class="p-4 rounded-lg bg-amber-500/10 border border-amber-500/20">
|
||||||
|
<div class="flex items-center gap-3 mb-3">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-amber-500/20 flex items-center justify-center">
|
||||||
|
<core:icon name="bolt" class="text-amber-600 dark:text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<h4 class="font-semibold text-gray-800 dark:text-gray-100">Boosts</h4>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||||
|
One-off grants for specific features. Admin can give a workspace +100 pages or enable a feature temporarily.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-1 text-xs">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="inline-flex items-center px-1.5 py-0.5 rounded bg-green-500/20 text-green-600 dark:text-green-400">permanent</span>
|
||||||
|
<span class="text-gray-500">Forever (or until revoked)</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="inline-flex items-center px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-600 dark:text-amber-400">expiring</span>
|
||||||
|
<span class="text-gray-500">Time-limited</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 p-4 rounded-lg bg-gray-100 dark:bg-gray-700/30">
|
||||||
|
<h5 class="font-medium text-gray-800 dark:text-gray-200 mb-2">The Flow</h5>
|
||||||
|
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 flex-wrap">
|
||||||
|
<span class="px-2 py-1 rounded bg-blue-500/20 text-blue-700 dark:text-blue-300">Features</span>
|
||||||
|
<core:icon name="arrow-right" class="text-gray-400" />
|
||||||
|
<span class="text-gray-500">bundled into</span>
|
||||||
|
<core:icon name="arrow-right" class="text-gray-400" />
|
||||||
|
<span class="px-2 py-1 rounded bg-purple-500/20 text-purple-700 dark:text-purple-300">Packages</span>
|
||||||
|
<core:icon name="arrow-right" class="text-gray-400" />
|
||||||
|
<span class="text-gray-500">assigned to</span>
|
||||||
|
<core:icon name="arrow-right" class="text-gray-400" />
|
||||||
|
<span class="px-2 py-1 rounded bg-green-500/20 text-green-700 dark:text-green-300">Workspaces</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mt-2">
|
||||||
|
Boosts bypass packages to grant features directly to workspaces (for support, promotions, etc.)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Stats Grid --}}
|
||||||
|
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs p-5">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
|
||||||
|
<core:icon name="box" class="text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-2xl font-bold text-gray-800 dark:text-gray-100">{{ $this->stats['packages']['total'] }}</div>
|
||||||
|
<div class="text-xs text-gray-500">Packages</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex items-center gap-3 text-xs">
|
||||||
|
<span class="text-green-600">{{ $this->stats['packages']['active'] }} active</span>
|
||||||
|
<span class="text-gray-400">{{ $this->stats['packages']['public'] }} public</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs p-5">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
|
||||||
|
<core:icon name="puzzle-piece" class="text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-2xl font-bold text-gray-800 dark:text-gray-100">{{ $this->stats['features']['total'] }}</div>
|
||||||
|
<div class="text-xs text-gray-500">Features</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex items-center gap-3 text-xs">
|
||||||
|
<span class="text-gray-500">{{ $this->stats['features']['boolean'] }} boolean</span>
|
||||||
|
<span class="text-blue-500">{{ $this->stats['features']['limit'] }} limits</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs p-5">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-green-500/20 flex items-center justify-center">
|
||||||
|
<core:icon name="folder" class="text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-2xl font-bold text-gray-800 dark:text-gray-100">{{ $this->stats['assignments']['workspace_packages'] }}</div>
|
||||||
|
<div class="text-xs text-gray-500">Active Assignments</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 text-xs text-gray-500">
|
||||||
|
Workspaces with packages
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs p-5">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-amber-500/20 flex items-center justify-center">
|
||||||
|
<core:icon name="bolt" class="text-amber-600 dark:text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-2xl font-bold text-gray-800 dark:text-gray-100">{{ $this->stats['assignments']['active_boosts'] }}</div>
|
||||||
|
<div class="text-xs text-gray-500">Active Boosts</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 text-xs text-gray-500">
|
||||||
|
Direct feature grants
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Categories --}}
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs p-5">
|
||||||
|
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-4">Feature Categories</h3>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
@forelse($this->stats['categories'] as $category)
|
||||||
|
<span class="inline-flex items-center px-3 py-1 rounded-lg text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300">
|
||||||
|
{{ $category }}
|
||||||
|
</span>
|
||||||
|
@empty
|
||||||
|
<span class="text-sm text-gray-400">No categories defined</span>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Packages Tab --}}
|
||||||
|
@if($tab === 'packages')
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Packages</h2>
|
||||||
|
<p class="text-sm text-gray-500">Bundles of features assigned to workspaces</p>
|
||||||
|
</div>
|
||||||
|
<flux:button wire:click="openCreatePackage" icon="plus">
|
||||||
|
New Package
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs overflow-hidden">
|
||||||
|
<admin:manager-table
|
||||||
|
:columns="['Package', 'Code', 'Features', ['label' => 'Type', 'align' => 'center'], ['label' => 'Status', 'align' => 'center'], ['label' => 'Actions', 'align' => 'center']]"
|
||||||
|
:rows="$this->packageTableRows"
|
||||||
|
:pagination="$this->packages"
|
||||||
|
empty="No packages found. Create your first package to get started."
|
||||||
|
emptyIcon="box"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Features Tab --}}
|
||||||
|
@if($tab === 'features')
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Features</h2>
|
||||||
|
<p class="text-sm text-gray-500">Individual capabilities that can be checked and tracked</p>
|
||||||
|
</div>
|
||||||
|
<flux:button wire:click="openCreateFeature" icon="plus">
|
||||||
|
New Feature
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xs overflow-hidden">
|
||||||
|
<admin:manager-table
|
||||||
|
:columns="['Feature', 'Code', 'Category', ['label' => 'Type', 'align' => 'center'], ['label' => 'Reset', 'align' => 'center'], ['label' => 'Status', 'align' => 'center'], ['label' => 'Actions', 'align' => 'center']]"
|
||||||
|
:rows="$this->featureTableRows"
|
||||||
|
:pagination="$this->features"
|
||||||
|
empty="No features found. Create your first feature to get started."
|
||||||
|
emptyIcon="puzzle-piece"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Package Modal --}}
|
||||||
|
<flux:modal wire:model="showPackageModal" class="max-w-xl">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-purple-500/20 flex items-center justify-center">
|
||||||
|
<core:icon name="box" class="text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<flux:heading size="lg">{{ $editingPackageId ? 'Edit Package' : 'Create Package' }}</flux:heading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form wire:submit="savePackage" class="space-y-4">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<flux:input wire:model="packageCode" label="Code" placeholder="pro" required />
|
||||||
|
<flux:input wire:model="packageName" label="Name" placeholder="Pro Plan" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<flux:textarea wire:model="packageDescription" label="Description" rows="2" />
|
||||||
|
|
||||||
|
<div class="grid grid-cols-3 gap-4">
|
||||||
|
<flux:input wire:model="packageIcon" label="Icon" placeholder="box" />
|
||||||
|
<flux:input wire:model="packageColor" label="Colour" placeholder="blue" />
|
||||||
|
<flux:input wire:model="packageSortOrder" label="Sort Order" type="number" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<flux:checkbox wire:model="packageIsBasePackage" label="Base Package" description="Only one per workspace" />
|
||||||
|
<flux:checkbox wire:model="packageIsStackable" label="Stackable" description="Can combine with others" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<flux:checkbox wire:model="packageIsActive" label="Active" />
|
||||||
|
<flux:checkbox wire:model="packageIsPublic" label="Public" description="Show on pricing" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 pt-4">
|
||||||
|
<flux:button wire:click="closePackageModal" variant="ghost">Cancel</flux:button>
|
||||||
|
<flux:button type="submit" variant="primary">
|
||||||
|
{{ $editingPackageId ? 'Update' : 'Create' }}
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</flux:modal>
|
||||||
|
|
||||||
|
{{-- Feature Modal --}}
|
||||||
|
<flux:modal wire:model="showFeatureModal" class="max-w-xl">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center">
|
||||||
|
<core:icon name="puzzle-piece" class="text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<flux:heading size="lg">{{ $editingFeatureId ? 'Edit Feature' : 'Create Feature' }}</flux:heading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form wire:submit="saveFeature" class="space-y-4">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<flux:input wire:model="featureCode" label="Code" placeholder="bio.pages" required />
|
||||||
|
<flux:input wire:model="featureName" label="Name" placeholder="Bio Pages" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<flux:textarea wire:model="featureDescription" label="Description" rows="2" />
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<flux:input wire:model="featureCategory" label="Category" placeholder="biolink" />
|
||||||
|
<flux:input wire:model="featureSortOrder" label="Sort Order" type="number" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<flux:select wire:model.live="featureType" label="Type">
|
||||||
|
<flux:select.option value="boolean">Boolean (on/off)</flux:select.option>
|
||||||
|
<flux:select.option value="limit">Limit (quota)</flux:select.option>
|
||||||
|
<flux:select.option value="unlimited">Unlimited</flux:select.option>
|
||||||
|
</flux:select>
|
||||||
|
|
||||||
|
<flux:select wire:model.live="featureResetType" label="Reset">
|
||||||
|
<flux:select.option value="none">Never</flux:select.option>
|
||||||
|
<flux:select.option value="monthly">Monthly</flux:select.option>
|
||||||
|
<flux:select.option value="rolling">Rolling Window</flux:select.option>
|
||||||
|
</flux:select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($featureResetType === 'rolling')
|
||||||
|
<flux:input wire:model="featureRollingDays" type="number" label="Rolling Window (days)" placeholder="30" />
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($featureType === 'limit')
|
||||||
|
<flux:select wire:model="featureParentId" label="Parent Pool (optional)">
|
||||||
|
<flux:select.option value="">None</flux:select.option>
|
||||||
|
@foreach($this->parentFeatures as $parent)
|
||||||
|
<flux:select.option value="{{ $parent->id }}">{{ $parent->name }} ({{ $parent->code }})</flux:select.option>
|
||||||
|
@endforeach
|
||||||
|
</flux:select>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<flux:checkbox wire:model="featureIsActive" label="Active" />
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 pt-4">
|
||||||
|
<flux:button wire:click="closeFeatureModal" variant="ghost">Cancel</flux:button>
|
||||||
|
<flux:button type="submit" variant="primary">
|
||||||
|
{{ $editingFeatureId ? 'Update' : 'Create' }}
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</flux:modal>
|
||||||
|
|
||||||
|
{{-- Features Assignment Modal --}}
|
||||||
|
<flux:modal wire:model="showFeaturesModal" class="max-w-2xl">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-green-500/20 flex items-center justify-center">
|
||||||
|
<core:icon name="puzzle-piece" class="text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<flux:heading size="lg">Assign Features to Package</flux:heading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form wire:submit="saveFeatures" class="space-y-6">
|
||||||
|
@foreach($this->allFeatures as $category => $categoryFeatures)
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2 capitalize">{{ $category ?: 'General' }}</h4>
|
||||||
|
<div class="space-y-2">
|
||||||
|
@foreach($categoryFeatures as $feature)
|
||||||
|
<div class="flex items-center gap-4 p-3 rounded-lg border border-gray-200 dark:border-gray-700 {{ isset($selectedFeatures[$feature->id]['enabled']) && $selectedFeatures[$feature->id]['enabled'] ? 'bg-green-500/5 border-green-500/30' : '' }}">
|
||||||
|
<flux:checkbox
|
||||||
|
wire:click="toggleFeature({{ $feature->id }})"
|
||||||
|
:checked="isset($selectedFeatures[$feature->id]['enabled']) && $selectedFeatures[$feature->id]['enabled']"
|
||||||
|
/>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="font-medium text-gray-800 dark:text-gray-100">{{ $feature->name }}</div>
|
||||||
|
<code class="text-xs text-gray-500">{{ $feature->code }}</code>
|
||||||
|
</div>
|
||||||
|
@if($feature->type === 'limit')
|
||||||
|
<flux:input
|
||||||
|
type="number"
|
||||||
|
wire:model="selectedFeatures.{{ $feature->id }}.limit"
|
||||||
|
placeholder="Limit"
|
||||||
|
class="w-24"
|
||||||
|
:disabled="!isset($selectedFeatures[$feature->id]['enabled']) || !$selectedFeatures[$feature->id]['enabled']"
|
||||||
|
/>
|
||||||
|
@elseif($feature->type === 'unlimited')
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-500/20 text-purple-600">Unlimited</span>
|
||||||
|
@else
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-500/20 text-gray-600">Boolean</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 pt-4">
|
||||||
|
<flux:button wire:click="$set('showFeaturesModal', false)" variant="ghost">Cancel</flux:button>
|
||||||
|
<flux:button type="submit" variant="primary">Save Features</flux:button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</flux:modal>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
<admin:module title="Features" subtitle="Manage entitlement features">
|
||||||
|
<x-slot:actions>
|
||||||
|
<core:button wire:click="openCreate" icon="plus">New Feature</core:button>
|
||||||
|
</x-slot:actions>
|
||||||
|
|
||||||
|
<admin:flash />
|
||||||
|
|
||||||
|
<admin:manager-table
|
||||||
|
:columns="$this->tableColumns"
|
||||||
|
:rows="$this->tableRows"
|
||||||
|
:pagination="$this->features"
|
||||||
|
empty="No features found. Create your first feature to get started."
|
||||||
|
emptyIcon="puzzle-piece"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{-- Create/Edit Feature Modal --}}
|
||||||
|
<core:modal wire:model="showModal" class="max-w-xl">
|
||||||
|
<core:heading size="lg">
|
||||||
|
{{ $editingId ? 'Edit Feature' : 'Create Feature' }}
|
||||||
|
</core:heading>
|
||||||
|
|
||||||
|
<form wire:submit="save" class="mt-4 space-y-4">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<core:input wire:model="code" label="Code" placeholder="social.posts.scheduled" required />
|
||||||
|
<core:input wire:model="name" label="Name" placeholder="Scheduled Posts" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<core:textarea wire:model="description" label="Description" rows="2" />
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<core:select wire:model="category" label="Category">
|
||||||
|
<option value="">Select category...</option>
|
||||||
|
@foreach ($this->categories as $cat)
|
||||||
|
<option value="{{ $cat }}">{{ ucfirst($cat) }}</option>
|
||||||
|
@endforeach
|
||||||
|
<option value="__new">+ New category</option>
|
||||||
|
</core:select>
|
||||||
|
|
||||||
|
<core:input wire:model="sort_order" label="Sort Order" type="number" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<core:select wire:model="type" label="Type">
|
||||||
|
<option value="boolean">Boolean (on/off)</option>
|
||||||
|
<option value="limit">Limit (numeric)</option>
|
||||||
|
<option value="unlimited">Unlimited</option>
|
||||||
|
</core:select>
|
||||||
|
|
||||||
|
<core:select wire:model="reset_type" label="Reset Type">
|
||||||
|
<option value="none">Never resets</option>
|
||||||
|
<option value="monthly">Monthly (billing cycle)</option>
|
||||||
|
<option value="rolling">Rolling window</option>
|
||||||
|
</core:select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($reset_type === 'rolling')
|
||||||
|
<core:input wire:model="rolling_window_days" label="Rolling Window (days)" type="number" placeholder="30" />
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<core:select wire:model="parent_feature_id" label="Parent Feature (for global pools)">
|
||||||
|
<option value="">No parent (standalone)</option>
|
||||||
|
@foreach ($this->parentFeatures as $parent)
|
||||||
|
<option value="{{ $parent->id }}">{{ $parent->name }} ({{ $parent->code }})</option>
|
||||||
|
@endforeach
|
||||||
|
</core:select>
|
||||||
|
|
||||||
|
<core:checkbox wire:model="is_active" label="Active" />
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2 pt-4">
|
||||||
|
<core:button variant="ghost" wire:click="closeModal">Cancel</core:button>
|
||||||
|
<core:button type="submit" variant="primary">
|
||||||
|
{{ $editingId ? 'Update' : 'Create' }}
|
||||||
|
</core:button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</core:modal>
|
||||||
|
</admin:module>
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
<admin:module title="Packages" subtitle="Manage entitlement packages">
|
||||||
|
<x-slot:actions>
|
||||||
|
<core:button wire:click="openCreate" icon="plus">New Package</core:button>
|
||||||
|
</x-slot:actions>
|
||||||
|
|
||||||
|
<admin:flash />
|
||||||
|
|
||||||
|
<admin:manager-table
|
||||||
|
:columns="$this->tableColumns"
|
||||||
|
:rows="$this->tableRows"
|
||||||
|
:pagination="$this->packages"
|
||||||
|
empty="No packages found. Create your first package to get started."
|
||||||
|
emptyIcon="cube"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{-- Create/Edit Package Modal --}}
|
||||||
|
<core:modal wire:model="showModal" class="max-w-xl">
|
||||||
|
<core:heading size="lg">
|
||||||
|
{{ $editingId ? 'Edit Package' : 'Create Package' }}
|
||||||
|
</core:heading>
|
||||||
|
|
||||||
|
<form wire:submit="save" class="mt-4 space-y-4">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<core:input wire:model="code" label="Code" placeholder="creator" required />
|
||||||
|
<core:input wire:model="name" label="Name" placeholder="Creator Plan" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<core:textarea wire:model="description" label="Description" rows="2" />
|
||||||
|
|
||||||
|
<div class="grid grid-cols-3 gap-4">
|
||||||
|
<core:input wire:model="icon" label="Icon" placeholder="user" />
|
||||||
|
<core:input wire:model="color" label="Colour" placeholder="blue" />
|
||||||
|
<core:input wire:model="sort_order" label="Sort Order" type="number" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<core:input wire:model="blesta_package_id" label="Blesta Package ID" placeholder="pkg_123" />
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<core:checkbox wire:model="is_base_package" label="Base Package" description="Only one base package per workspace" />
|
||||||
|
<core:checkbox wire:model="is_stackable" label="Stackable" description="Can be combined with other packages" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<core:checkbox wire:model="is_active" label="Active" />
|
||||||
|
<core:checkbox wire:model="is_public" label="Public" description="Show on pricing page" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2 pt-4">
|
||||||
|
<core:button variant="ghost" wire:click="closeModal">Cancel</core:button>
|
||||||
|
<core:button type="submit" variant="primary">
|
||||||
|
{{ $editingId ? 'Update' : 'Create' }}
|
||||||
|
</core:button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</core:modal>
|
||||||
|
|
||||||
|
{{-- Features Assignment Modal --}}
|
||||||
|
<core:modal wire:model="showFeaturesModal" class="max-w-2xl">
|
||||||
|
<core:heading size="lg">Assign Features</core:heading>
|
||||||
|
|
||||||
|
<form wire:submit="saveFeatures" class="mt-4 space-y-6">
|
||||||
|
@foreach ($this->features as $category => $categoryFeatures)
|
||||||
|
<div>
|
||||||
|
<core:heading size="sm" class="mb-2 capitalize">{{ $category }}</core:heading>
|
||||||
|
<div class="space-y-2">
|
||||||
|
@foreach ($categoryFeatures as $feature)
|
||||||
|
<div class="flex items-center gap-4 p-2 rounded border border-gray-200 dark:border-gray-700">
|
||||||
|
<core:checkbox
|
||||||
|
wire:click="toggleFeature({{ $feature->id }})"
|
||||||
|
:checked="isset($selectedFeatures[$feature->id]['enabled']) && $selectedFeatures[$feature->id]['enabled']"
|
||||||
|
/>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="font-medium">{{ $feature->name }}</div>
|
||||||
|
<code class="text-xs text-gray-500">{{ $feature->code }}</code>
|
||||||
|
</div>
|
||||||
|
@if ($feature->type === 'limit')
|
||||||
|
<core:input
|
||||||
|
type="number"
|
||||||
|
wire:model="selectedFeatures.{{ $feature->id }}.limit"
|
||||||
|
placeholder="Limit"
|
||||||
|
class="w-24"
|
||||||
|
:disabled="!isset($selectedFeatures[$feature->id]['enabled']) || !$selectedFeatures[$feature->id]['enabled']"
|
||||||
|
/>
|
||||||
|
@elseif ($feature->type === 'unlimited')
|
||||||
|
<core:badge color="purple">Unlimited</core:badge>
|
||||||
|
@else
|
||||||
|
<core:badge color="gray">Boolean</core:badge>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2 pt-4">
|
||||||
|
<core:button variant="ghost" wire:click="closeModal">Cancel</core:button>
|
||||||
|
<core:button type="submit" variant="primary">Save Features</core:button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</core:modal>
|
||||||
|
</admin:module>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
<admin:module title="Waitlist" subtitle="Manage signups and invitations">
|
||||||
|
<x-slot:actions>
|
||||||
|
<core:button wire:click="export" icon="arrow-down-tray" variant="ghost">Export CSV</core:button>
|
||||||
|
@if (count($selected) > 0)
|
||||||
|
<core:button wire:click="sendBulkInvites" icon="paper-airplane" variant="primary">
|
||||||
|
Invite Selected ({{ count($selected) }})
|
||||||
|
</core:button>
|
||||||
|
@endif
|
||||||
|
</x-slot:actions>
|
||||||
|
|
||||||
|
<admin:flash />
|
||||||
|
|
||||||
|
{{-- Stats Cards --}}
|
||||||
|
<admin:stats cols="4" class="mb-6">
|
||||||
|
<admin:stat-card label="Total signups" :value="number_format($totalCount)" />
|
||||||
|
<admin:stat-card label="Pending invite" :value="number_format($pendingCount)" color="amber" />
|
||||||
|
<admin:stat-card label="Invited" :value="number_format($invitedCount)" color="blue" />
|
||||||
|
<admin:stat-card label="Converted" :value="number_format($convertedCount)" color="green" />
|
||||||
|
</admin:stats>
|
||||||
|
|
||||||
|
<admin:filter-bar cols="4">
|
||||||
|
<admin:search model="search" placeholder="Search emails or names..." />
|
||||||
|
<admin:filter model="statusFilter" :options="$this->statusOptions" placeholder="All entries" />
|
||||||
|
<admin:filter model="interestFilter" :options="$this->interests" placeholder="All interests" />
|
||||||
|
<div class="flex items-center">
|
||||||
|
<label class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<input type="checkbox" wire:model.live="selectAll" class="rounded">
|
||||||
|
Select all
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</admin:filter-bar>
|
||||||
|
|
||||||
|
<admin:manager-table
|
||||||
|
:columns="$this->tableColumns"
|
||||||
|
:rows="$this->tableRows"
|
||||||
|
:pagination="$this->entries"
|
||||||
|
empty="No waitlist entries found."
|
||||||
|
emptyIcon="users"
|
||||||
|
/>
|
||||||
|
</admin:module>
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
<div class="relative" x-data="{ open: @entangle('open') }">
|
||||||
|
<!-- Trigger Button -->
|
||||||
|
<button
|
||||||
|
@click="open = !open"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700/50 hover:bg-gray-200 dark:hover:bg-gray-700 transition"
|
||||||
|
>
|
||||||
|
<div class="w-6 h-6 rounded-md bg-{{ $current['color'] }}-500/20 flex items-center justify-center">
|
||||||
|
<core:icon :name="$current['icon']" class="text-{{ $current['color'] }}-500 text-xs" />
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">{{ $current['name'] }}</span>
|
||||||
|
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="open ? 'rotate-180' : ''" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Dropdown -->
|
||||||
|
<div
|
||||||
|
x-show="open"
|
||||||
|
x-transition:enter="transition ease-out duration-100"
|
||||||
|
x-transition:enter-start="opacity-0 scale-95"
|
||||||
|
x-transition:enter-end="opacity-100 scale-100"
|
||||||
|
x-transition:leave="transition ease-in duration-75"
|
||||||
|
x-transition:leave-start="opacity-100 scale-100"
|
||||||
|
x-transition:leave-end="opacity-0 scale-95"
|
||||||
|
@click.outside="open = false"
|
||||||
|
class="absolute left-0 mt-2 w-64 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden z-50"
|
||||||
|
x-cloak
|
||||||
|
>
|
||||||
|
<div class="px-4 py-3 border-b border-gray-100 dark:border-gray-700">
|
||||||
|
<p class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">{{ __('hub::hub.workspace_switcher.title') }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="py-2">
|
||||||
|
@foreach($workspaces as $slug => $workspace)
|
||||||
|
<button
|
||||||
|
wire:click="switchWorkspace('{{ $slug }}')"
|
||||||
|
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition {{ $current['slug'] === $slug ? 'bg-gray-50 dark:bg-gray-700/30' : '' }}"
|
||||||
|
>
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-{{ $workspace['color'] }}-500/20 flex items-center justify-center shrink-0">
|
||||||
|
<core:icon :name="$workspace['icon']" class="text-{{ $workspace['color'] }}-500" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 text-left">
|
||||||
|
<div class="text-sm font-medium text-gray-800 dark:text-gray-100">{{ $workspace['name'] }}</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">{{ $workspace['description'] }}</div>
|
||||||
|
</div>
|
||||||
|
@if($current['slug'] === $slug)
|
||||||
|
<core:icon name="check" class="text-{{ $workspace['color'] }}-500" />
|
||||||
|
@endif
|
||||||
|
</button>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
<div class="px-4 py-3 border-t border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/20">
|
||||||
|
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<core:icon name="globe" />
|
||||||
|
<span class="truncate">{{ $current['domain'] }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Website\Hub\View\Modal\Admin;
|
||||||
|
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class Console extends Component
|
||||||
|
{
|
||||||
|
public array $servers = [];
|
||||||
|
|
||||||
|
public ?int $selectedServer = null;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->servers = [
|
||||||
|
[
|
||||||
|
'id' => 1,
|
||||||
|
'name' => 'Bio (Production)',
|
||||||
|
'type' => 'WordPress',
|
||||||
|
'status' => 'online',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 2,
|
||||||
|
'name' => 'Social (Production)',
|
||||||
|
'type' => 'Laravel',
|
||||||
|
'status' => 'online',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 3,
|
||||||
|
'name' => 'Analytics (Production)',
|
||||||
|
'type' => 'Node.js',
|
||||||
|
'status' => 'online',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 4,
|
||||||
|
'name' => 'Host Hub (Development)',
|
||||||
|
'type' => 'Laravel',
|
||||||
|
'status' => 'online',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function selectServer(int $serverId): void
|
||||||
|
{
|
||||||
|
$this->selectedServer = $serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('hub::admin.console')
|
||||||
|
->layout('hub::admin.layouts.app', ['title' => 'Console']);
|
||||||
|
}
|
||||||
|
}
|
||||||
295
packages/core-admin/src/Website/Hub/View/Modal/Admin/Content.php
Normal file
295
packages/core-admin/src/Website/Hub/View/Modal/Admin/Content.php
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Website\Hub\View\Modal\Admin;
|
||||||
|
|
||||||
|
use Core\Mod\Tenant\Services\WorkspaceService;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
|
use Livewire\Attributes\Url;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\WithPagination;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content management component.
|
||||||
|
*
|
||||||
|
* Native content system - no longer uses WordPress.
|
||||||
|
*/
|
||||||
|
class Content extends Component
|
||||||
|
{
|
||||||
|
use WithPagination;
|
||||||
|
|
||||||
|
public string $tab = 'posts';
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public string $search = '';
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public string $status = '';
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public string $sort = 'date';
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public string $dir = 'desc';
|
||||||
|
|
||||||
|
public string $view = 'list';
|
||||||
|
|
||||||
|
public ?int $editingId = null;
|
||||||
|
|
||||||
|
public string $editTitle = '';
|
||||||
|
|
||||||
|
public string $editContent = '';
|
||||||
|
|
||||||
|
public string $editStatus = 'draft';
|
||||||
|
|
||||||
|
public string $editExcerpt = '';
|
||||||
|
|
||||||
|
public bool $showEditor = false;
|
||||||
|
|
||||||
|
public bool $isCreating = false;
|
||||||
|
|
||||||
|
public array $items = [];
|
||||||
|
|
||||||
|
public int $total = 0;
|
||||||
|
|
||||||
|
public int $perPage = 15;
|
||||||
|
|
||||||
|
public array $currentWorkspace = [];
|
||||||
|
|
||||||
|
protected WorkspaceService $workspaceService;
|
||||||
|
|
||||||
|
public function boot(WorkspaceService $workspaceService): void
|
||||||
|
{
|
||||||
|
$this->workspaceService = $workspaceService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(string $workspace = 'main', string $type = 'posts'): void
|
||||||
|
{
|
||||||
|
$this->tab = $type;
|
||||||
|
|
||||||
|
// Set workspace from URL
|
||||||
|
$this->workspaceService->setCurrent($workspace);
|
||||||
|
$this->currentWorkspace = $this->workspaceService->current();
|
||||||
|
|
||||||
|
$this->loadContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[On('workspace-changed')]
|
||||||
|
public function handleWorkspaceChange(string $workspace): void
|
||||||
|
{
|
||||||
|
$this->currentWorkspace = $this->workspaceService->current();
|
||||||
|
$this->resetPage();
|
||||||
|
$this->loadContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function stats(): array
|
||||||
|
{
|
||||||
|
$published = collect($this->items)->where('status', 'publish')->count();
|
||||||
|
$drafts = collect($this->items)->where('status', 'draft')->count();
|
||||||
|
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'title' => 'Total '.ucfirst($this->tab),
|
||||||
|
'value' => (string) $this->total,
|
||||||
|
'trend' => '+12%',
|
||||||
|
'trendUp' => true,
|
||||||
|
'icon' => $this->tab === 'posts' ? 'newspaper' : ($this->tab === 'pages' ? 'file-lines' : 'images'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => 'Published',
|
||||||
|
'value' => (string) $published,
|
||||||
|
'trend' => '+8%',
|
||||||
|
'trendUp' => true,
|
||||||
|
'icon' => 'check-circle',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => 'Drafts',
|
||||||
|
'value' => (string) $drafts,
|
||||||
|
'trend' => '-3%',
|
||||||
|
'trendUp' => false,
|
||||||
|
'icon' => 'pencil',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => 'This Week',
|
||||||
|
'value' => (string) collect($this->items)->filter(fn ($i) => \Carbon\Carbon::parse($i['date'] ?? $i['modified'] ?? now())->isCurrentWeek())->count(),
|
||||||
|
'trend' => '+24%',
|
||||||
|
'trendUp' => true,
|
||||||
|
'icon' => 'calendar',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function paginator(): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$page = $this->getPage();
|
||||||
|
|
||||||
|
return new LengthAwarePaginator(
|
||||||
|
items: array_slice($this->items, ($page - 1) * $this->perPage, $this->perPage),
|
||||||
|
total: $this->total,
|
||||||
|
perPage: $this->perPage,
|
||||||
|
currentPage: $page,
|
||||||
|
options: ['path' => request()->url()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function rows(): array
|
||||||
|
{
|
||||||
|
return $this->paginator()->items();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadContent(): void
|
||||||
|
{
|
||||||
|
// Load demo data - native content system to be implemented
|
||||||
|
$this->loadDemoData();
|
||||||
|
|
||||||
|
// Apply sorting
|
||||||
|
$this->applySorting();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function applySorting(): void
|
||||||
|
{
|
||||||
|
$items = collect($this->items);
|
||||||
|
|
||||||
|
$items = match ($this->sort) {
|
||||||
|
'title' => $items->sortBy(fn ($i) => $i['title']['rendered'] ?? '', SORT_REGULAR, $this->dir === 'desc'),
|
||||||
|
'status' => $items->sortBy('status', SORT_REGULAR, $this->dir === 'desc'),
|
||||||
|
'modified' => $items->sortBy('modified', SORT_REGULAR, $this->dir === 'desc'),
|
||||||
|
default => $items->sortBy('date', SORT_REGULAR, $this->dir === 'desc'),
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->items = $items->values()->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function loadDemoData(): void
|
||||||
|
{
|
||||||
|
$workspaceName = $this->currentWorkspace['name'] ?? 'Host UK';
|
||||||
|
$workspaceSlug = $this->currentWorkspace['slug'] ?? 'main';
|
||||||
|
|
||||||
|
if ($this->tab === 'posts') {
|
||||||
|
$this->items = [];
|
||||||
|
for ($i = 1; $i <= 25; $i++) {
|
||||||
|
$this->items[] = [
|
||||||
|
'id' => $i,
|
||||||
|
'title' => ['rendered' => "{$workspaceName} Post #{$i}"],
|
||||||
|
'content' => ['rendered' => "<p>Content for post {$i} in {$workspaceName}.</p>"],
|
||||||
|
'status' => $i % 3 === 0 ? 'draft' : 'publish',
|
||||||
|
'date' => now()->subDays($i)->toIso8601String(),
|
||||||
|
'modified' => now()->subDays($i - 1)->toIso8601String(),
|
||||||
|
'excerpt' => ['rendered' => "Excerpt for post {$i}"],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$this->total = 25;
|
||||||
|
} elseif ($this->tab === 'pages') {
|
||||||
|
$pageNames = ['Home', 'About', 'Services', 'Contact', 'Privacy', 'Terms', 'FAQ', 'Blog', 'Portfolio', 'Team'];
|
||||||
|
$this->items = [];
|
||||||
|
foreach ($pageNames as $i => $name) {
|
||||||
|
$this->items[] = [
|
||||||
|
'id' => $i + 10,
|
||||||
|
'title' => ['rendered' => $name],
|
||||||
|
'content' => ['rendered' => "<p>{$workspaceName} {$name} page content.</p>"],
|
||||||
|
'status' => 'publish',
|
||||||
|
'date' => now()->subMonths($i)->toIso8601String(),
|
||||||
|
'modified' => now()->subDays($i)->toIso8601String(),
|
||||||
|
'excerpt' => ['rendered' => ''],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$this->total = count($pageNames);
|
||||||
|
} else {
|
||||||
|
$this->items = [];
|
||||||
|
for ($i = 1; $i <= 12; $i++) {
|
||||||
|
$this->items[] = [
|
||||||
|
'id' => 100 + $i,
|
||||||
|
'title' => ['rendered' => "{$workspaceSlug}-image-{$i}.jpg"],
|
||||||
|
'media_type' => 'image',
|
||||||
|
'source_url' => '/images/placeholder.jpg',
|
||||||
|
'date' => now()->subDays($i)->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$this->total = 12;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setSort(string $sort): void
|
||||||
|
{
|
||||||
|
if ($this->sort === $sort) {
|
||||||
|
$this->dir = $this->dir === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
$this->sort = $sort;
|
||||||
|
$this->dir = 'desc';
|
||||||
|
}
|
||||||
|
$this->loadContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStatus(string $status): void
|
||||||
|
{
|
||||||
|
$this->status = $status;
|
||||||
|
$this->resetPage();
|
||||||
|
$this->loadContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setView(string $view): void
|
||||||
|
{
|
||||||
|
$this->view = $view;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createNew(): void
|
||||||
|
{
|
||||||
|
$this->isCreating = true;
|
||||||
|
$this->editingId = null;
|
||||||
|
$this->editTitle = '';
|
||||||
|
$this->editContent = '';
|
||||||
|
$this->editStatus = 'draft';
|
||||||
|
$this->editExcerpt = '';
|
||||||
|
$this->showEditor = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(int $id): void
|
||||||
|
{
|
||||||
|
$this->isCreating = false;
|
||||||
|
$this->editingId = $id;
|
||||||
|
|
||||||
|
$item = collect($this->items)->firstWhere('id', $id);
|
||||||
|
if ($item) {
|
||||||
|
$this->editTitle = $item['title']['rendered'] ?? '';
|
||||||
|
$this->editContent = $item['content']['rendered'] ?? '';
|
||||||
|
$this->editStatus = $item['status'] ?? 'draft';
|
||||||
|
$this->editExcerpt = $item['excerpt']['rendered'] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->showEditor = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
// Native content save - to be implemented
|
||||||
|
// For now, just close editor and dispatch event
|
||||||
|
|
||||||
|
$this->closeEditor();
|
||||||
|
$this->dispatch('content-saved');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(int $id): void
|
||||||
|
{
|
||||||
|
// Native content delete - to be implemented
|
||||||
|
// For demo, just remove from items
|
||||||
|
$this->items = array_values(array_filter($this->items, fn ($p) => $p['id'] !== $id));
|
||||||
|
$this->total = count($this->items);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeEditor(): void
|
||||||
|
{
|
||||||
|
$this->showEditor = false;
|
||||||
|
$this->editingId = null;
|
||||||
|
$this->isCreating = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('hub::admin.content')
|
||||||
|
->layout('hub::admin.layouts.app', ['title' => 'Content']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
Loading…
Add table
Reference in a new issue