Initial Ladill Hosting app with Gitea deploy pipeline.
Deploy Ladill Hosting / deploy (push) Failing after 17s

Shared web hosting extracted from the platform monolith, with CI deploy
to /var/www/ladill-hosting matching Bird/Domains/Email.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-06 16:24:20 +00:00
co-authored by Cursor
commit e251a4cf60
367 changed files with 66268 additions and 0 deletions
@@ -0,0 +1,551 @@
<x-hosting-panel-layout :account="$account">
<x-slot name="title">Application Installer - {{ $account->username }}</x-slot>
<x-slot name="header">Application Installer</x-slot>
<div class="space-y-6" x-data="{ selectedApp: '' }">
@if(session('error'))
<div class="rounded-lg bg-red-50 border border-red-200 p-4">
<p class="text-sm text-red-800">{{ session('error') }}</p>
</div>
@endif
@php
$sslProvisioningSites = $account->sites->filter(fn($site) => $site->ssl_status === 'provisioning');
@endphp
@if($sslProvisioningSites->isNotEmpty())
<div class="rounded-xl border border-amber-200 bg-amber-50 p-4">
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-amber-500 animate-spin" 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>
</div>
<div>
<h3 class="text-sm font-medium text-amber-800">SSL Certificate Provisioning</h3>
<p class="mt-1 text-sm text-amber-700">
SSL certificates are being provisioned for
<strong>{{ $sslProvisioningSites->pluck('domain')->join(', ') }}</strong>.
Please wait until this completes before installing applications. This usually takes 1-2 minutes.
</p>
</div>
</div>
</div>
@endif
{{-- Active Installations In Progress (at top for visibility) --}}
@php
$pendingInstallations = \App\Models\AppInstallation::whereIn('hosted_site_id', $account->sites->pluck('id'))
->where('status', 'installing')
->with('site')
->get();
@endphp
@if($pendingInstallations->count() > 0)
<div x-data="installationProgress({{ $pendingInstallations->map(fn($p) => ['id' => $p->id, 'app_type' => $p->app_type, 'domain' => $p->site->domain, 'progress' => $p->progress, 'progress_message' => $p->progress_message])->toJson() }}, {{ $account->id }})">
<template x-for="item in installationItems" :key="item.id">
<div class="rounded-xl border overflow-hidden mb-4 transition-all duration-500"
:class="item.status === 'active' ? 'border-emerald-200 bg-emerald-50' : item.status === 'failed' ? 'border-red-200 bg-red-50' : 'border-indigo-200 bg-indigo-50'">
<div class="px-6 py-4 border-b transition-colors duration-500"
:class="item.status === 'active' ? 'border-emerald-200 bg-emerald-100/50' : item.status === 'failed' ? 'border-red-200 bg-red-100/50' : 'border-indigo-200 bg-indigo-100/50'">
<h3 class="text-base font-semibold flex items-center gap-2 transition-colors duration-500"
:class="item.status === 'active' ? 'text-emerald-900' : item.status === 'failed' ? 'text-red-900' : 'text-indigo-900'">
<template x-if="item.status === 'installing'">
<svg class="h-5 w-5 animate-spin text-indigo-600" 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>
</template>
<template x-if="item.status === 'active'">
<svg class="h-5 w-5 text-emerald-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</template>
<template x-if="item.status === 'failed'">
<svg class="h-5 w-5 text-red-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
</template>
<span x-text="item.status === 'active' ? 'Installation Complete!' : item.status === 'failed' ? 'Installation Failed' : 'Installing...'"></span>
</h3>
<p class="text-sm mt-1 transition-colors duration-500"
:class="item.status === 'active' ? 'text-emerald-700' : item.status === 'failed' ? 'text-red-700' : 'text-indigo-700'"
x-text="item.status === 'active' ? 'Your application is ready to use.' : item.status === 'failed' ? 'Something went wrong during installation.' : 'This may take a few minutes.'"></p>
</div>
<div class="p-6">
<div class="flex items-center gap-4 mb-4">
<div class="h-12 w-12 rounded-xl bg-white flex items-center justify-center flex-shrink-0 transition-colors duration-500"
:class="item.status === 'active' ? 'border-emerald-200 border' : item.status === 'failed' ? 'border-red-200 border' : 'border-indigo-200 border'">
<img :src="'/images/app_logos/' + item.app_type + '.svg'" :alt="item.app_type" class="h-7 w-7">
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold transition-colors duration-500"
:class="item.status === 'active' ? 'text-emerald-900' : item.status === 'failed' ? 'text-red-900' : 'text-indigo-900'"
x-text="item.app_type.charAt(0).toUpperCase() + item.app_type.slice(1)"></p>
<p class="text-xs transition-colors duration-500"
:class="item.status === 'active' ? 'text-emerald-600' : item.status === 'failed' ? 'text-red-600' : 'text-indigo-600'"
x-text="item.domain"></p>
</div>
<div class="text-right" x-show="item.status === 'installing'">
<p class="text-2xl font-bold text-indigo-600" x-text="item.progress + '%'"></p>
</div>
</div>
{{-- Progress bar (only during installation) --}}
<div class="space-y-2" x-show="item.status === 'installing'" x-transition>
<div class="h-2 w-full bg-indigo-200 rounded-full overflow-hidden">
<div class="h-full bg-indigo-600 rounded-full transition-all duration-500 ease-out"
:style="'width: ' + item.progress + '%'"></div>
</div>
<p class="text-xs text-indigo-700" x-text="item.progress_message"></p>
</div>
{{-- Success actions --}}
<div class="flex items-center gap-3 pt-2" x-show="item.status === 'active'" x-transition>
<a :href="'https://' + item.domain" target="_blank" class="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 transition">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
Visit Your Site
</a>
<button @click="item.dismissed = true; $nextTick(() => { if (installationItems.every(i => i.dismissed)) window.location.reload(); })" class="inline-flex items-center gap-1 text-sm text-emerald-700 hover:text-emerald-900">
Dismiss
</button>
</div>
{{-- Failed actions --}}
<div class="pt-2" x-show="item.status === 'failed'" x-transition>
<p class="text-xs text-red-600 mb-3" x-text="item.error_message || 'Please try again or contact support if the issue persists.'"></p>
<button @click="window.location.reload()" class="inline-flex items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 transition">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
Try Again
</button>
</div>
</div>
</div>
</template>
</div>
@endif
{{-- Failed Installations --}}
@php
$failedInstallations = \App\Models\AppInstallation::whereIn('hosted_site_id', $account->sites->pluck('id'))
->where('status', 'failed')
->with('site')
->get();
@endphp
@if($failedInstallations->count() > 0)
<div class="rounded-xl border border-red-200 bg-red-50 overflow-hidden">
<div class="px-6 py-4 border-b border-red-200 bg-red-100/50">
<h3 class="text-base font-semibold text-red-900 flex items-center gap-2">
<svg class="h-5 w-5 text-red-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
Failed Installations
</h3>
</div>
<div class="divide-y divide-red-200">
@foreach($failedInstallations as $failed)
<div class="p-6">
<div class="flex items-center gap-4">
<div class="h-10 w-10 rounded-lg bg-white border border-red-200 flex items-center justify-center flex-shrink-0">
<img src="{{ asset('images/app_logos/' . $failed->app_type . '.svg') }}" alt="{{ ucfirst($failed->app_type) }}" class="h-6 w-6 opacity-50">
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-red-900">{{ ucfirst($failed->app_type) }} on {{ $failed->site->domain }}</p>
<p class="text-xs text-red-600 mt-0.5">{{ $failed->error_message ?: 'Installation failed. Please try again.' }}</p>
</div>
<form action="{{ route('hosting.panel.apps.install', $account) }}" method="POST" class="flex-shrink-0">
@csrf
<input type="hidden" name="app" value="{{ $failed->app_type }}">
<input type="hidden" name="site_id" value="{{ $failed->hosted_site_id }}">
<button type="submit" class="inline-flex items-center gap-1.5 rounded-lg bg-red-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-red-700 transition">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
Retry
</button>
</form>
</div>
</div>
@endforeach
</div>
</div>
@endif
@if(!empty($isWordPressPlan))
{{-- WordPress-Only Installer --}}
<div class="rounded-xl border border-slate-200 bg-white overflow-hidden">
<div class="relative bg-gradient-to-br from-[#21759b]/5 via-white to-[#21759b]/5">
<div class="px-6 py-8 sm:px-8">
<div class="flex flex-col items-center text-center max-w-md mx-auto">
<div class="h-16 w-16 rounded-2xl bg-gradient-to-br from-[#21759b]/10 to-[#21759b]/20 flex items-center justify-center mb-5">
@include('partials.wordpress-icon', ['class' => 'h-10 w-10'])
</div>
<h3 class="text-lg font-bold text-slate-900">Install WordPress</h3>
<p class="text-sm text-slate-500 mt-1.5">Your hosting plan is optimised for WordPress. Install it in one click with a database and admin credentials set up automatically.</p>
</div>
</div>
</div>
<form action="{{ route('hosting.panel.apps.install', $account) }}" method="POST" class="border-t border-slate-200 px-6 py-6 sm:px-8 space-y-5">
@csrf
<input type="hidden" name="app" value="wordpress">
<div class="grid gap-5 sm:grid-cols-2">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">Install To Domain</label>
@if($account->sites->count() > 0)
<select name="site_id" required class="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-indigo-500 bg-white">
@foreach($account->sites as $site)
<option value="{{ $site->id }}">{{ $site->domain }}</option>
@endforeach
</select>
@else
<p class="text-sm text-amber-600 bg-amber-50 border border-amber-200 rounded-lg p-3">You need to <a href="{{ route('hosting.panel.domains', $account) }}" class="underline font-medium">add a domain</a> first.</p>
@endif
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">PHP Version</label>
<select name="php_version" class="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-indigo-500 bg-white">
<option value="8.4">PHP 8.4 (Recommended)</option>
<option value="8.3">PHP 8.3</option>
<option value="8.2">PHP 8.2</option>
<option value="8.1">PHP 8.1</option>
</select>
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">Subdirectory <span class="text-slate-400 font-normal">(optional)</span></label>
<input type="text" name="directory" placeholder="Leave empty to install at domain root" pattern="[a-zA-Z0-9_-]*" maxlength="100" class="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm font-mono focus:border-indigo-500 focus:ring-indigo-500">
<p class="mt-1.5 text-xs text-slate-500">Install in a subfolder instead of the domain root.</p>
</div>
<div class="flex items-center gap-3 pt-2">
<button type="submit" class="inline-flex items-center gap-2 rounded-lg bg-[#21759b] px-5 py-2.5 text-sm font-semibold text-white hover:bg-[#1a5f7f] transition shadow-sm">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
Install WordPress
</button>
</div>
</form>
</div>
@else
{{-- Available Applications - Modern Grid --}}
<div class="rounded-xl border border-slate-200 bg-white overflow-hidden">
<div class="px-6 py-4 border-b border-slate-200">
<h3 class="text-base font-semibold text-slate-900">Available Applications</h3>
<p class="text-sm text-slate-500 mt-1">One-click installation for popular CMS platforms and frameworks.</p>
</div>
<div class="p-6">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
@foreach($availableApps as $app)
<div @click="selectedApp = '{{ $app['id'] }}'; $nextTick(() => { document.getElementById('install-form').scrollIntoView({ behavior: 'smooth', block: 'center' }); })" class="group relative rounded-xl border border-slate-200 bg-white p-5 hover:border-indigo-300 hover:shadow-md transition-all duration-200 cursor-pointer">
<div class="flex flex-col items-center text-center">
<div class="h-14 w-14 rounded-xl bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center mb-4 group-hover:scale-105 transition-transform">
<img src="{{ asset('images/app_logos/' . $app['id'] . '.svg') }}" alt="{{ $app['name'] }}" class="h-9 w-9">
</div>
<h4 class="text-sm font-semibold text-slate-900">{{ $app['name'] }}</h4>
<p class="text-xs text-slate-500 mt-1 line-clamp-2">{{ $app['description'] }}</p>
<div class="mt-3 flex flex-wrap justify-center gap-1.5">
<span class="inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">{{ ucfirst($app['category']) }}</span>
@if(in_array($app['id'], ['wordpress', 'joomla', 'drupal', 'opencart', 'magento', 'laravel']))
<span class="inline-flex items-center rounded-full bg-indigo-50 px-2 py-0.5 text-[10px] font-medium text-indigo-600">PHP</span>
@elseif($app['id'] === 'nodejs')
<span class="inline-flex items-center rounded-full bg-green-50 px-2 py-0.5 text-[10px] font-medium text-green-600">JavaScript</span>
@elseif($app['id'] === 'python')
<span class="inline-flex items-center rounded-full bg-yellow-50 px-2 py-0.5 text-[10px] font-medium text-yellow-700">Python</span>
@endif
</div>
</div>
</div>
@endforeach
</div>
</div>
</div>
{{-- Install Application Form --}}
<div id="install-form" class="rounded-xl border border-slate-200 bg-white p-6 scroll-mt-6">
<div class="flex items-start gap-4 mb-6">
<div class="h-10 w-10 rounded-lg bg-indigo-100 flex items-center justify-center flex-shrink-0">
<svg class="h-5 w-5 text-indigo-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/></svg>
</div>
<div>
<h3 class="text-base font-semibold text-slate-900">Install New Application</h3>
<p class="text-sm text-slate-500 mt-0.5">CMS applications automatically provision a database and generate admin credentials.</p>
</div>
</div>
<form action="{{ route('hosting.panel.apps.install', $account) }}" method="POST" class="space-y-5">
@csrf
<div class="grid gap-5 sm:grid-cols-2">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">Application</label>
<select name="app" x-model="selectedApp" required class="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-indigo-500 bg-white">
<option value="">Select an application...</option>
@foreach($availableApps as $app)
<option value="{{ $app['id'] }}">{{ $app['name'] }} {{ $app['description'] }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">Install To Domain</label>
@if($account->sites->count() > 0)
<select name="site_id" required class="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-indigo-500 bg-white">
@foreach($account->sites as $site)
<option value="{{ $site->id }}">{{ $site->domain }}</option>
@endforeach
</select>
@else
<p class="text-sm text-amber-600 bg-amber-50 border border-amber-200 rounded-lg p-3">You need to <a href="{{ route('hosting.panel.domains', $account) }}" class="underline font-medium">add a domain</a> first.</p>
@endif
</div>
</div>
{{-- PHP Version Selector (only for PHP apps) --}}
<div x-show="['wordpress', 'joomla', 'drupal', 'opencart', 'magento', 'laravel'].includes(selectedApp)" x-transition>
<label class="block text-sm font-medium text-slate-700 mb-1.5">PHP Version</label>
<select name="php_version" class="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-indigo-500 bg-white">
<option value="8.4">PHP 8.4 (Default)</option>
<option value="8.3">PHP 8.3</option>
<option value="8.2">PHP 8.2</option>
<option value="8.1">PHP 8.1</option>
</select>
<p class="mt-1.5 text-xs text-slate-500">
<span x-show="selectedApp === 'magento'" class="text-amber-600 font-medium">Magento requires PHP 8.1, 8.2, or 8.3</span>
<span x-show="selectedApp !== 'magento'">Select the PHP version for your application. Most apps work best with PHP 8.3 or 8.4.</span>
</p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">Subdirectory <span class="text-slate-400 font-normal">(optional)</span></label>
<input type="text" name="directory" placeholder="e.g., blog, shop (leave empty for root)" pattern="[a-zA-Z0-9_-]*" maxlength="100" class="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm font-mono focus:border-indigo-500 focus:ring-indigo-500">
<p class="mt-1.5 text-xs text-slate-500">Install in a subfolder instead of the domain root.</p>
</div>
<div class="flex items-center gap-3 pt-2">
<button type="submit" class="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-indigo-700 transition shadow-sm">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
Install Application
</button>
</div>
</form>
</div>
@endif
{{-- Current Installations --}}
@php
$installedSites = $account->sites->filter(fn($site) => $site->installed_app)->load('appInstallation');
@endphp
@if($installedSites->count() > 0)
<div class="rounded-xl border border-slate-200 bg-white overflow-hidden">
<div class="px-6 py-4 border-b border-slate-200">
<h3 class="text-base font-semibold text-slate-900">Current Installations</h3>
<p class="text-sm text-slate-500 mt-1">View your application credentials and access details below.</p>
</div>
<div class="divide-y divide-slate-200">
@foreach($installedSites as $site)
<div class="p-6" x-data="{ showCredentials: false }">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="h-10 w-10 rounded-lg bg-slate-100 flex items-center justify-center flex-shrink-0">
<img src="{{ asset('images/app_logos/' . $site->installed_app . '.svg') }}" alt="{{ ucfirst($site->installed_app) }}" class="h-6 w-6" onerror="this.style.display='none'; this.nextElementSibling.style.display='block';">
<svg class="h-6 w-6 text-slate-400 hidden" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
</div>
<div>
<p class="text-sm font-medium text-slate-900">{{ ucfirst($site->installed_app) }}</p>
<p class="text-xs text-slate-500">{{ $site->domain }}</p>
</div>
</div>
<div class="flex items-center gap-3">
@if($site->installed_app_version)
<span class="text-xs text-slate-500">v{{ $site->installed_app_version }}</span>
@endif
<span class="inline-flex items-center rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">Active</span>
<a href="https://{{ $site->domain }}" target="_blank" class="text-xs text-indigo-600 hover:text-indigo-800">Visit Site </a>
@if($site->appInstallation)
<button @click="showCredentials = !showCredentials" class="inline-flex items-center gap-1 text-xs text-slate-600 hover:text-slate-800">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path x-show="!showCredentials" stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path x-show="!showCredentials" stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
<path x-show="showCredentials" stroke-linecap="round" stroke-linejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
</svg>
<span x-text="showCredentials ? 'Hide' : 'Show'"></span> Credentials
</button>
@endif
<form action="{{ route('hosting.panel.apps.delete', [$account, $site]) }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this {{ ucfirst($site->installed_app) }} installation? This will remove all application files and cannot be undone.');">
@csrf
@method('DELETE')
<button type="submit" class="inline-flex items-center gap-1.5 rounded-lg border border-red-200 bg-white px-3 py-1.5 text-xs font-medium text-red-600 hover:bg-red-50 transition">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
Delete
</button>
</form>
</div>
</div>
{{-- Credentials Panel --}}
@if($site->appInstallation)
<div x-show="showCredentials" x-collapse class="mt-4 rounded-lg bg-slate-50 border border-slate-200 p-4">
<div class="grid gap-4 sm:grid-cols-2">
{{-- Admin Credentials --}}
<div class="space-y-3">
<h4 class="text-sm font-semibold text-slate-700 flex items-center gap-2">
<svg class="h-4 w-4 text-slate-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" 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>
Admin Access
</h4>
<div class="space-y-2">
<div>
<label class="block text-xs text-slate-500 mb-0.5">Admin URL</label>
<div class="flex items-center gap-2">
<a href="{{ $site->appInstallation->admin_url }}" target="_blank" class="text-sm text-indigo-600 hover:text-indigo-800 font-mono break-all">{{ $site->appInstallation->admin_url }}</a>
<button onclick="navigator.clipboard.writeText('{{ $site->appInstallation->admin_url }}')" class="text-slate-400 hover:text-slate-600 flex-shrink-0" title="Copy">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
</button>
</div>
</div>
<div>
<label class="block text-xs text-slate-500 mb-0.5">Username</label>
<div class="flex items-center gap-2">
<span class="text-sm font-mono text-slate-900">{{ $site->appInstallation->admin_username }}</span>
<button onclick="navigator.clipboard.writeText('{{ $site->appInstallation->admin_username }}')" class="text-slate-400 hover:text-slate-600" title="Copy">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
</button>
</div>
</div>
<div>
<label class="block text-xs text-slate-500 mb-0.5">Password</label>
@if($site->appInstallation->admin_password)
<div class="flex items-center gap-2" x-data="{ showPassword: false }">
<span class="text-sm font-mono text-slate-900" x-text="showPassword ? '{{ $site->appInstallation->admin_password }}' : '••••••••••••'"></span>
<button @click="showPassword = !showPassword" class="text-slate-400 hover:text-slate-600" title="Toggle visibility">
<svg x-show="!showPassword" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
<svg x-show="showPassword" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/></svg>
</button>
<button onclick="navigator.clipboard.writeText('{{ $site->appInstallation->admin_password }}')" class="text-slate-400 hover:text-slate-600" title="Copy">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
</button>
</div>
@else
<p class="text-sm text-slate-500 italic">Use the application's password reset if needed</p>
@endif
</div>
</div>
</div>
{{-- Database Credentials --}}
@if($site->appInstallation->database_name)
<div class="space-y-3">
<h4 class="text-sm font-semibold text-slate-700 flex items-center gap-2">
<svg class="h-4 w-4 text-slate-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"/></svg>
Database Access
</h4>
<div class="space-y-2">
<div>
<label class="block text-xs text-slate-500 mb-0.5">Database Name</label>
<div class="flex items-center gap-2">
<span class="text-sm font-mono text-slate-900">{{ $site->appInstallation->database_name }}</span>
<button onclick="navigator.clipboard.writeText('{{ $site->appInstallation->database_name }}')" class="text-slate-400 hover:text-slate-600" title="Copy">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
</button>
</div>
</div>
<div>
<label class="block text-xs text-slate-500 mb-0.5">Username</label>
<div class="flex items-center gap-2">
<span class="text-sm font-mono text-slate-900">{{ $site->appInstallation->database_username }}</span>
<button onclick="navigator.clipboard.writeText('{{ $site->appInstallation->database_username }}')" class="text-slate-400 hover:text-slate-600" title="Copy">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
</button>
</div>
</div>
<div>
<label class="block text-xs text-slate-500 mb-0.5">Password</label>
@if($site->appInstallation->database_password)
<div class="flex items-center gap-2" x-data="{ showDbPassword: false }">
<span class="text-sm font-mono text-slate-900" x-text="showDbPassword ? '{{ $site->appInstallation->database_password }}' : '••••••••••••'"></span>
<button @click="showDbPassword = !showDbPassword" class="text-slate-400 hover:text-slate-600" title="Toggle visibility">
<svg x-show="!showDbPassword" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
<svg x-show="showDbPassword" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/></svg>
</button>
<button onclick="navigator.clipboard.writeText('{{ $site->appInstallation->database_password }}')" class="text-slate-400 hover:text-slate-600" title="Copy">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
</button>
</div>
@else
<p class="text-sm text-slate-500 italic">Not available</p>
@endif
</div>
</div>
</div>
@endif
</div>
<div class="mt-4 pt-3 border-t border-slate-200">
<p class="text-xs text-amber-600 flex items-center gap-1">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
Keep these credentials secure. We recommend changing your password after first login.
</p>
</div>
</div>
@endif
</div>
@endforeach
</div>
</div>
@endif
</div>
@push('scripts')
<script>
function installationProgress(initialItems, accountId) {
return {
installationItems: initialItems.map(item => ({
...item,
status: 'installing',
dismissed: false,
error_message: null
})),
polling: null,
init() {
if (this.installationItems.length === 0) return;
// Start polling
this.poll();
this.polling = setInterval(() => this.poll(), 2000);
},
async poll() {
let hasInstalling = false;
for (const item of this.installationItems) {
if (item.status !== 'installing') continue;
try {
const response = await fetch(`/hosting/panel/${accountId}/apps/${item.id}/progress`);
if (!response.ok) continue;
const data = await response.json();
item.progress = data.progress;
item.progress_message = data.progress_message;
if (data.status === 'active') {
item.status = 'active';
item.progress = 100;
item.progress_message = 'Installation complete!';
} else if (data.status === 'failed') {
item.status = 'failed';
item.error_message = data.error_message;
} else {
hasInstalling = true;
}
} catch (e) {
console.error('Failed to fetch progress:', e);
hasInstalling = true;
}
}
if (!hasInstalling && this.polling) {
clearInterval(this.polling);
this.polling = null;
}
},
destroy() {
if (this.polling) {
clearInterval(this.polling);
}
}
};
}
</script>
@endpush
</x-hosting-panel-layout>
@@ -0,0 +1,136 @@
<x-hosting-panel-layout :account="$account">
<x-slot name="title">Cron Jobs - {{ $account->username }}</x-slot>
<x-slot name="header">Cron Jobs</x-slot>
<div class="space-y-6">
@if(session('success'))
<div class="rounded-lg bg-emerald-50 border border-emerald-200 p-4">
<p class="text-sm text-emerald-800">{{ session('success') }}</p>
</div>
@endif
@if(session('error'))
<div class="rounded-lg bg-red-50 border border-red-200 p-4">
<p class="text-sm text-red-800">{{ session('error') }}</p>
</div>
@endif
<div class="rounded-xl border border-slate-200 bg-white p-6">
<h3 class="text-base font-semibold text-slate-900 mb-4">Add Cron Job</h3>
<form action="{{ route('hosting.panel.cron.add', $account) }}" method="POST" class="space-y-4">
@csrf
<div class="grid gap-4 sm:grid-cols-5">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Minute</label>
<input type="text" name="minute" value="*" required class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm font-mono focus:border-indigo-500 focus:ring-indigo-500">
<p class="mt-1 text-[10px] text-slate-500">0-59 or *</p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Hour</label>
<input type="text" name="hour" value="*" required class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm font-mono focus:border-indigo-500 focus:ring-indigo-500">
<p class="mt-1 text-[10px] text-slate-500">0-23 or *</p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Day</label>
<input type="text" name="day" value="*" required class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm font-mono focus:border-indigo-500 focus:ring-indigo-500">
<p class="mt-1 text-[10px] text-slate-500">1-31 or *</p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Month</label>
<input type="text" name="month" value="*" required class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm font-mono focus:border-indigo-500 focus:ring-indigo-500">
<p class="mt-1 text-[10px] text-slate-500">1-12 or *</p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Weekday</label>
<input type="text" name="weekday" value="*" required class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm font-mono focus:border-indigo-500 focus:ring-indigo-500">
<p class="mt-1 text-[10px] text-slate-500">0-6 or *</p>
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Command</label>
<input type="text" name="command" required placeholder="/usr/bin/php /home/{{ $account->username }}/public_html/artisan schedule:run" class="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm font-mono focus:border-indigo-500 focus:ring-indigo-500">
<p class="mt-1 text-xs text-slate-500">Use full paths for commands and scripts</p>
</div>
<button type="submit" class="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-indigo-700 transition">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
Add Cron Job
</button>
</form>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-6">
<h3 class="text-base font-semibold text-slate-900 mb-2">Common Schedules</h3>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div class="rounded-lg bg-slate-50 p-3">
<p class="text-xs font-medium text-slate-700">Every minute</p>
<code class="text-xs font-mono text-slate-600">* * * * *</code>
</div>
<div class="rounded-lg bg-slate-50 p-3">
<p class="text-xs font-medium text-slate-700">Every 5 minutes</p>
<code class="text-xs font-mono text-slate-600">*/5 * * * *</code>
</div>
<div class="rounded-lg bg-slate-50 p-3">
<p class="text-xs font-medium text-slate-700">Every hour</p>
<code class="text-xs font-mono text-slate-600">0 * * * *</code>
</div>
<div class="rounded-lg bg-slate-50 p-3">
<p class="text-xs font-medium text-slate-700">Daily at midnight</p>
<code class="text-xs font-mono text-slate-600">0 0 * * *</code>
</div>
<div class="rounded-lg bg-slate-50 p-3">
<p class="text-xs font-medium text-slate-700">Weekly (Sunday)</p>
<code class="text-xs font-mono text-slate-600">0 0 * * 0</code>
</div>
<div class="rounded-lg bg-slate-50 p-3">
<p class="text-xs font-medium text-slate-700">Monthly</p>
<code class="text-xs font-mono text-slate-600">0 0 1 * *</code>
</div>
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white overflow-hidden">
<div class="px-6 py-4 border-b border-slate-200">
<h3 class="text-base font-semibold text-slate-900">Active Cron Jobs</h3>
</div>
@if(count($cronJobs))
<table class="min-w-full divide-y divide-slate-200">
<thead class="bg-slate-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Schedule</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Command</th>
<th class="px-6 py-3 text-right text-xs font-medium text-slate-500 uppercase">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200">
@foreach($cronJobs as $job)
<tr class="hover:bg-slate-50">
<td class="px-6 py-4 whitespace-nowrap">
<code class="text-sm font-mono text-slate-700 bg-slate-100 px-2 py-1 rounded">{{ $job['schedule'] }}</code>
</td>
<td class="px-6 py-4">
<code class="text-sm font-mono text-slate-600 break-all">{{ $job['command'] }}</code>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<form action="{{ route('hosting.panel.cron.delete', $account) }}" method="POST" onsubmit="return confirm('Delete this cron job?')">
@csrf
@method('DELETE')
<input type="hidden" name="line_number" value="{{ $job['line_number'] }}">
<button type="submit" class="inline-flex items-center gap-1.5 rounded-lg border border-red-200 bg-white px-3 py-1.5 text-xs font-medium text-red-600 hover:bg-red-50 transition">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/></svg>
Delete
</button>
</form>
</td>
</tr>
@endforeach
</tbody>
</table>
@else
<div class="px-6 py-12 text-center">
<svg class="mx-auto h-12 w-12 text-slate-300" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<p class="mt-4 text-sm text-slate-500">No cron jobs configured yet.</p>
</div>
@endif
</div>
</div>
</x-hosting-panel-layout>
@@ -0,0 +1,148 @@
<x-hosting-panel-layout :account="$account">
<x-slot name="title">Databases - {{ $account->username }}</x-slot>
<x-slot name="header">Databases</x-slot>
<div class="space-y-6">
@if(session('success'))
<div class="rounded-lg bg-emerald-50 border border-emerald-200 p-4">
<p class="text-sm text-emerald-800">{{ session('success') }}</p>
</div>
@endif
@if(session('error'))
<div class="rounded-lg bg-red-50 border border-red-200 p-4">
<p class="text-sm text-red-800">{{ session('error') }}</p>
</div>
@endif
<div class="rounded-xl border border-slate-200 bg-white p-6">
<h3 class="text-base font-semibold text-slate-900 mb-4">Create New Database</h3>
<form action="{{ route('hosting.panel.databases.create', $account) }}" method="POST" class="flex flex-wrap items-end gap-4">
@csrf
<div class="flex-1 min-w-[200px]">
<label class="block text-sm font-medium text-slate-700 mb-1">Database Name</label>
<div class="flex items-center">
<span class="inline-flex items-center rounded-l-lg border border-r-0 border-slate-200 bg-slate-50 px-3 py-2.5 text-sm text-slate-500">{{ substr($account->username, 0, 8) }}_</span>
<input type="text" name="name" required pattern="[a-zA-Z0-9_]+" maxlength="24" placeholder="mydb" class="flex-1 rounded-r-lg border border-slate-200 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-indigo-500">
</div>
</div>
<button type="submit" class="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-indigo-700 transition">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
Create Database
</button>
</form>
</div>
<div class="rounded-xl border border-slate-200 bg-white overflow-hidden">
<div class="px-6 py-4 border-b border-slate-200">
<h3 class="text-base font-semibold text-slate-900">Your Databases</h3>
</div>
@if($account->databases->count())
<table class="min-w-full divide-y divide-slate-200">
<thead class="bg-slate-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Database Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Username</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Host</th>
<th class="px-6 py-3 text-right text-xs font-medium text-slate-500 uppercase">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200">
@foreach($account->databases as $database)
<tr class="hover:bg-slate-50">
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm font-mono text-slate-900">{{ $database->name }}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm font-mono text-slate-600">{{ $database->username }}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm font-mono text-slate-600">{{ $database->host }}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<div class="flex items-center justify-end gap-2">
@if($phpMyAdminUrl)
<a href="{{ $phpMyAdminUrl }}" target="_blank" class="inline-flex items-center gap-1.5 rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-1.5 text-xs font-medium text-indigo-700 hover:bg-indigo-100 transition">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375"/></svg>
phpMyAdmin
</a>
@endif
<button onclick="resetPassword({{ $database->id }})" class="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50 transition">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"/></svg>
Reset Password
</button>
<form action="{{ route('hosting.panel.databases.delete', [$account, $database]) }}" method="POST" onsubmit="return confirm('Are you sure? This will permanently delete the database and all its data.')">
@csrf
@method('DELETE')
<button type="submit" class="inline-flex items-center gap-1.5 rounded-lg border border-red-200 bg-white px-3 py-1.5 text-xs font-medium text-red-600 hover:bg-red-50 transition">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/></svg>
Delete
</button>
</form>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
@else
<div class="px-6 py-12 text-center">
<svg class="mx-auto h-12 w-12 text-slate-300" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375"/></svg>
<p class="mt-4 text-sm text-slate-500">No databases yet. Create one above.</p>
</div>
@endif
</div>
<div class="rounded-xl border border-slate-200 bg-white p-6">
<h3 class="text-base font-semibold text-slate-900 mb-4">Connection Information</h3>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div>
<p class="text-xs font-medium text-slate-500">Host</p>
<p class="mt-1 text-sm font-mono text-slate-900">localhost</p>
</div>
<div>
<p class="text-xs font-medium text-slate-500">Port</p>
<p class="mt-1 text-sm font-mono text-slate-900">3306</p>
</div>
<div>
<p class="text-xs font-medium text-slate-500">External Access</p>
<p class="mt-1 text-sm text-slate-500">Not available (localhost only)</p>
</div>
<div>
<p class="text-xs font-medium text-slate-500">phpMyAdmin</p>
@if($phpMyAdminUrl)
<a href="{{ $phpMyAdminUrl }}" target="_blank" rel="noopener noreferrer" class="mt-1 inline-flex items-center gap-1.5 text-sm font-medium text-indigo-600 hover:text-indigo-800">
Open phpMyAdmin
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H18m0 0v4.5M18 6l-7.5 7.5"/><path stroke-linecap="round" stroke-linejoin="round" d="M6 18h12a2 2 0 002-2V9.75m-16 0V16a2 2 0 002 2"/></svg>
</a>
<p class="mt-1 text-xs text-slate-500">Use your database username and password to log in.</p>
@else
<p class="mt-1 text-sm text-slate-500">phpMyAdmin is not configured for this server.</p>
@endif
</div>
</div>
</div>
</div>
@push('scripts')
<script>
async function resetPassword(databaseId) {
if (!confirm('Reset the database password? You will need to update your application configuration.')) return;
try {
const res = await fetch(`{{ url('hosting/panel/' . $account->id . '/databases') }}/${databaseId}/reset-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
});
const data = await res.json();
if (data.success) {
alert('New password: ' + data.password + '\n\nPlease save this password - it will not be shown again.');
} else {
alert(data.error || 'Failed to reset password');
}
} catch (e) {
alert('Error: ' + e.message);
}
}
</script>
@endpush
</x-hosting-panel-layout>
@@ -0,0 +1,256 @@
<x-hosting-panel-layout :account="$account">
<x-slot name="title">Domains - {{ $account->username }}</x-slot>
<x-slot name="header">Domains</x-slot>
<div class="space-y-6">
@if(session('success'))
<div class="rounded-lg bg-emerald-50 border border-emerald-200 p-4">
<p class="text-sm text-emerald-800">{{ session('success') }}</p>
</div>
@endif
@if(session('error'))
<div class="rounded-lg bg-red-50 border border-red-200 p-4">
<p class="text-sm text-red-800">{{ session('error') }}</p>
</div>
@endif
@php
$ownedDomainHosts = $ownedDomains->map(fn ($d) => (string) $d)->all();
$oldDomain = trim((string) old('domain', ''));
$initialDomainSource = $ownedDomains->isEmpty()
? 'external'
: ((string) old('is_owned_domain', '1') === '0' ? 'external' : 'ladill');
$selectedOwnedDomain = in_array($oldDomain, $ownedDomainHosts, true) ? $oldDomain : '';
$externalFallbackDomain = ! in_array($oldDomain, $ownedDomainHosts, true) ? $oldDomain : '';
@endphp
<div class="rounded-xl border border-slate-200 bg-white p-6">
<h3 class="text-base font-semibold text-slate-900 mb-4">Add Domain</h3>
<form action="{{ route('hosting.panel.domains.add', $account) }}" method="POST" class="space-y-5"
x-data="{
domainSource: @js($initialDomainSource),
selectedOwnedDomain: @js($selectedOwnedDomain),
externalDomain: @js($externalFallbackDomain),
onboardingMode: '{{ old('onboarding_mode', 'ns_auto') }}'
}">
@csrf
<input type="hidden" name="is_owned_domain" :value="domainSource === 'ladill' ? '1' : '0'">
{{-- Domain Source Toggle --}}
<div>
<div class="mb-2 flex items-center justify-between">
<label class="text-sm font-medium text-slate-700">Domain</label>
<div class="inline-flex items-center rounded-md bg-slate-100 p-0.5 text-[11px] font-medium">
<button type="button" @click="domainSource = 'ladill'"
:class="domainSource === 'ladill' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500'"
class="rounded px-2 py-1 transition">Ladill</button>
<button type="button" @click="domainSource = 'external'"
:class="domainSource === 'external' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500'"
class="rounded px-2 py-1 transition">External</button>
</div>
</div>
{{-- Ladill (Owned) Domains --}}
<div x-show="domainSource === 'ladill'" x-cloak>
@if($ownedDomains->isNotEmpty())
<select name="domain" x-model="selectedOwnedDomain" :disabled="domainSource !== 'ladill'"
class="block w-full rounded-lg border-slate-200 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<option value="">Choose a domain...</option>
@foreach($ownedDomains as $domain)
<option value="{{ $domain }}">{{ $domain }}</option>
@endforeach
</select>
<p class="mt-1.5 text-xs text-slate-500">
Don't have a domain?
<a href="{{ route('user.domains.purchase') }}" class="font-medium text-indigo-600 hover:text-indigo-800">Purchase one first</a>
</p>
@else
<div class="rounded-lg bg-slate-50 px-4 py-3 text-center text-sm text-slate-600">
No domains found in your account.
<a href="{{ route('user.domains.purchase') }}" class="font-medium text-indigo-600 underline underline-offset-2 hover:text-indigo-800">Register a domain</a>
</div>
@endif
</div>
{{-- External Domain --}}
<div x-show="domainSource === 'external'" x-cloak>
<input type="text" name="domain" x-model="externalDomain" :disabled="domainSource !== 'external'" required placeholder="example.com"
class="block w-full rounded-lg border-slate-200 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 @error('domain') border-red-300 @enderror">
<p class="mt-1.5 text-xs text-slate-500">Enter your domain without www or http (e.g., example.com)</p>
</div>
@error('domain')
<p class="mt-1.5 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
{{-- Onboarding Mode (External only) --}}
<div x-show="domainSource === 'external'" x-cloak>
<label class="block text-sm font-medium text-slate-700 mb-3">Connection Method</label>
<div class="space-y-3">
<label class="relative flex cursor-pointer rounded-lg border border-slate-200 bg-white p-4 hover:bg-slate-50 transition">
<input type="radio" name="onboarding_mode" value="ns_auto" x-model="onboardingMode" class="sr-only peer">
<div class="flex flex-1 items-start gap-3">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-emerald-100 text-emerald-600">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 0 6h13.5a3 3 0 1 0 0-6m-16.5-3a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3m-19.5 0a4.5 4.5 0 0 1 .9-2.7L5.737 5.1a3.375 3.375 0 0 1 2.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 0 1 .9 2.7m0 0a3 3 0 0 1-3 3m0 3h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Zm-3 6h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Z"/></svg>
</div>
<div class="flex-1">
<p class="text-sm font-semibold text-slate-900">Point Nameservers</p>
<p class="mt-0.5 text-xs text-slate-500">Point your domain's nameservers to Ladill. We'll manage DNS and SSL automatically.</p>
</div>
</div>
<div class="absolute right-4 top-4 hidden h-5 w-5 items-center justify-center rounded-full bg-indigo-600 text-white peer-checked:flex">
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
</div>
<div class="absolute inset-0 rounded-lg border-2 border-transparent peer-checked:border-indigo-600 pointer-events-none"></div>
</label>
<label class="relative flex cursor-pointer rounded-lg border border-slate-200 bg-white p-4 hover:bg-slate-50 transition">
<input type="radio" name="onboarding_mode" value="manual_dns" x-model="onboardingMode" class="sr-only peer">
<div class="flex flex-1 items-start gap-3">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-blue-100 text-blue-600">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"/></svg>
</div>
<div class="flex-1">
<p class="text-sm font-semibold text-slate-900">Add DNS Records</p>
<p class="mt-0.5 text-xs text-slate-500">Keep your current DNS provider and manually add A records pointing to our server.</p>
</div>
</div>
<div class="absolute right-4 top-4 hidden h-5 w-5 items-center justify-center rounded-full bg-indigo-600 text-white peer-checked:flex">
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
</div>
<div class="absolute inset-0 rounded-lg border-2 border-transparent peer-checked:border-indigo-600 pointer-events-none"></div>
</label>
</div>
@error('onboarding_mode')
<p class="mt-1.5 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
{{-- Document Root (optional, both flows) --}}
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Document Root <span class="font-normal text-slate-400">(optional)</span></label>
<input type="text" name="document_root" placeholder="public_html/example.com" value="{{ old('document_root') }}"
class="w-full rounded-lg border-slate-200 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<p class="mt-1 text-xs text-slate-500">Leave empty to use default: public_html/domain.com</p>
</div>
<button type="submit" class="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-indigo-700 transition">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
Add Domain
</button>
</form>
</div>
<div class="rounded-xl border border-slate-200 bg-white overflow-hidden">
<div class="px-6 py-4 border-b border-slate-200">
<h3 class="text-base font-semibold text-slate-900">Your Domains</h3>
</div>
@if($account->sites->count())
<table class="min-w-full divide-y divide-slate-200">
<thead class="bg-slate-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Domain</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Document Root</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Status</th>
<th class="px-6 py-3 text-right text-xs font-medium text-slate-500 uppercase">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200">
@foreach($account->sites as $site)
<tr class="hover:bg-slate-50">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-slate-900">{{ $site->domain }}</span>
<a href="http://{{ $site->domain }}" target="_blank" class="text-slate-400 hover:text-indigo-600">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/></svg>
</a>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm font-mono text-slate-600">{{ $site->document_root }}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {{ $site->status === 'active' ? 'bg-emerald-100 text-emerald-800' : 'bg-slate-100 text-slate-800' }}">
{{ ucfirst($site->status) }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
@if((int) $account->user_id === (int) auth()->id())
<form action="{{ route('hosting.panel.domains.remove', [$account, $site]) }}" method="POST" onsubmit="return confirm('Remove this domain? The files will not be deleted.')">
@csrf
@method('DELETE')
<button type="submit" class="inline-flex items-center gap-1.5 rounded-lg border border-red-200 bg-white px-3 py-1.5 text-xs font-medium text-red-600 hover:bg-red-50 transition">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/></svg>
Remove
</button>
</form>
@else
<span class="text-xs text-slate-400">Owner only</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
@else
<div class="px-6 py-12 text-center">
@include('components.icons.domain-globe', ['class' => 'mx-auto h-12 w-12 text-slate-300'])
<p class="mt-4 text-sm text-slate-500">No addon domains yet. Add one above.</p>
</div>
@endif
</div>
@php
$serverIp = $account->node?->ip_address ?? '161.97.138.149';
@endphp
{{-- DNS Quick Reference --}}
<div class="rounded-xl border border-slate-200 bg-white p-6">
<h3 class="text-base font-semibold text-slate-900 mb-2">DNS Quick Reference</h3>
<p class="text-sm text-slate-600 mb-4">Use one of these methods to connect an external domain:</p>
<div class="grid gap-4 md:grid-cols-2">
<div class="rounded-lg border border-emerald-200 bg-emerald-50/50 p-4">
<div class="flex items-center gap-2 mb-2">
<h4 class="text-sm font-semibold text-slate-900">Nameservers</h4>
<span class="inline-flex items-center rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-medium text-emerald-800">Recommended</span>
</div>
<div class="space-y-1.5">
<div class="flex items-center justify-between rounded bg-white border border-emerald-200 px-3 py-1.5">
<span class="text-sm font-mono text-slate-900">ns1.ladill.com</span>
<button type="button" onclick="navigator.clipboard.writeText('ns1.ladill.com')" class="text-xs text-indigo-600 hover:text-indigo-800">Copy</button>
</div>
<div class="flex items-center justify-between rounded bg-white border border-emerald-200 px-3 py-1.5">
<span class="text-sm font-mono text-slate-900">ns2.ladill.com</span>
<button type="button" onclick="navigator.clipboard.writeText('ns2.ladill.com')" class="text-xs text-indigo-600 hover:text-indigo-800">Copy</button>
</div>
</div>
</div>
<div class="rounded-lg border border-slate-200 bg-slate-50/50 p-4">
<h4 class="text-sm font-semibold text-slate-900 mb-2">A Records</h4>
<div class="space-y-1 rounded bg-white border border-slate-200 p-3">
<div class="grid grid-cols-3 gap-2 text-xs">
<span class="font-medium text-slate-500">Type</span>
<span class="font-medium text-slate-500">Name</span>
<span class="font-medium text-slate-500">Value</span>
</div>
<div class="grid grid-cols-3 gap-2 text-xs">
<span class="font-mono text-slate-900">A</span>
<span class="font-mono text-slate-900">@</span>
<span class="font-mono text-slate-900">{{ $serverIp }}</span>
</div>
<div class="grid grid-cols-3 gap-2 text-xs">
<span class="font-mono text-slate-900">A</span>
<span class="font-mono text-slate-900">www</span>
<span class="font-mono text-slate-900">{{ $serverIp }}</span>
</div>
</div>
<p class="mt-2 text-xs text-slate-500">DNS changes can take up to 2448 hours.</p>
</div>
</div>
</div>
</div>
</x-hosting-panel-layout>
@@ -0,0 +1,782 @@
<x-hosting-panel-layout :account="$account">
<x-slot name="title">File Manager - {{ $account->username }}</x-slot>
<x-slot name="header">File Manager</x-slot>
<div x-data="fileManager()" class="space-y-6">
{{-- Action Feedback --}}
<template x-teleport="body">
<div class="fixed right-4 top-4 z-[70] w-[calc(100%-2rem)] max-w-sm space-y-2 sm:right-6 sm:top-6 sm:w-full" aria-live="polite" aria-atomic="true">
<template x-for="toast in toasts" :key="toast.id">
<div
x-show="toast.visible"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="translate-y-2 opacity-0 sm:translate-x-2 sm:translate-y-0"
x-transition:enter-end="translate-y-0 opacity-100 sm:translate-x-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="rounded-xl border bg-white p-4 shadow-lg"
:class="toast.type === 'success' ? 'border-emerald-200' : (toast.type === 'error' ? 'border-red-200' : 'border-indigo-200')"
>
<div class="flex items-start gap-3">
<div class="mt-0.5 rounded-full p-1"
:class="toast.type === 'success' ? 'bg-emerald-100 text-emerald-600' : (toast.type === 'error' ? 'bg-red-100 text-red-600' : 'bg-indigo-100 text-indigo-600')">
<svg x-show="toast.type === 'success'" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg>
<svg x-show="toast.type === 'error'" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"/></svg>
<svg x-show="toast.type !== 'success' && toast.type !== 'error'" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"/></svg>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-slate-900" x-text="toast.title"></p>
<p class="mt-0.5 text-sm text-slate-600" x-text="toast.message"></p>
</div>
<button type="button" @click="dismissToast(toast.id)" class="rounded-md p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
</div>
</template>
</div>
</template>
<div x-show="actionStatus.message" x-cloak class="rounded-xl border p-4"
:class="actionStatus.type === 'success' ? 'border-emerald-200 bg-emerald-50' : (actionStatus.type === 'error' ? 'border-red-200 bg-red-50' : 'border-indigo-200 bg-indigo-50')">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-semibold"
:class="actionStatus.type === 'success' ? 'text-emerald-800' : (actionStatus.type === 'error' ? 'text-red-800' : 'text-indigo-800')"
x-text="actionStatus.title"></p>
<p class="mt-0.5 text-sm"
:class="actionStatus.type === 'success' ? 'text-emerald-700' : (actionStatus.type === 'error' ? 'text-red-700' : 'text-indigo-700')"
x-text="actionStatus.message"></p>
</div>
<button type="button" @click="clearStatus()" class="rounded-md p-1 text-slate-400 hover:bg-white/70 hover:text-slate-600">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
</div>
{{-- Toolbar --}}
<div class="flex flex-wrap items-center gap-3 rounded-xl border border-slate-200 bg-white p-4">
<button @click="createModal = true; createType = 'folder'" class="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 transition">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 10.5v6m3-3H9m4.06-7.19l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z"/></svg>
New Folder
</button>
<button @click="createModal = true; createType = 'file'" class="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 transition">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m3.75 9v6m3-3H9m1.5-12H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/></svg>
New File
</button>
<label class="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 transition cursor-pointer">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"/></svg>
Upload
<input type="file" class="hidden" @change="uploadFile($event)" multiple>
</label>
</div>
{{-- Upload Progress --}}
<div x-show="uploading" x-cloak class="rounded-xl border border-indigo-200 bg-indigo-50 p-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-indigo-700">Uploading <span x-text="uploadFileName"></span></span>
<span class="text-sm font-semibold text-indigo-700" x-text="uploadProgress + '%'"></span>
</div>
<div class="h-2 w-full rounded-full bg-indigo-100 overflow-hidden">
<div class="h-full rounded-full bg-indigo-600 transition-all duration-300" :style="'width: ' + uploadProgress + '%'"></div>
</div>
<p x-show="uploadProgress >= 100" class="mt-2 text-xs text-indigo-600">Processing file on server...</p>
</div>
{{-- Extraction Progress --}}
<div x-show="extracting" x-cloak class="rounded-xl border border-emerald-200 bg-emerald-50 p-4">
<div class="flex items-center gap-3">
<svg class="h-5 w-5 text-emerald-600 animate-spin" 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>
<div>
<span class="text-sm font-medium text-emerald-700">Extracting <span x-text="extractFileName"></span></span>
<p class="text-xs text-emerald-600 mt-0.5">This may take a while for large archives...</p>
</div>
</div>
</div>
{{-- Bulk Action Bar --}}
<div x-show="selected.length > 0" x-cloak class="flex flex-wrap items-center gap-2 rounded-xl border border-indigo-200 bg-indigo-50 p-3">
<span class="text-sm font-medium text-indigo-700 mr-1" x-text="selected.length + ' selected'"></span>
<div class="h-5 w-px bg-indigo-200"></div>
<button @click="bulkAction('move')" class="inline-flex items-center gap-1.5 rounded-lg bg-white px-3 py-1.5 text-xs font-medium text-slate-700 border border-slate-200 hover:bg-slate-50 transition">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"/></svg>
Move
</button>
<button @click="bulkAction('copy')" class="inline-flex items-center gap-1.5 rounded-lg bg-white px-3 py-1.5 text-xs font-medium text-slate-700 border border-slate-200 hover:bg-slate-50 transition">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75"/></svg>
Copy
</button>
<button @click="bulkAction('compress')" class="inline-flex items-center gap-1.5 rounded-lg bg-white px-3 py-1.5 text-xs font-medium text-slate-700 border border-slate-200 hover:bg-slate-50 transition">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"/></svg>
Compress
</button>
<button @click="bulkAction('chmod')" class="inline-flex items-center gap-1.5 rounded-lg bg-white px-3 py-1.5 text-xs font-medium text-slate-700 border border-slate-200 hover:bg-slate-50 transition">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"/></svg>
Permissions
</button>
<button @click="bulkAction('delete')" class="inline-flex items-center gap-1.5 rounded-lg bg-red-50 px-3 py-1.5 text-xs font-medium text-red-700 border border-red-200 hover:bg-red-100 transition">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/></svg>
Delete
</button>
<div class="ml-auto">
<button @click="selected = []" class="text-xs text-indigo-600 hover:text-indigo-800 font-medium">Clear</button>
</div>
</div>
{{-- Breadcrumbs --}}
<div class="flex items-center gap-2 text-sm">
@foreach($breadcrumbs as $index => $crumb)
@if($index > 0)
<svg class="h-4 w-4 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/></svg>
@endif
<a href="{{ route('hosting.panel.files', ['account' => $account, 'path' => $crumb['path']]) }}" class="text-slate-600 hover:text-indigo-600 transition {{ $loop->last ? 'font-medium text-slate-900' : '' }}">{{ $crumb['name'] }}</a>
@endforeach
</div>
{{-- File Table --}}
<div class="rounded-xl border border-slate-200 bg-white overflow-hidden">
<table class="min-w-full divide-y divide-slate-200">
<thead class="bg-slate-50">
<tr>
<th class="w-10 px-4 py-3"><input type="checkbox" @change="toggleAll($event)" :checked="selected.length === allPaths.length && allPaths.length > 0" class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"></th>
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Name</th>
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Size</th>
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Modified</th>
<th class="px-4 py-3 text-right text-xs font-medium text-slate-500 uppercase">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200">
@forelse($files as $file)
<tr class="hover:bg-slate-50 transition" :class="{ 'bg-indigo-50/50': selected.includes('{{ $file['path'] }}') }">
<td class="w-10 px-4 py-3"><input type="checkbox" value="{{ $file['path'] }}" x-model="selected" class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"></td>
<td class="px-4 py-3 whitespace-nowrap">
<div class="flex items-center gap-3">
@if($file['type'] === 'folder')
<svg class="h-5 w-5 text-amber-500 shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M19.5 21a3 3 0 003-3v-4.5a3 3 0 00-3-3h-15a3 3 0 00-3 3V18a3 3 0 003 3h15zM1.5 10.146V6a3 3 0 013-3h5.379a2.25 2.25 0 011.59.659l2.122 2.121c.14.141.331.22.53.22H19.5a3 3 0 013 3v1.146A4.483 4.483 0 0019.5 9h-15a4.483 4.483 0 00-3 1.146z"/></svg>
<a href="{{ route('hosting.panel.files', ['account' => $account, 'path' => $file['path']]) }}" class="text-sm font-medium text-slate-900 hover:text-indigo-600">{{ $file['name'] }}</a>
@else
<svg class="h-5 w-5 text-slate-400 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/></svg>
<span class="text-sm text-slate-900">{{ $file['name'] }}</span>
@endif
</div>
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-slate-500">{{ $file['size_formatted'] }}</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-slate-500">{{ $file['modified'] }}</td>
<td class="px-4 py-3 whitespace-nowrap text-right">
<div class="flex items-center justify-end gap-1">
@if($file['type'] === 'file')
@if(in_array(strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)), ['zip', 'tar', 'tgz']) || str_ends_with(strtolower($file['name']), '.tar.gz') || str_ends_with(strtolower($file['name']), '.tar.bz2'))
<button @click="extractFile('{{ $file['path'] }}', '{{ $file['name'] }}')" class="rounded px-2 py-1 text-xs font-medium text-emerald-700 ring-1 ring-emerald-300 hover:bg-emerald-50 transition" title="Unzip / Extract archive">
Unzip
</button>
@endif
<button @click="editFile('{{ $file['path'] }}')" class="p-1.5 text-slate-400 hover:text-indigo-600 transition" title="Edit">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931z"/></svg>
</button>
@endif
<button @click="renameFile('{{ $file['path'] }}', '{{ $file['name'] }}')" class="p-1.5 text-slate-400 hover:text-amber-600 transition" title="Rename">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487z"/></svg>
</button>
<button @click="deleteFile('{{ $file['path'] }}')" class="p-1.5 text-slate-400 hover:text-red-600 transition" title="Delete">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/></svg>
</button>
</div>
</td>
</tr>
@empty
<tr><td colspan="5" class="px-6 py-12 text-center text-sm text-slate-500">This folder is empty</td></tr>
@endforelse
</tbody>
</table>
</div>
{{-- Create Modal --}}
<template x-teleport="body">
<div x-show="createModal" x-cloak class="fixed inset-0 z-50 overflow-y-auto bg-black/50 sm:flex sm:items-center sm:justify-center sm:p-4" @click.self="createModal = false">
<div class="min-h-full sm:min-h-0 w-full sm:max-w-md sm:rounded-2xl bg-white p-6 shadow-xl">
<h3 class="text-lg font-semibold text-slate-900" x-text="'Create New ' + (createType === 'folder' ? 'Folder' : 'File')"></h3>
<form @submit.prevent="submitCreate">
<input type="text" x-model="createName" placeholder="Enter name..." class="mt-4 w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-indigo-500" required>
<div class="mt-4 flex justify-end gap-3">
<button type="button" @click="createModal = false" class="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</button>
<button type="submit" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700" :disabled="loading">Create</button>
</div>
</form>
</div>
</div>
</template>
{{-- Edit Modal --}}
<template x-teleport="body">
<div x-show="editModal" x-cloak class="fixed inset-0 z-50 overflow-y-auto bg-black/50 sm:flex sm:items-center sm:justify-center sm:p-4">
<div class="min-h-full sm:min-h-0 w-full sm:max-w-4xl sm:h-[80vh] h-full sm:rounded-2xl bg-white shadow-xl flex flex-col">
<div class="flex items-center justify-between border-b border-slate-200 px-6 py-4">
<h3 class="text-lg font-semibold text-slate-900" x-text="'Editing: ' + editPath"></h3>
<button @click="editModal = false" class="p-1 text-slate-400 hover:text-slate-600"><svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg></button>
</div>
<div class="flex-1 p-4"><textarea x-model="editContent" class="w-full h-full font-mono text-sm border border-slate-200 rounded-lg p-4 focus:border-indigo-500 focus:ring-indigo-500 resize-none"></textarea></div>
<div class="flex justify-end gap-3 border-t border-slate-200 px-6 py-4">
<button @click="editModal = false" class="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</button>
<button @click="saveEditedFile" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700" :disabled="loading">Save</button>
</div>
</div>
</div>
</template>
{{-- Rename Modal --}}
<template x-teleport="body">
<div x-show="renameModal" x-cloak class="fixed inset-0 z-50 overflow-y-auto bg-black/50 sm:flex sm:items-center sm:justify-center sm:p-4" @click.self="renameModal = false">
<div class="min-h-full sm:min-h-0 w-full sm:max-w-md sm:rounded-2xl bg-white p-6 shadow-xl">
<h3 class="text-lg font-semibold text-slate-900">Rename</h3>
<form @submit.prevent="submitRename">
<input type="text" x-model="renameName" class="mt-4 w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-indigo-500" required>
<div class="mt-4 flex justify-end gap-3">
<button type="button" @click="renameModal = false" class="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</button>
<button type="submit" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700" :disabled="loading">Rename</button>
</div>
</form>
</div>
</div>
</template>
{{-- Move/Copy Modal --}}
<template x-teleport="body">
<div x-show="moveModal" x-cloak class="fixed inset-0 z-50 overflow-y-auto bg-black/50 sm:flex sm:items-center sm:justify-center sm:p-4" @click.self="moveModal = false">
<div class="min-h-full sm:min-h-0 w-full sm:max-w-md sm:rounded-2xl bg-white p-6 shadow-xl">
<h3 class="text-lg font-semibold text-slate-900" x-text="moveAction === 'move' ? 'Move Items' : 'Copy Items'"></h3>
<p class="mt-1 text-sm text-slate-500" x-text="selected.length + ' item(s) selected'"></p>
<form @submit.prevent="submitMoveCopy">
<label class="mt-4 block text-sm font-medium text-slate-700">Destination path</label>
<input type="text" x-model="moveDestination" placeholder="/public_html" class="mt-1 w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm font-mono focus:border-indigo-500 focus:ring-indigo-500" required>
<p class="mt-1 text-xs text-slate-400">Relative to home directory, e.g. /public_html/backup</p>
<div class="mt-4 flex justify-end gap-3">
<button type="button" @click="moveModal = false" class="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</button>
<button type="submit" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700" :disabled="loading" x-text="moveAction === 'move' ? 'Move' : 'Copy'"></button>
</div>
</form>
</div>
</div>
</template>
{{-- Compress Modal --}}
<template x-teleport="body">
<div x-show="compressModal" x-cloak class="fixed inset-0 z-50 overflow-y-auto bg-black/50 sm:flex sm:items-center sm:justify-center sm:p-4" @click.self="compressModal = false">
<div class="min-h-full sm:min-h-0 w-full sm:max-w-md sm:rounded-2xl bg-white p-6 shadow-xl">
<h3 class="text-lg font-semibold text-slate-900">Compress to Zip</h3>
<p class="mt-1 text-sm text-slate-500" x-text="selected.length + ' item(s) selected'"></p>
<form @submit.prevent="submitCompress">
<label class="mt-4 block text-sm font-medium text-slate-700">Archive name</label>
<input type="text" x-model="archiveName" placeholder="archive.zip" class="mt-1 w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-indigo-500" required>
<div class="mt-4 flex justify-end gap-3">
<button type="button" @click="compressModal = false" class="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</button>
<button type="submit" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700" :disabled="loading">Compress</button>
</div>
</form>
</div>
</div>
</template>
{{-- Chmod Modal --}}
<template x-teleport="body">
<div x-show="chmodModal" x-cloak class="fixed inset-0 z-50 overflow-y-auto bg-black/50 sm:flex sm:items-center sm:justify-center sm:p-4" @click.self="chmodModal = false">
<div class="min-h-full sm:min-h-0 w-full sm:max-w-md sm:rounded-2xl bg-white p-6 shadow-xl">
<h3 class="text-lg font-semibold text-slate-900">Change Permissions</h3>
<p class="mt-1 text-sm text-slate-500" x-text="selected.length + ' item(s) selected'"></p>
<form @submit.prevent="submitChmod">
<label class="mt-4 block text-sm font-medium text-slate-700">Permissions (octal)</label>
<input type="text" x-model="chmodValue" placeholder="755" maxlength="4" class="mt-1 w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm font-mono focus:border-indigo-500 focus:ring-indigo-500" required>
<div class="mt-2 flex flex-wrap gap-2">
<button type="button" @click="chmodValue = '644'" class="rounded-md bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600 hover:bg-slate-200">644</button>
<button type="button" @click="chmodValue = '755'" class="rounded-md bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600 hover:bg-slate-200">755</button>
<button type="button" @click="chmodValue = '775'" class="rounded-md bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600 hover:bg-slate-200">775</button>
<button type="button" @click="chmodValue = '777'" class="rounded-md bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600 hover:bg-slate-200">777</button>
<button type="button" @click="chmodValue = '600'" class="rounded-md bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600 hover:bg-slate-200">600</button>
</div>
<div class="mt-4 flex justify-end gap-3">
<button type="button" @click="chmodModal = false" class="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</button>
<button type="submit" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700" :disabled="loading">Apply</button>
</div>
</form>
</div>
</div>
</template>
</div>
@push('scripts')
<script>
function fileManager() {
return {
loading: false,
selected: [],
allPaths: @json(collect($files)->pluck('path')->values()),
toasts: [],
toastId: 0,
actionStatus: { type: '', title: '', message: '' },
// Create
createModal: false,
createType: 'folder',
createName: '',
// Edit
editModal: false,
editPath: '',
editContent: '',
// Rename
renameModal: false,
renamePath: '',
renameName: '',
// Upload
uploadProgress: 0,
uploadFileName: '',
uploading: false,
// Move/Copy
moveModal: false,
moveAction: 'move',
moveDestination: '',
// Compress
compressModal: false,
archiveName: 'archive.zip',
// Chmod
chmodModal: false,
chmodValue: '755',
// Extract
extracting: false,
extractFileName: '',
init() {
const stored = sessionStorage.getItem('fileManagerToast');
if (!stored) return;
sessionStorage.removeItem('fileManagerToast');
try {
const toast = JSON.parse(stored);
this.notify(toast.type || 'success', toast.title || 'Done', toast.message || 'Action completed.');
} catch (e) {
this.notify('success', 'Done', stored);
}
},
notify(type, title, message, options = {}) {
const toast = {
id: ++this.toastId,
type,
title,
message,
visible: true,
};
this.toasts.push(toast);
this.actionStatus = { type, title, message };
window.setTimeout(() => this.dismissToast(toast.id), options.duration || 4500);
},
dismissToast(id) {
const toast = this.toasts.find(item => item.id === id);
if (toast) toast.visible = false;
window.setTimeout(() => {
this.toasts = this.toasts.filter(item => item.id !== id);
}, 200);
},
clearStatus() {
this.actionStatus = { type: '', title: '', message: '' };
},
rememberToast(type, title, message) {
sessionStorage.setItem('fileManagerToast', JSON.stringify({ type, title, message }));
},
reloadWithToast(type, title, message) {
this.rememberToast(type, title, message);
location.reload();
},
async parseJsonResponse(res, fallbackMessage) {
let data = {};
try {
data = await res.json();
} catch (e) {
data = {};
}
if (!res.ok || data.success === false) {
throw new Error(data.error || data.message || fallbackMessage || 'Operation failed');
}
return data;
},
errorMessage(error, fallback) {
return error?.message || fallback || 'Operation failed';
},
toggleAll(event) {
this.selected = event.target.checked ? [...this.allPaths] : [];
},
bulkAction(action) {
if (action === 'delete') {
if (!confirm('Delete ' + this.selected.length + ' item(s)? This cannot be undone.')) return;
this.submitBulkDelete();
} else if (action === 'move' || action === 'copy') {
this.moveAction = action;
this.moveDestination = '{{ $currentPath }}';
this.moveModal = true;
} else if (action === 'compress') {
this.archiveName = 'archive.zip';
this.compressModal = true;
} else if (action === 'chmod') {
this.chmodValue = '755';
this.chmodModal = true;
}
},
async submitBulkDelete() {
this.loading = true;
try {
const res = await fetch('{{ route("hosting.panel.files.bulk-delete", $account) }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({ paths: this.selected })
});
const data = await this.parseJsonResponse(res, 'Delete failed');
this.reloadWithToast('success', 'Items deleted', data.message || 'Selected items were deleted.');
} catch (e) {
this.notify('error', 'Delete failed', this.errorMessage(e, 'Delete failed'));
} finally { this.loading = false; }
},
async submitMoveCopy() {
this.loading = true;
const url = this.moveAction === 'move'
? '{{ route("hosting.panel.files.bulk-move", $account) }}'
: '{{ route("hosting.panel.files.bulk-copy", $account) }}';
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({ paths: this.selected, destination: this.moveDestination })
});
const data = await this.parseJsonResponse(res, 'Operation failed');
this.reloadWithToast('success', this.moveAction === 'move' ? 'Items moved' : 'Items copied', data.message || 'Selected items were updated.');
} catch (e) {
this.notify('error', 'Operation failed', this.errorMessage(e, 'Operation failed'));
} finally { this.loading = false; this.moveModal = false; }
},
async submitCompress() {
this.loading = true;
try {
const res = await fetch('{{ route("hosting.panel.files.bulk-compress", $account) }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({ paths: this.selected, archive_name: this.archiveName, base_path: '{{ $currentPath }}' })
});
const data = await this.parseJsonResponse(res, 'Compression failed');
this.reloadWithToast('success', 'Archive created', data.message || 'Selected items were compressed.');
} catch (e) {
this.notify('error', 'Compression failed', this.errorMessage(e, 'Compression failed'));
} finally { this.loading = false; this.compressModal = false; }
},
async submitChmod() {
if (!/^[0-7]{3,4}$/.test(this.chmodValue)) {
this.notify('error', 'Invalid permissions', 'Use 3 or 4 octal digits, for example 755.');
return;
}
this.loading = true;
try {
const res = await fetch('{{ route("hosting.panel.files.chmod", $account) }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({ paths: this.selected, permissions: this.chmodValue })
});
const data = await this.parseJsonResponse(res, 'Failed to change permissions');
this.chmodModal = false;
this.selected = [];
this.notify('success', 'Permissions updated', data.message || 'Selected permissions were updated.');
} catch (e) {
this.notify('error', 'Permission update failed', this.errorMessage(e, 'Failed to change permissions'));
} finally { this.loading = false; }
},
async submitCreate() {
this.loading = true;
try {
const res = await fetch('{{ route("hosting.panel.files.create", $account) }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({ path: '{{ $currentPath }}', name: this.createName, type: this.createType })
});
const data = await this.parseJsonResponse(res, 'Failed to create');
const label = this.createType === 'folder' ? 'Folder created' : 'File created';
this.reloadWithToast('success', label, data.message || `${this.createName} was created.`);
} catch (e) {
this.notify('error', 'Create failed', this.errorMessage(e, 'Failed to create'));
} finally { this.loading = false; }
},
async editFile(path) {
this.loading = true;
try {
const res = await fetch('{{ route("hosting.panel.files.read", $account) }}?path=' + encodeURIComponent(path));
const data = await this.parseJsonResponse(res, 'Failed to read file');
this.editPath = path;
this.editContent = data.content;
this.editModal = true;
} catch (e) {
this.notify('error', 'File could not be opened', this.errorMessage(e, 'Failed to read file'));
} finally { this.loading = false; }
},
async saveEditedFile() {
this.loading = true;
try {
const res = await fetch('{{ route("hosting.panel.files.save", $account) }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({ path: this.editPath, content: this.editContent })
});
const data = await this.parseJsonResponse(res, 'Failed to save');
this.editModal = false;
this.notify('success', 'File saved', data.message || `${this.editPath} was saved.`);
} catch (e) {
this.notify('error', 'Save failed', this.errorMessage(e, 'Failed to save'));
} finally { this.loading = false; }
},
renameFile(path, name) { this.renamePath = path; this.renameName = name; this.renameModal = true; },
async submitRename() {
this.loading = true;
try {
const res = await fetch('{{ route("hosting.panel.files.rename", $account) }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({ path: this.renamePath, newName: this.renameName })
});
const data = await this.parseJsonResponse(res, 'Failed to rename');
this.reloadWithToast('success', 'Item renamed', data.message || `${this.renameName} was renamed.`);
} catch (e) {
this.notify('error', 'Rename failed', this.errorMessage(e, 'Failed to rename'));
} finally { this.loading = false; }
},
async deleteFile(path) {
if (!confirm('Are you sure you want to delete this?')) return;
this.loading = true;
try {
const res = await fetch('{{ route("hosting.panel.files.delete", $account) }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({ path })
});
const data = await this.parseJsonResponse(res, 'Failed to delete');
this.reloadWithToast('success', 'Item deleted', data.message || 'The item was deleted.');
} catch (e) {
this.notify('error', 'Delete failed', this.errorMessage(e, 'Failed to delete'));
} finally { this.loading = false; }
},
async extractFile(path, name) {
if (!confirm('Extract "' + name + '" in the current directory?\n\nThis may take a while for large archives.')) return;
this.loading = true;
this.extracting = true;
this.extractFileName = name;
try {
const res = await fetch('{{ route("hosting.panel.files.extract", $account) }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({ path })
});
const data = await this.parseJsonResponse(res, 'Failed to extract');
const msg = data.total_files
? `Extracted ${data.extracted_files || data.total_files} files successfully.`
: (data.message || 'Archive extracted successfully.');
this.reloadWithToast('success', 'Archive extracted', msg);
} catch (e) {
this.notify('error', 'Extract failed', this.errorMessage(e, 'Failed to extract'));
}
finally {
this.loading = false;
this.extracting = false;
this.extractFileName = '';
}
},
async uploadFile(event) {
const files = event.target.files;
if (!files.length) return;
this.loading = true;
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB chunks
const CHUNKED_THRESHOLD = 5 * 1024 * 1024; // Use chunked upload for files > 5MB
let completedUploads = 0;
let failedUploads = 0;
for (const file of files) {
this.uploading = true;
this.uploadFileName = file.name;
this.uploadProgress = 0;
try {
if (file.size > CHUNKED_THRESHOLD) {
// Chunked upload for large files
await this.uploadFileChunked(file, CHUNK_SIZE);
} else {
// Regular upload for small files
await this.uploadFileRegular(file);
}
completedUploads++;
} catch (e) {
failedUploads++;
console.error('Upload error:', e);
this.notify('error', 'Upload failed', e.message || 'Unknown error');
}
}
this.uploading = false;
this.loading = false;
event.target.value = '';
if (completedUploads > 0) {
const type = failedUploads > 0 ? 'info' : 'success';
const title = failedUploads > 0 ? 'Upload partially complete' : 'Upload complete';
const message = failedUploads > 0
? `${completedUploads} file(s) uploaded, ${failedUploads} failed.`
: (files.length === 1 ? `${files[0].name} was uploaded.` : `${files.length} files were uploaded.`);
this.reloadWithToast(type, title, message);
} else {
this.notify('error', 'Upload failed', 'No files were uploaded.');
}
},
async uploadFileRegular(file) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const formData = new FormData();
formData.append('file', file);
formData.append('path', '{{ $currentPath }}');
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) this.uploadProgress = Math.round((e.loaded / e.total) * 100);
});
xhr.addEventListener('load', () => {
try {
const data = JSON.parse(xhr.responseText);
if (!data.success) {
reject(new Error(data.error || 'Failed to upload ' + file.name));
return;
}
} catch (e) {
if (xhr.status !== 200) {
reject(new Error('Upload failed (HTTP ' + xhr.status + ')'));
return;
}
reject(new Error('Upload failed: invalid server response'));
return;
}
resolve();
});
xhr.addEventListener('error', () => { reject(new Error('Network error')); });
xhr.addEventListener('abort', () => { reject(new Error('Upload cancelled')); });
xhr.open('POST', '{{ route("hosting.panel.files.upload", $account) }}');
xhr.setRequestHeader('X-CSRF-TOKEN', '{{ csrf_token() }}');
xhr.send(formData);
});
},
async uploadFileChunked(file, chunkSize) {
const totalChunks = Math.ceil(file.size / chunkSize);
const maxRetries = 3;
// Step 1: Initialize upload session
const initRes = await fetch('{{ route("hosting.panel.files.upload.init", $account) }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({
filename: file.name,
filesize: file.size,
path: '{{ $currentPath }}'
})
});
const initData = await initRes.json();
if (!initData.success) throw new Error(initData.error || 'Failed to initialize upload');
const uploadId = initData.upload_id;
// Step 2: Upload chunks with retry logic
for (let i = 0; i < totalChunks; i++) {
const start = i * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
const expectedSize = end - start;
let success = false;
let lastError = null;
for (let attempt = 0; attempt < maxRetries && !success; attempt++) {
try {
const formData = new FormData();
formData.append('upload_id', uploadId);
formData.append('chunk_index', i);
formData.append('chunk_size', expectedSize);
formData.append('chunk', chunk, `chunk_${i}`);
const chunkRes = await fetch('{{ route("hosting.panel.files.upload.chunk", $account) }}', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: formData
});
if (!chunkRes.ok) {
throw new Error(`HTTP ${chunkRes.status}`);
}
const chunkData = await chunkRes.json();
if (!chunkData.success) {
throw new Error(chunkData.error || 'Upload failed');
}
success = true;
} catch (e) {
lastError = e;
if (attempt < maxRetries - 1) {
await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
}
}
}
if (!success) {
throw new Error(`Failed to upload chunk ${i + 1}/${totalChunks} after ${maxRetries} attempts: ${lastError?.message}`);
}
// Update progress
this.uploadProgress = Math.round(((i + 1) / totalChunks) * 100);
}
// Step 3: Finalize upload
const finalRes = await fetch('{{ route("hosting.panel.files.upload.finalize", $account) }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({
upload_id: uploadId,
total_chunks: totalChunks
})
});
const finalData = await finalRes.json();
if (!finalData.success) throw new Error(finalData.error || 'Failed to finalize upload');
}
};
}
</script>
@endpush
</x-hosting-panel-layout>
@@ -0,0 +1,156 @@
<x-hosting-panel-layout :account="$account">
<x-slot name="title">Hosting Panel - {{ $account->username }}</x-slot>
<x-slot name="header">Overview - {{ $account->username }}</x-slot>
<div class="space-y-6">
@php
$sslProvisioningSites = $account->sites->filter(fn($site) => $site->ssl_status === 'provisioning');
@endphp
@if($sslProvisioningSites->isNotEmpty())
<div class="rounded-xl border border-amber-200 bg-amber-50 p-4">
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-amber-500 animate-spin" 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>
</div>
<div>
<h3 class="text-sm font-medium text-amber-800">SSL Certificate Provisioning</h3>
<p class="mt-1 text-sm text-amber-700">
SSL certificates are being provisioned for
<strong>{{ $sslProvisioningSites->pluck('domain')->join(', ') }}</strong>.
Please wait until this completes before installing applications. This usually takes 1-2 minutes.
</p>
</div>
</div>
</div>
@endif
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div class="rounded-xl border border-slate-200 bg-white p-5">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
<svg class="h-5 w-5 text-blue-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4"/></svg>
</div>
<div>
<p class="text-xs font-medium text-slate-500">Disk Usage</p>
<p class="text-lg font-semibold text-slate-900">{{ number_format(($stats['disk_used_bytes'] ?? 0) / 1048576, 2) }} MB</p>
</div>
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-5">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-100">
<svg class="h-5 w-5 text-emerald-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776"/></svg>
</div>
<div>
<p class="text-xs font-medium text-slate-500">Files</p>
<p class="text-lg font-semibold text-slate-900">{{ number_format(($stats['file_size_bytes'] ?? 0) / 1048576, 2) }} MB</p>
</div>
</div>
</div>
@can('manageDatabases', $account)
<div class="rounded-xl border border-slate-200 bg-white p-5">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-100">
<svg class="h-5 w-5 text-purple-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375"/></svg>
</div>
<div>
<p class="text-xs font-medium text-slate-500">Databases</p>
<p class="text-lg font-semibold text-slate-900">{{ $account->databases->count() }}</p>
</div>
</div>
</div>
@endcan
<div class="rounded-xl border border-slate-200 bg-white p-5">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-100 text-amber-600">
@include('components.icons.domain-globe', ['class' => 'h-5 w-5'])
</div>
<div>
<p class="text-xs font-medium text-slate-500">Domains</p>
<p class="text-lg font-semibold text-slate-900">{{ $account->sites->count() }}</p>
</div>
</div>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
<a href="{{ route('hosting.panel.files', $account) }}" class="group rounded-xl border border-slate-200 bg-white p-6 hover:border-indigo-300 hover:shadow-md transition">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-indigo-100 group-hover:bg-indigo-200 transition">
<svg class="h-6 w-6 text-indigo-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776"/></svg>
</div>
<h3 class="mt-4 text-base font-semibold text-slate-900">File Manager</h3>
<p class="mt-1 text-sm text-slate-500">Upload, edit, and manage your files</p>
</a>
@can('manageDatabases', $account)
<a href="{{ route('hosting.panel.databases', $account) }}" class="group rounded-xl border border-slate-200 bg-white p-6 hover:border-purple-300 hover:shadow-md transition">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-100 group-hover:bg-purple-200 transition">
<svg class="h-6 w-6 text-purple-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375"/></svg>
</div>
<h3 class="mt-4 text-base font-semibold text-slate-900">Databases</h3>
<p class="mt-1 text-sm text-slate-500">Create and manage MySQL databases</p>
</a>
@endcan
<a href="{{ route('hosting.panel.domains', $account) }}" class="group rounded-xl border border-slate-200 bg-white p-6 hover:border-amber-300 hover:shadow-md transition">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-amber-100 group-hover:bg-amber-200 transition">
@include('components.icons.domain-globe', ['class' => 'h-6 w-6 text-amber-600'])
</div>
<h3 class="mt-4 text-base font-semibold text-slate-900">Domains</h3>
<p class="mt-1 text-sm text-slate-500">{{ auth()->user()->can('manageDomains', $account) ? 'Add and manage addon domains' : 'Review linked domains' }}</p>
</a>
@can('useTerminal', $account)
<a href="{{ route('hosting.panel.terminal', $account) }}" class="group rounded-xl border border-slate-200 bg-white p-6 hover:border-emerald-300 hover:shadow-md transition">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-100 group-hover:bg-emerald-200 transition">
<svg class="h-6 w-6 text-emerald-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 7.5 10.5 12l-3.75 4.5m6-9h4.5" /></svg>
</div>
<h3 class="mt-4 text-base font-semibold text-slate-900">Terminal</h3>
<p class="mt-1 text-sm text-slate-500">Run app commands inside your account sandbox</p>
</a>
@endcan
@can('viewSettings', $account)
<a href="{{ route('hosting.panel.settings', $account) }}" class="group rounded-xl border border-slate-200 bg-white p-6 hover:border-slate-400 hover:shadow-md transition">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-slate-100 group-hover:bg-slate-200 transition">
<svg class="h-6 w-6 text-slate-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
</div>
<h3 class="mt-4 text-base font-semibold text-slate-900">Settings</h3>
<p class="mt-1 text-sm text-slate-500">{{ auth()->user()->can('changePassword', $account) ? 'SFTP password and team access settings' : 'SFTP connection details and your SSH key' }}</p>
</a>
@endcan
</div>
<!-- SFTP Info -->
<div class="rounded-xl border border-slate-200 bg-white p-6">
<h3 class="text-base font-semibold text-slate-900 mb-4">SFTP Access</h3>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div>
<p class="text-xs font-medium text-slate-500">Host</p>
<p class="mt-1 text-sm font-mono text-slate-900">{{ $account->node?->ip_address ?? '161.97.138.149' }}</p>
</div>
<div>
<p class="text-xs font-medium text-slate-500">Port</p>
<p class="mt-1 text-sm font-mono text-slate-900">22</p>
</div>
<div>
<p class="text-xs font-medium text-slate-500">Username</p>
<p class="mt-1 text-sm font-mono text-slate-900">{{ $account->username }}</p>
</div>
<div>
<p class="text-xs font-medium text-slate-500">Password</p>
@can('changePassword', $account)
<p class="mt-1 text-sm text-slate-500">Set in <a href="{{ route('hosting.panel.settings', $account) }}" class="text-indigo-600 hover:underline">Settings</a></p>
@else
<p class="mt-1 text-sm text-slate-500">Use your SSH key in <a href="{{ route('hosting.panel.settings', $account) }}" class="text-indigo-600 hover:underline">Settings</a></p>
@endcan
</div>
</div>
</div>
</div>
</x-hosting-panel-layout>
@@ -0,0 +1,80 @@
<x-hosting-panel-layout :account="$account">
<x-slot name="title">Error Logs - {{ $account->username }}</x-slot>
<x-slot name="header">Error Logs</x-slot>
<div class="space-y-6">
@if(session('success'))
<div class="rounded-lg bg-emerald-50 border border-emerald-200 p-4">
<p class="text-sm text-emerald-800">{{ session('success') }}</p>
</div>
@endif
@if(session('error'))
<div class="rounded-lg bg-red-50 border border-red-200 p-4">
<p class="text-sm text-red-800">{{ session('error') }}</p>
</div>
@endif
<div class="flex flex-wrap items-center gap-4">
<div class="flex rounded-lg border border-slate-200 bg-white p-1">
<a href="{{ route('hosting.panel.logs', ['account' => $account, 'type' => 'error', 'lines' => $lines]) }}"
class="rounded-md px-4 py-2 text-sm font-medium transition {{ $logType === 'error' ? 'bg-indigo-600 text-white' : 'text-slate-600 hover:bg-slate-50' }}">
Error Log
</a>
<a href="{{ route('hosting.panel.logs', ['account' => $account, 'type' => 'access', 'lines' => $lines]) }}"
class="rounded-md px-4 py-2 text-sm font-medium transition {{ $logType === 'access' ? 'bg-indigo-600 text-white' : 'text-slate-600 hover:bg-slate-50' }}">
Access Log
</a>
</div>
<form action="{{ route('hosting.panel.logs', $account) }}" method="GET" class="flex items-center gap-2">
<input type="hidden" name="type" value="{{ $logType }}">
<select name="lines" onchange="this.form.submit()" class="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm focus:border-indigo-500 focus:ring-indigo-500">
<option value="50" {{ $lines == 50 ? 'selected' : '' }}>Last 50 lines</option>
<option value="100" {{ $lines == 100 ? 'selected' : '' }}>Last 100 lines</option>
<option value="200" {{ $lines == 200 ? 'selected' : '' }}>Last 200 lines</option>
<option value="500" {{ $lines == 500 ? 'selected' : '' }}>Last 500 lines</option>
</select>
</form>
<form action="{{ route('hosting.panel.logs.clear', $account) }}" method="POST" onsubmit="return confirm('Clear this log file?')" class="ml-auto">
@csrf
<input type="hidden" name="type" value="{{ $logType }}">
<button type="submit" class="inline-flex items-center gap-2 rounded-lg border border-red-200 bg-white px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 transition">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/></svg>
Clear Log
</button>
</form>
</div>
<div class="rounded-xl border border-slate-200 bg-white overflow-hidden">
<div class="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
<div>
<h3 class="text-base font-semibold text-slate-900">{{ $logType === 'error' ? 'Error Log' : 'Access Log' }}</h3>
<p class="mt-1 text-xs text-slate-500">{{ $logFilePath }}</p>
</div>
<a href="{{ route('hosting.panel.logs', ['account' => $account, 'type' => $logType, 'lines' => $lines]) }}" class="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-600 hover:bg-slate-50 transition">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"/></svg>
Refresh
</a>
</div>
<div class="bg-slate-900 p-4 overflow-x-auto max-h-[600px] overflow-y-auto">
<pre class="text-xs font-mono text-slate-300 whitespace-pre-wrap break-all">{{ $logContent }}</pre>
</div>
</div>
<div class="rounded-xl border border-blue-200 bg-blue-50 p-6">
<h3 class="text-base font-semibold text-blue-900 mb-2">Log File Locations</h3>
<div class="grid gap-3 sm:grid-cols-2">
<div>
<p class="text-xs font-medium text-blue-700">Error Log</p>
<code class="text-xs font-mono text-blue-800 bg-blue-100 px-1.5 py-0.5 rounded">{{ $errorLogPath }}</code>
</div>
<div>
<p class="text-xs font-medium text-blue-700">Access Log</p>
<code class="text-xs font-mono text-blue-800 bg-blue-100 px-1.5 py-0.5 rounded">{{ $accessLogPath }}</code>
</div>
</div>
</div>
</div>
</x-hosting-panel-layout>
@@ -0,0 +1,220 @@
@php
$currentRoute = request()->route()->getName();
$panelRuntime = app(\App\Services\Hosting\PanelRuntimeResolver::class)->forSubject($account);
$currentUser = auth()->user();
$navGroups = [
'main' => [
[
'name' => 'Overview',
'route' => route('hosting.panel.index', $account),
'active' => $currentRoute === 'hosting.panel.index',
'capability' => null,
'permission' => 'viewPanel',
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />',
],
[
'name' => 'File Manager',
'route' => route('hosting.panel.files', $account),
'active' => str_starts_with($currentRoute, 'hosting.panel.files'),
'capability' => 'files',
'permission' => 'manageFiles',
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 0 0-1.883 2.542l.857 6a2.25 2.25 0 0 0 2.227 1.932H19.05a2.25 2.25 0 0 0 2.227-1.932l.857-6a2.25 2.25 0 0 0-1.883-2.542m-16.5 0V6A2.25 2.25 0 0 1 6 3.75h3.879a1.5 1.5 0 0 1 1.06.44l2.122 2.12a1.5 1.5 0 0 0 1.06.44H18A2.25 2.25 0 0 1 20.25 9v.776" />',
],
[
'name' => 'Databases',
'route' => route('hosting.panel.databases', $account),
'active' => str_starts_with($currentRoute, 'hosting.panel.databases'),
'capability' => 'databases',
'permission' => 'manageDatabases',
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375" />',
],
[
'name' => 'Domains',
'route' => route('hosting.panel.domains', $account),
'active' => str_starts_with($currentRoute, 'hosting.panel.domains'),
'capability' => 'domains',
'permission' => 'viewDomains',
'domain_icon' => true,
],
],
'software' => [
[
'name' => 'Terminal',
'route' => route('hosting.panel.terminal', $account),
'active' => str_starts_with($currentRoute, 'hosting.panel.terminal'),
'capability' => 'terminal',
'permission' => 'useTerminal',
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 7.5 10.5 12l-3.75 4.5m6-9h4.5" />',
],
[
'name' => 'PHP Settings',
'route' => route('hosting.panel.php', $account),
'active' => str_starts_with($currentRoute, 'hosting.panel.php'),
'capability' => 'php',
'permission' => 'managePhp',
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" />',
],
[
'name' => 'SSL Certificates',
'route' => route('hosting.panel.ssl', $account),
'active' => str_starts_with($currentRoute, 'hosting.panel.ssl'),
'capability' => 'ssl',
'permission' => 'manageSsl',
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />',
],
[
'name' => 'App Installer',
'route' => route('hosting.panel.apps', $account),
'active' => str_starts_with($currentRoute, 'hosting.panel.apps'),
'capability' => 'apps',
'permission' => 'manageApps',
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />',
],
],
'advanced' => [
[
'name' => 'Cron Jobs',
'route' => route('hosting.panel.cron', $account),
'active' => str_starts_with($currentRoute, 'hosting.panel.cron'),
'capability' => 'cron',
'permission' => 'manageCron',
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />',
],
[
'name' => 'Error Logs',
'route' => route('hosting.panel.logs', $account),
'active' => str_starts_with($currentRoute, 'hosting.panel.logs'),
'capability' => 'logs',
'permission' => 'viewLogs',
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />',
],
[
'name' => 'Settings',
'route' => route('hosting.panel.settings', $account),
'active' => str_starts_with($currentRoute, 'hosting.panel.settings'),
'capability' => 'settings',
'permission' => 'viewSettings',
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.43.992a6.759 6.759 0 010 .255c-.008.378.137.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />',
],
],
];
foreach ($navGroups as $group => $items) {
$navGroups[$group] = array_values(array_filter($items, static function (array $item) use ($panelRuntime, $currentUser, $account): bool {
$capability = $item['capability'] ?? null;
$permission = $item['permission'] ?? null;
return ($capability === null || $panelRuntime->supports($capability))
&& ($permission === null || $currentUser?->can($permission, $account));
}));
}
@endphp
<div class="flex h-full flex-col">
{{-- Logo/Header --}}
<div class="flex h-14 items-center gap-3 border-b border-slate-200 px-4">
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-indigo-600">
<svg class="h-4 w-4 text-white" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 0 6h13.5a3 3 0 1 0 0-6m-16.5-3a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3m-19.5 0a4.5 4.5 0 0 1 .9-2.7L5.737 5.1a3.375 3.375 0 0 1 2.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 0 1 .9 2.7m0 0a3 3 0 0 1-3 3m0 3h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Zm-3 6h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Z" />
</svg>
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-slate-900">{{ $panelRuntime->label() }}</p>
<p class="truncate text-xs text-slate-500">{{ $account->username }}</p>
</div>
<button type="button" @click="sidebarOpen = false" class="lg:hidden p-1 text-slate-400 hover:text-slate-600">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
{{-- Navigation --}}
<nav class="flex-1 overflow-y-auto p-3">
{{-- Main Section --}}
<ul class="space-y-1">
@foreach($navGroups['main'] as $item)
<li>
<a href="{{ $item['route'] }}"
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition {{ $item['active'] ? 'bg-indigo-50 text-indigo-700' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}">
@if (! empty($item['domain_icon']))
{!! \App\Support\DomainGlobeIcon::svg('h-5 w-5 shrink-0 ' . ($item['active'] ? 'text-indigo-600' : 'text-slate-400')) !!}
@else
<svg class="h-5 w-5 flex-shrink-0 {{ $item['active'] ? 'text-indigo-600' : 'text-slate-400' }}" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
{!! $item['icon'] !!}
</svg>
@endif
{{ $item['name'] }}
</a>
</li>
@endforeach
</ul>
{{-- Software Section --}}
@if (! empty($navGroups['software']))
<div class="mt-6">
<p class="px-3 text-[10px] font-semibold uppercase tracking-wider text-slate-400">Software</p>
<ul class="mt-2 space-y-1">
@foreach($navGroups['software'] as $item)
<li>
<a href="{{ $item['route'] }}"
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition {{ $item['active'] ? 'bg-indigo-50 text-indigo-700' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}">
@if (! empty($item['domain_icon']))
{!! \App\Support\DomainGlobeIcon::svg('h-5 w-5 shrink-0 ' . ($item['active'] ? 'text-indigo-600' : 'text-slate-400')) !!}
@else
<svg class="h-5 w-5 flex-shrink-0 {{ $item['active'] ? 'text-indigo-600' : 'text-slate-400' }}" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
{!! $item['icon'] !!}
</svg>
@endif
{{ $item['name'] }}
</a>
</li>
@endforeach
</ul>
</div>
@endif
{{-- Advanced Section --}}
@if (! empty($navGroups['advanced']))
<div class="mt-6">
<p class="px-3 text-[10px] font-semibold uppercase tracking-wider text-slate-400">Advanced</p>
<ul class="mt-2 space-y-1">
@foreach($navGroups['advanced'] as $item)
<li>
<a href="{{ $item['route'] }}"
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition {{ $item['active'] ? 'bg-indigo-50 text-indigo-700' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}">
@if (! empty($item['domain_icon']))
{!! \App\Support\DomainGlobeIcon::svg('h-5 w-5 shrink-0 ' . ($item['active'] ? 'text-indigo-600' : 'text-slate-400')) !!}
@else
<svg class="h-5 w-5 flex-shrink-0 {{ $item['active'] ? 'text-indigo-600' : 'text-slate-400' }}" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
{!! $item['icon'] !!}
</svg>
@endif
{{ $item['name'] }}
</a>
</li>
@endforeach
</ul>
</div>
@endif
</nav>
{{-- Account Info Footer --}}
<div class="border-t border-slate-200 p-3">
<div class="rounded-lg bg-slate-50 p-3">
<div class="flex items-center gap-2 text-xs text-slate-500">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 17.25v-.228a4.5 4.5 0 0 0-.12-1.03l-2.268-9.64a3.375 3.375 0 0 0-3.285-2.602H7.923a3.375 3.375 0 0 0-3.285 2.602l-2.268 9.64a4.5 4.5 0 0 0-.12 1.03v.228m19.5 0a3 3 0 0 1-3 3H5.25a3 3 0 0 1-3-3m19.5 0a3 3 0 0 0-3-3H5.25a3 3 0 0 0-3 3m16.5 0h.008v.008h-.008v-.008Zm-3 0h.008v.008h-.008v-.008Z" />
</svg>
<span>{{ $account->node->hostname ?? $account->node->ip_address ?? 'Server' }}</span>
</div>
<div class="mt-2 flex items-center gap-2">
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium {{ $account->status === 'active' ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-200 text-slate-600' }}">
{{ ucfirst($account->status) }}
</span>
@if($account->product)
<span class="text-[10px] text-slate-400">{{ $account->product->name }}</span>
@endif
</div>
</div>
</div>
</div>
@@ -0,0 +1,91 @@
<x-hosting-panel-layout :account="$account">
<x-slot name="title">PHP Settings - {{ $account->username }}</x-slot>
<x-slot name="header">PHP Settings</x-slot>
<div class="space-y-6">
@if(session('success'))
<div class="rounded-lg bg-emerald-50 border border-emerald-200 p-4">
<p class="text-sm text-emerald-800">{{ session('success') }}</p>
</div>
@endif
@if(session('error'))
<div class="rounded-lg bg-red-50 border border-red-200 p-4">
<p class="text-sm text-red-800">{{ session('error') }}</p>
</div>
@endif
<div class="rounded-xl border border-slate-200 bg-white p-6">
<h3 class="text-base font-semibold text-slate-900 mb-4">PHP Version</h3>
<p class="text-sm text-slate-600 mb-4">Select the PHP version for your hosting account. This will apply to all your domains.</p>
<form action="{{ route('hosting.panel.php.version', $account) }}" method="POST" class="space-y-4">
@csrf
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3">
@foreach($availableVersions as $version)
<label for="php_version_{{ str_replace('.', '_', $version) }}" class="relative flex cursor-pointer rounded-lg border p-4 focus:outline-none {{ $currentVersion === $version ? 'border-indigo-600 bg-indigo-50 ring-2 ring-indigo-600' : 'border-slate-200 bg-white hover:bg-slate-50' }}">
<input id="php_version_{{ str_replace('.', '_', $version) }}" type="radio" name="php_version" value="{{ $version }}" class="mt-1 h-4 w-4 border-slate-300 text-indigo-600 focus:ring-indigo-500" {{ $currentVersion === $version ? 'checked' : '' }}>
<div class="ml-3 flex w-full flex-col items-center">
<svg class="h-8 w-8 {{ $currentVersion === $version ? 'text-indigo-600' : 'text-slate-400' }}" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
</svg>
<span class="mt-2 text-sm font-semibold {{ $currentVersion === $version ? 'text-indigo-900' : 'text-slate-700' }}">PHP {{ $version }}</span>
@if($currentVersion === $version)
<span class="mt-1 text-xs text-indigo-600">Current</span>
@endif
</div>
</label>
@endforeach
</div>
<button type="submit" class="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-indigo-700 transition">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg>
Change PHP Version
</button>
</form>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-6">
<h3 class="text-base font-semibold text-slate-900 mb-4">PHP Configuration</h3>
<p class="text-sm text-slate-600 mb-4">Customize PHP settings for your hosting account. These settings are saved to your .user.ini file.</p>
<form action="{{ route('hosting.panel.php.settings', $account) }}" method="POST" class="space-y-6">
@csrf
<div class="grid gap-6 sm:grid-cols-2">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Upload Max Filesize (MB)</label>
<input type="number" name="upload_max_filesize" value="{{ $phpSettings['upload_max_filesize'] }}" min="1" max="512" class="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-indigo-500">
<p class="mt-1 text-xs text-slate-500">Maximum size for file uploads (1-512 MB)</p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Post Max Size (MB)</label>
<input type="number" name="post_max_size" value="{{ $phpSettings['post_max_size'] }}" min="1" max="512" class="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-indigo-500">
<p class="mt-1 text-xs text-slate-500">Maximum size for POST data (1-512 MB)</p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Memory Limit (MB)</label>
<input type="number" name="memory_limit" value="{{ $phpSettings['memory_limit'] }}" min="32" max="1024" class="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-indigo-500">
<p class="mt-1 text-xs text-slate-500">Maximum memory per script (32-1024 MB)</p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Max Execution Time (seconds)</label>
<input type="number" name="max_execution_time" value="{{ $phpSettings['max_execution_time'] }}" min="30" max="600" class="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-indigo-500">
<p class="mt-1 text-xs text-slate-500">Maximum script execution time (30-600 seconds)</p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Max Input Variables</label>
<input type="number" name="max_input_vars" value="{{ $phpSettings['max_input_vars'] }}" min="1000" max="10000" class="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-indigo-500">
<p class="mt-1 text-xs text-slate-500">Maximum number of input variables (1000-10000)</p>
</div>
</div>
<button type="submit" class="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-indigo-700 transition">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg>
Save PHP Settings
</button>
</form>
</div>
<div class="rounded-xl border border-amber-200 bg-amber-50 p-6">
<h3 class="text-base font-semibold text-amber-900 mb-2">Custom php.ini</h3>
<p class="text-sm text-amber-800 mb-2">You can also edit your .user.ini file directly via the File Manager for additional PHP settings.</p>
<p class="text-sm text-amber-800">Location: <code class="bg-amber-100 px-1.5 py-0.5 rounded text-xs font-mono">/public_html/.user.ini</code></p>
</div>
</div>
</x-hosting-panel-layout>
@@ -0,0 +1,160 @@
<x-hosting-panel-layout :account="$account">
<x-slot name="title">Settings - {{ $account->username }}</x-slot>
<x-slot name="header">Settings</x-slot>
@php
$serverIp = $account->node?->ip_address ?? '161.97.138.149';
$sftpCommand = "sftp {$account->username}@{$serverIp}";
@endphp
<div class="space-y-6">
@if(session('success'))
<div class="rounded-lg border border-emerald-200 bg-emerald-50 p-4">
<p class="text-sm text-emerald-800">{{ session('success') }}</p>
</div>
@endif
@if(session('error'))
<div class="rounded-lg border border-red-200 bg-red-50 p-4">
<p class="text-sm text-red-800">{{ session('error') }}</p>
</div>
@endif
@if($canChangePassword)
<div class="rounded-xl border border-slate-200 bg-white p-6">
<h3 class="mb-4 text-base font-semibold text-slate-900">SFTP Password</h3>
<p class="mb-4 text-sm text-slate-600">Change the shared SFTP password for this hosting account. Team developers do not see or manage this password.</p>
<form action="{{ route('hosting.panel.settings.password', $account) }}" method="POST" class="max-w-md space-y-4">
@csrf
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">New Password</label>
<input type="password" name="password" required minlength="8" class="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-indigo-500">
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">Confirm Password</label>
<input type="password" name="password_confirmation" required minlength="8" class="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-indigo-500">
</div>
<button type="submit" class="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-indigo-700">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"/></svg>
Change Password
</button>
</form>
</div>
@endif
<div class="rounded-xl border border-slate-200 bg-white p-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 class="text-base font-semibold text-slate-900">SFTP Key Access</h3>
@if ($teamMembership)
<p class="mt-2 text-sm text-slate-600">Add your SSH public key to connect over SFTP as <span class="font-mono text-slate-900">{{ $account->username }}</span>. Removing you from the team automatically removes this key.</p>
@else
<p class="mt-2 text-sm text-slate-600">Owners connect with the shared account password. Team developers add their own SSH public key here after they sign in.</p>
@endif
</div>
@if ($teamMembership?->ssh_key_installed_at)
<span class="inline-flex items-center rounded-full bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-700">
Key active
</span>
@endif
</div>
@if ($teamMembership)
<form action="{{ route('hosting.panel.settings.ssh-key', $account) }}" method="POST" class="mt-5 space-y-4">
@csrf
<div>
<label for="ssh_public_key" class="mb-1 block text-sm font-medium text-slate-700">SSH Public Key</label>
<textarea id="ssh_public_key" name="ssh_public_key" rows="5" required class="w-full rounded-lg border border-slate-200 px-4 py-3 font-mono text-sm focus:border-indigo-500 focus:ring-indigo-500" placeholder="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... your@email.com">{{ old('ssh_public_key', $teamMembership->ssh_public_key) }}</textarea>
<p class="mt-2 text-xs text-slate-500">Use an OpenSSH public key for SFTP access to this hosting account.</p>
<x-input-error :messages="$errors->get('ssh_public_key')" class="mt-2" />
</div>
<button type="submit" class="inline-flex items-center rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-indigo-700">
Save SFTP Key
</button>
@if ($teamMembership->ssh_key_installed_at)
<p class="text-xs text-slate-500">Last installed {{ $teamMembership->ssh_key_installed_at->diffForHumans() }}.</p>
@endif
</form>
@if ($teamMembership->hasSshAccessKey())
<form method="POST" action="{{ route('hosting.panel.settings.ssh-key.destroy', $account) }}" class="mt-3" onsubmit="return confirm('Remove your SFTP key access for this hosting account?');">
@csrf
@method('DELETE')
<button type="submit" class="inline-flex items-center rounded-lg border border-slate-300 px-5 py-2.5 text-sm font-medium text-slate-700 transition hover:border-slate-400 hover:text-slate-900">
Remove SFTP Key
</button>
</form>
@endif
@else
<div class="mt-5 rounded-xl bg-slate-50 px-4 py-4 text-sm text-slate-600">
Developers on your team use their own SSH keys instead of the shared password, so removing them from the team immediately cuts off SFTP access.
</div>
@endif
</div>
<div class="rounded-xl border border-slate-200 bg-white p-6">
<div class="flex items-center gap-3 mb-5">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-indigo-50">
<svg class="h-5 w-5 text-indigo-600" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 7.5h-.75A2.25 2.25 0 004.5 9.75v7.5a2.25 2.25 0 002.25 2.25h7.5a2.25 2.25 0 002.25-2.25v-7.5a2.25 2.25 0 00-2.25-2.25h-.75m-6 3.75l3 3m0 0l3-3m-3 3V1.5m6 9h.75a2.25 2.25 0 012.25 2.25v7.5a2.25 2.25 0 01-2.25 2.25h-7.5a2.25 2.25 0 01-2.25-2.25v-.75"/></svg>
</div>
<div>
<h3 class="text-base font-semibold text-slate-900">SFTP Connection</h3>
<p class="text-sm text-slate-500">Connect with any SFTP client</p>
</div>
</div>
<div class="mb-5 rounded-lg border border-slate-200 bg-slate-50 p-4">
<div class="flex items-center justify-between gap-4">
<code class="flex-1 overflow-x-auto font-mono text-sm text-slate-800">{{ $sftpCommand }}</code>
<button type="button" onclick="navigator.clipboard.writeText('{{ $sftpCommand }}'); this.textContent = 'Copied!'; setTimeout(() => this.textContent = 'Copy', 1500);" class="flex-shrink-0 rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white transition hover:bg-indigo-700">Copy</button>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div class="rounded-lg border border-slate-100 bg-slate-50/50 px-4 py-3">
<p class="text-xs font-medium text-slate-500">Host</p>
<p class="mt-1 font-mono text-sm text-slate-900">{{ $serverIp }}</p>
</div>
<div class="rounded-lg border border-slate-100 bg-slate-50/50 px-4 py-3">
<p class="text-xs font-medium text-slate-500">Port</p>
<p class="mt-1 font-mono text-sm text-slate-900">22</p>
</div>
<div class="rounded-lg border border-slate-100 bg-slate-50/50 px-4 py-3">
<p class="text-xs font-medium text-slate-500">Username</p>
<p class="mt-1 font-mono text-sm text-slate-900">{{ $account->username }}</p>
</div>
<div class="rounded-lg border border-slate-100 bg-slate-50/50 px-4 py-3">
<p class="text-xs font-medium text-slate-500">Authentication</p>
<p class="mt-1 text-sm text-slate-900">{{ $teamMembership ? 'SSH key' : 'Password' }}</p>
</div>
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-6">
<h3 class="mb-4 text-base font-semibold text-slate-900">Account Information</h3>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div>
<p class="text-xs font-medium text-slate-500">Account Type</p>
<p class="mt-1 text-sm text-slate-900">{{ ucfirst($account->type) }} Hosting</p>
</div>
<div>
<p class="text-xs font-medium text-slate-500">Product</p>
<p class="mt-1 text-sm text-slate-900">{{ $account->product->name ?? 'N/A' }}</p>
</div>
<div>
<p class="text-xs font-medium text-slate-500">PHP Version</p>
<p class="mt-1 text-sm text-slate-900">{{ $account->php_version ?? '8.2' }}</p>
</div>
<div>
<p class="text-xs font-medium text-slate-500">Status</p>
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {{ $account->status === 'active' ? 'bg-emerald-100 text-emerald-800' : 'bg-slate-100 text-slate-800' }}">
{{ ucfirst($account->status) }}
</span>
</div>
</div>
</div>
</div>
</x-hosting-panel-layout>
+100
View File
@@ -0,0 +1,100 @@
<x-hosting-panel-layout :account="$account">
<x-slot name="title">SSL Certificates - {{ $account->username }}</x-slot>
<x-slot name="header">SSL Certificates</x-slot>
<div class="space-y-6">
@if(session('success'))
<div class="rounded-lg bg-emerald-50 border border-emerald-200 p-4">
<p class="text-sm text-emerald-800">{{ session('success') }}</p>
</div>
@endif
@if(session('error'))
<div class="rounded-lg bg-red-50 border border-red-200 p-4">
<p class="text-sm text-red-800">{{ session('error') }}</p>
</div>
@endif
<div class="rounded-xl border border-slate-200 bg-white overflow-hidden">
<div class="px-6 py-4 border-b border-slate-200">
<h3 class="text-base font-semibold text-slate-900">Your Domains</h3>
<p class="mt-1 text-sm text-slate-500">Manage SSL certificates for your domains using Let's Encrypt.</p>
</div>
@if($account->sites->count())
<table class="min-w-full divide-y divide-slate-200">
<thead class="bg-slate-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Domain</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">SSL Status</th>
<th class="px-6 py-3 text-right text-xs font-medium text-slate-500 uppercase">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200">
@foreach($account->sites as $site)
<tr class="hover:bg-slate-50">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center gap-2">
@include('components.icons.domain-globe', ['class' => 'h-5 w-5 text-slate-400'])
<span class="text-sm font-medium text-slate-900">{{ $site->domain }}</span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
@if($site->ssl_enabled)
<span class="inline-flex items-center gap-1.5 rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-medium text-emerald-800">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"/></svg>
SSL Active
</span>
@else
<span class="inline-flex items-center gap-1.5 rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/></svg>
No SSL
</span>
@endif
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
@if(!$site->ssl_enabled)
<form action="{{ route('hosting.panel.ssl.request', [$account, $site]) }}" method="POST" class="inline">
@csrf
<button type="submit" class="inline-flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-700 transition">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"/></svg>
Issue SSL
</button>
</form>
@else
<span class="text-xs text-slate-500">Auto-renews</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
@else
<div class="px-6 py-12 text-center">
@include('components.icons.domain-globe', ['class' => 'mx-auto h-12 w-12 text-slate-300'])
<p class="mt-4 text-sm text-slate-500">No domains configured. Add a domain first to enable SSL.</p>
<a href="{{ route('hosting.panel.domains', $account) }}" class="mt-4 inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 transition">
Add Domain
</a>
</div>
@endif
</div>
<div class="rounded-xl border border-blue-200 bg-blue-50 p-6">
<h3 class="text-base font-semibold text-blue-900 mb-2">About Let's Encrypt SSL</h3>
<ul class="space-y-2 text-sm text-blue-800">
<li class="flex items-start gap-2">
<svg class="h-5 w-5 text-blue-600 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg>
<span>Free SSL certificates from Let's Encrypt</span>
</li>
<li class="flex items-start gap-2">
<svg class="h-5 w-5 text-blue-600 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg>
<span>Certificates auto-renew every 90 days</span>
</li>
<li class="flex items-start gap-2">
<svg class="h-5 w-5 text-blue-600 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg>
<span>Your domain must point to this server before issuing SSL</span>
</li>
</ul>
</div>
</div>
</x-hosting-panel-layout>
@@ -0,0 +1,107 @@
<x-hosting-panel-layout :account="$account">
<x-slot name="title">Terminal - {{ $account->username }}</x-slot>
<x-slot name="header">Terminal</x-slot>
<div
x-data="hostingInteractiveTerminal({
accountId: @js($account->getKey()),
username: @js($account->username),
initialPath: @js($currentPath),
csrfToken: @js(csrf_token()),
runUrl: @js(route('hosting.panel.terminal.run', $account)),
startUrl: @js(route('hosting.panel.terminal.sessions.start', $account)),
readUrlTemplate: @js(route('hosting.panel.terminal.sessions.read', [$account, '__SESSION__'])),
inputUrlTemplate: @js(route('hosting.panel.terminal.sessions.input', [$account, '__SESSION__'])),
resizeUrlTemplate: @js(route('hosting.panel.terminal.sessions.resize', [$account, '__SESSION__'])),
closeUrlTemplate: @js(route('hosting.panel.terminal.sessions.close', [$account, '__SESSION__'])),
})"
class="-m-6 flex h-[calc(100vh-8rem)] flex-col bg-[#0a0a0a]"
>
{{-- Terminal Header --}}
<div class="flex items-center justify-between border-b border-white/5 bg-[#0a0a0a] px-4 py-2">
<div class="flex items-center gap-3">
{{-- macOS-style window controls --}}
<div class="flex items-center gap-1.5">
<button @click="shutdown()" class="h-3 w-3 rounded-full bg-[#ff5f57] transition hover:brightness-110" title="Close"></button>
<div class="h-3 w-3 rounded-full bg-[#febc2e]"></div>
<div class="h-3 w-3 rounded-full bg-[#28c840]"></div>
</div>
{{-- Session info --}}
<div class="flex items-center gap-2 pl-3">
<span class="font-mono text-xs text-white/40">{{ $account->username }}@ladill</span>
<span class="text-white/20">:</span>
<span class="font-mono text-xs text-emerald-400/80" x-text="promptPath()">~</span>
</div>
</div>
<div class="flex items-center gap-3">
{{-- Connection status --}}
<div class="flex items-center gap-2">
<span
class="h-1.5 w-1.5 rounded-full"
:class="statusLabel === 'Live' ? 'bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.5)]' : statusLabel === 'Connecting' ? 'bg-amber-400 animate-pulse' : 'bg-red-400'"
></span>
<span class="text-[10px] font-medium uppercase tracking-wider text-white/30" x-text="statusLabel"></span>
</div>
{{-- Reconnect button --}}
<button
x-show="canReconnect"
x-transition
@click="restart()"
class="flex items-center gap-1.5 rounded-md bg-white/5 px-2.5 py-1 text-[10px] font-medium text-white/60 transition hover:bg-white/10 hover:text-white/80"
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Reconnect
</button>
</div>
</div>
{{-- Terminal Body --}}
<div class="relative min-h-0 flex-1 overflow-hidden">
<div
x-ref="terminal"
@mousedown.prevent="focusTerminal()"
@click="focusTerminal()"
tabindex="0"
class="h-full w-full cursor-text bg-[#0a0a0a] p-2"
></div>
{{-- Connection overlay --}}
<div
x-show="overlayMessage && !canReconnect"
x-transition.opacity.duration.200ms
class="absolute inset-0 flex items-center justify-center bg-[#0a0a0a]/90 backdrop-blur-sm"
>
<div class="text-center">
<div class="mb-3 inline-flex h-8 w-8 items-center justify-center">
<svg class="h-5 w-5 animate-spin text-white/40" 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>
</div>
<p class="font-mono text-xs text-white/50" x-text="overlayMessage"></p>
</div>
</div>
</div>
{{-- Terminal Footer --}}
<div class="flex items-center justify-between border-t border-white/5 bg-[#0a0a0a] px-4 py-1.5">
<div class="flex items-center gap-4 text-[10px] text-white/25">
<span class="font-mono">PTY</span>
<span class="font-mono">UTF-8</span>
<span class="font-mono">bash</span>
</div>
<div class="flex items-center gap-1 text-[10px] text-white/25">
<kbd class="rounded bg-white/5 px-1 py-0.5 font-mono">Ctrl+C</kbd>
<span>cancel</span>
<span class="mx-2 text-white/10">|</span>
<kbd class="rounded bg-white/5 px-1 py-0.5 font-mono">Tab</kbd>
<span>autocomplete</span>
</div>
</div>
</div>
</x-hosting-panel-layout>