UI: sidebar fixed-header/scroll-mid/fixed-foot + mobile drawer + ChartJS

Sidebar layout (per user request: header+footer fix, only nav scrollable):
- Restructured clu-sidebar into 3 flex-children: clu-sidebar-head (brand +
  ws-switch fixed at top), clu-sidebar-scroll (nav, overflow-y:auto in
  middle), clu-sidebar-foot (plan-box fixed at bottom).

Mobile sidebar (per user request: drawer + hamburger):
- <920px: sidebar position fixed, translateX(-110%) hidden, .open class
  slides in. clu-sidebar-backdrop overlay dimms main + click-to-close.
- New x-clu.burger component with hamburger icon, visible only <920px
  via display:grid in media query. Injected into all 9 topbars.
- Alpine state sidebarOpen on <body>, nav-item click closes drawer
  after navigation.

Linking (per user request: sites detail unreachable):
- Sites index table + dashboard top-sites table: domain cell now <a>
  to route('sites.show', $site) with wire:navigate.

Charts (per user request: chart.js):
- composer/npm: chart.js ^4.5.
- resources/js/app.js: register all chart.js plugins, expose window.Chart
  for inline page scripts.
- Dashboard: added Traffic-24h line chart (Chart.js line+gradient fill)
  between metrics and 2-col row. Init re-runs on livewire:navigated for
  wire:navigate compatibility.

Modal (per user request: wire-elements):
- @livewire('wire-elements-modal') already in layouts/app.blade.php
  master layout per wire-elements/modal v3 docs (single mount). Open
  pattern: wire:click="$dispatch('openModal', { component: 'name' })".

Full Pest regression: 47/47 green.
master
Boban Blaskovic 2026-05-31 23:17:29 +02:00
parent 4f0c787f89
commit 665ca4e40b
16 changed files with 207 additions and 58 deletions

19
app/package-lock.json generated
View File

@ -5,6 +5,7 @@
"packages": { "packages": {
"": { "": {
"dependencies": { "dependencies": {
"chart.js": "^4.5.1",
"laravel-echo": "^2.3.4", "laravel-echo": "^2.3.4",
"pusher-js": "^8.5.0" "pusher-js": "^8.5.0"
}, },
@ -512,6 +513,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.60.4", "version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz",
@ -1369,6 +1376,18 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/cliui": { "node_modules/cliui": {
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",

View File

@ -18,6 +18,7 @@
"vite": "^7.0.7" "vite": "^7.0.7"
}, },
"dependencies": { "dependencies": {
"chart.js": "^4.5.1",
"laravel-echo": "^2.3.4", "laravel-echo": "^2.3.4",
"pusher-js": "^8.5.0" "pusher-js": "^8.5.0"
} }

View File

@ -630,20 +630,55 @@
/* ───── App layout (sidebar + main) ───── */ /* ───── App layout (sidebar + main) ───── */
.clu-app-grid { display: grid; grid-template-columns: 236px 1fr; min-height: 100vh; } .clu-app-grid { display: grid; grid-template-columns: 236px 1fr; min-height: 100vh; }
@media (max-width: 920px) { .clu-app-grid { grid-template-columns: 1fr; } .clu-sidebar { display: none; } }
/* Sidebar */ /* Sidebar — fixed brand+ws-switch header, scrollable nav, fixed plan-box footer */
.clu-sidebar { .clu-sidebar {
position: sticky; top: 0; height: 100vh; position: sticky; top: 0;
height: 100vh;
margin: 14px 0 14px 14px; margin: 14px 0 14px 14px;
border-radius: var(--radius); border-radius: var(--radius);
display: flex; flex-direction: column; display: flex; flex-direction: column;
padding: 18px 14px; gap: 18px; padding: 18px 14px;
background: var(--color-glass-strong); background: var(--color-glass-strong);
backdrop-filter: blur(30px) saturate(180%); backdrop-filter: blur(30px) saturate(180%);
-webkit-backdrop-filter: blur(30px) saturate(180%); -webkit-backdrop-filter: blur(30px) saturate(180%);
border: 1px solid var(--color-glass-border); border: 1px solid var(--color-glass-border);
box-shadow: var(--shadow-glass), var(--shadow-glass-inset); box-shadow: var(--shadow-glass), var(--shadow-glass-inset);
z-index: 30;
}
.clu-sidebar-head { flex: none; display: flex; flex-direction: column; gap: 18px; }
.clu-sidebar-scroll { flex: 1; min-height: 0; overflow-y: auto; margin-top: 18px; padding-right: 4px; }
.clu-sidebar-scroll::-webkit-scrollbar { width: 6px; }
.clu-sidebar-scroll::-webkit-scrollbar-thumb { background: rgba(30,35,60,0.15); border-radius: 3px; }
.clu-sidebar-foot { flex: none; margin-top: 14px; }
/* Sidebar backdrop (mobile only) */
.clu-sidebar-backdrop {
position: fixed; inset: 0;
background: rgba(15,18,30,0.45);
backdrop-filter: blur(2px);
z-index: 28;
display: none;
}
/* Hamburger button in topbar (mobile only) */
.clu-burger { display: none; }
@media (max-width: 920px) {
.clu-app-grid { grid-template-columns: 1fr; }
.clu-sidebar {
position: fixed;
top: 0; left: 0;
height: 100vh;
width: 256px;
margin: 0;
border-radius: 0 var(--radius) var(--radius) 0;
transform: translateX(-110%);
transition: transform .25s ease;
}
.clu-sidebar.open { transform: translateX(0); }
.clu-sidebar-backdrop.open { display: block; }
.clu-burger { display: grid; }
} }
.clu-brand-row { display: flex; align-items: center; gap: 10px; padding: 2px 4px; } .clu-brand-row { display: flex; align-items: center; gap: 10px; padding: 2px 4px; }
.clu-brand-name { font-weight: 600; font-size: 15px; letter-spacing: -0.01em; } .clu-brand-name { font-weight: 600; font-size: 15px; letter-spacing: -0.01em; }

View File

@ -1,4 +1,12 @@
import './bootstrap'; import './bootstrap';
import './login-effects'; import './login-effects';
import './register-effects'; import './register-effects';
import { Chart, registerables } from 'chart.js';
Chart.register(...registerables);
// Expose Chart globally so Blade inline <script> can create charts
// without needing a separate import per page.
window.Chart = Chart;
window.__clupilot = 1; window.__clupilot = 1;

View File

@ -0,0 +1,9 @@
{{-- Mobile-only burger button (toggles sidebar via Alpine sidebarOpen). --}}
<button type="button"
class="clu-burger clu-icon-btn"
@click="sidebarOpen = !sidebarOpen"
aria-label="Menu öffnen">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true">
<path d="M3 6h18M3 12h18M3 18h18"/>
</svg>
</button>

View File

@ -21,14 +21,14 @@
]; ];
@endphp @endphp
<aside class="clu-sidebar"> <aside class="clu-sidebar" :class="{ 'open': sidebarOpen }">
{{-- Brand --}} {{-- Fixed header --}}
<div class="clu-sidebar-head">
<div class="clu-brand-row"> <div class="clu-brand-row">
<div class="clu-brand-mark" aria-hidden="true"></div> <div class="clu-brand-mark" aria-hidden="true"></div>
<div class="clu-brand-name">CluPilot<span>v2.4</span></div> <div class="clu-brand-name">CluPilot<span>v2.4</span></div>
</div> </div>
{{-- Workspace switch --}}
<div class="clu-ws-switch" role="button" tabindex="0"> <div class="clu-ws-switch" role="button" tabindex="0">
<div class="clu-ws-avatar">AC</div> <div class="clu-ws-avatar">AC</div>
<div class="clu-ws-meta"> <div class="clu-ws-meta">
@ -39,8 +39,10 @@
<path d="M7 9l5-5 5 5M7 15l5 5 5-5"/> <path d="M7 9l5-5 5 5M7 15l5 5 5-5"/>
</svg> </svg>
</div> </div>
</div>
{{-- Nav --}} {{-- Scrollable nav --}}
<div class="clu-sidebar-scroll">
<nav class="clu-nav"> <nav class="clu-nav">
<div class="clu-nav-title">Cluster</div> <div class="clu-nav-title">Cluster</div>
@foreach ($items as $item) @foreach ($items as $item)
@ -49,7 +51,8 @@
$href = \Illuminate\Support\Facades\Route::has($item['route']) ? route($item['route']) : '#'; $href = \Illuminate\Support\Facades\Route::has($item['route']) ? route($item['route']) : '#';
@endphp @endphp
<a href="{{ $href }}" wire:navigate <a href="{{ $href }}" wire:navigate
class="clu-nav-item @if($isActive) active @endif"> class="clu-nav-item @if($isActive) active @endif"
@click="sidebarOpen = false">
<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"> <svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
{!! $item['svg'] !!} {!! $item['svg'] !!}
</svg> </svg>
@ -60,8 +63,10 @@
</a> </a>
@endforeach @endforeach
</nav> </nav>
</div>
{{-- Plan box --}} {{-- Fixed footer --}}
<div class="clu-sidebar-foot">
<div class="clu-plan-box"> <div class="clu-plan-box">
<div class="clu-plan-row"> <div class="clu-plan-row">
<span class="clu-plan-name">PRO</span> <span class="clu-plan-name">PRO</span>
@ -73,4 +78,5 @@
<span>€249/mo</span> <span>€249/mo</span>
</div> </div>
</div> </div>
</div>
</aside> </aside>

View File

@ -11,13 +11,17 @@
@vite(['resources/css/app.css', 'resources/js/app.js']) @vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles @livewireStyles
</head> </head>
<body class="clu-bg-aurora"> <body class="clu-bg-aurora" x-data="{ sidebarOpen: false }">
<div class="clu-app-grid">
{{-- ───── Sidebar ───── --}} {{-- Mobile backdrop --}}
<div class="clu-sidebar-backdrop"
:class="{ 'open': sidebarOpen }"
@click="sidebarOpen = false"
aria-hidden="true"></div>
<div class="clu-app-grid">
<x-clu.sidebar :active="$active ?? request()->route()?->getName()" /> <x-clu.sidebar :active="$active ?? request()->route()?->getName()" />
{{-- ───── Main ───── --}}
<main class="flex flex-col min-w-0" style="padding:14px 14px 0;"> <main class="flex flex-col min-w-0" style="padding:14px 14px 0;">
{{ $slot }} {{ $slot }}
</main> </main>

View File

@ -36,6 +36,7 @@ new #[Layout('layouts.app')] class extends Component {
<div> <div>
<div class="clu-topbar"> <div class="clu-topbar">
<x-clu.burger />
<div> <div>
<h1 class="clu-page-title">Backups</h1> <h1 class="clu-page-title">Backups</h1>
<div class="clu-page-sub">{{ $recoveryPoints }} wiederherstellungspunkte · {{ $totalSites }} sites</div> <div class="clu-page-sub">{{ $recoveryPoints }} wiederherstellungspunkte · {{ $totalSites }} sites</div>

View File

@ -75,6 +75,7 @@ new #[Layout('layouts.app')] class extends Component {
<div> <div>
<div class="clu-topbar"> <div class="clu-topbar">
<x-clu.burger />
<div> <div>
<h1 class="clu-page-title">Cluster anlegen</h1> <h1 class="clu-page-title">Cluster anlegen</h1>
<div class="clu-page-sub">schritt {{ $step }} von 4</div> <div class="clu-page-sub">schritt {{ $step }} von 4</div>

View File

@ -38,6 +38,7 @@ new #[Layout('layouts.app')] class extends Component {
<div> <div>
{{-- ───── Topbar ───── --}} {{-- ───── Topbar ───── --}}
<div class="clu-topbar"> <div class="clu-topbar">
<x-clu.burger />
<div> <div>
<h1>Dashboard</h1> <h1>Dashboard</h1>
<div class="crumb">Acme Cluster · eu-central-1</div> <div class="crumb">Acme Cluster · eu-central-1</div>
@ -112,6 +113,64 @@ new #[Layout('layouts.app')] class extends Component {
</div> </div>
</div> </div>
{{-- ───── Traffic chart (ChartJS) ───── --}}
<div class="clu-card mb-[18px]" wire:ignore>
<div class="clu-card-head">
<h3>Traffic · letzte 24h</h3>
<span class="meta">requests / minute</span>
</div>
<div class="clu-card-body" style="position:relative;height:240px;">
<canvas id="clu-traffic-chart"></canvas>
</div>
</div>
@push('scripts')
<script>
(function initTrafficChart() {
const el = document.getElementById('clu-traffic-chart');
if (!el || !window.Chart) return;
if (el._chart) el._chart.destroy();
const ctx = el.getContext('2d');
// 24 buckets, hour labels
const labels = Array.from({length: 24}, (_, i) => ((24 + new Date().getHours() - 23 + i) % 24).toString().padStart(2, '0') + ':00');
// mock 24h traffic curve (peak midday)
const data = [42, 38, 32, 28, 22, 19, 25, 48, 92, 134, 168, 192, 210, 218, 204, 185, 162, 148, 135, 118, 96, 78, 64, 52];
const grad = ctx.createLinearGradient(0, 0, 0, 240);
grad.addColorStop(0, 'rgba(59,111,242,0.35)');
grad.addColorStop(1, 'rgba(59,111,242,0.00)');
el._chart = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [{
data,
borderColor: '#3b6ff2',
borderWidth: 2,
backgroundColor: grad,
fill: true,
tension: 0.35,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: '#3b6ff2',
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { grid: { color: 'rgba(30,35,60,0.06)' }, ticks: { font: { family: 'JetBrains Mono', size: 10 }, color: '#9596a6' } },
y: { grid: { color: 'rgba(30,35,60,0.06)' }, ticks: { font: { family: 'JetBrains Mono', size: 10 }, color: '#9596a6' }, beginAtZero: true },
},
},
});
})();
document.addEventListener('livewire:navigated', () => initTrafficChart());
</script>
@endpush
{{-- ───── 2-col: Sites table + Activity feed ───── --}} {{-- ───── 2-col: Sites table + Activity feed ───── --}}
<div class="grid gap-[14px] mb-[18px]" style="grid-template-columns:minmax(0,2fr) minmax(280px,1fr);"> <div class="grid gap-[14px] mb-[18px]" style="grid-template-columns:minmax(0,2fr) minmax(280px,1fr);">
@ -136,13 +195,13 @@ new #[Layout('layouts.app')] class extends Component {
@foreach ($sites as $site) @foreach ($sites as $site)
<tr> <tr>
<td> <td>
<div class="clu-site-cell"> <a href="{{ route('sites.show', $site) }}" wire:navigate class="clu-site-cell" style="text-decoration:none;color:inherit;">
<div class="clu-favicon">{{ mb_substr($site->domain, 0, 1) }}</div> <div class="clu-favicon">{{ mb_substr($site->domain, 0, 1) }}</div>
<div class="clu-site-meta"> <div class="clu-site-meta">
<span class="clu-site-domain">{{ $site->domain }}</span> <span class="clu-site-domain">{{ $site->domain }}</span>
<span class="clu-site-server">{{ $site->server->name }} · {{ $site->server->region }}</span> <span class="clu-site-server">{{ $site->server->name }} · {{ $site->server->region }}</span>
</div> </div>
</div> </a>
</td> </td>
<td><span class="clu-pill muted">WP {{ $site->wp_version }} · PHP {{ $site->php_version }}</span></td> <td><span class="clu-pill muted">WP {{ $site->wp_version }} · PHP {{ $site->php_version }}</span></td>
<td class="font-mono">{{ number_format($site->visitors_24h, 0, ',', '.') }}</td> <td class="font-mono">{{ number_format($site->visitors_24h, 0, ',', '.') }}</td>

View File

@ -47,6 +47,7 @@ new #[Layout('layouts.app')] class extends Component {
<div> <div>
<div class="clu-topbar"> <div class="clu-topbar">
<x-clu.burger />
<div> <div>
<h1 class="clu-page-title">Sicherheit</h1> <h1 class="clu-page-title">Sicherheit</h1>
<div class="clu-page-sub">{{ $blockedCount }} blockiert / 24h · {{ $advisories->count() }} advisories</div> <div class="clu-page-sub">{{ $blockedCount }} blockiert / 24h · {{ $advisories->count() }} advisories</div>

View File

@ -46,6 +46,7 @@ new #[Layout('layouts.app')] class extends Component {
<div> <div>
{{-- ───── Topbar ───── --}} {{-- ───── Topbar ───── --}}
<div class="clu-topbar"> <div class="clu-topbar">
<x-clu.burger />
<div> <div>
<h1 class="clu-page-title">Server</h1> <h1 class="clu-page-title">Server</h1>
<div class="clu-page-sub">{{ $totalServers }} nodes · cluster acme</div> <div class="clu-page-sub">{{ $totalServers }} nodes · cluster acme</div>

View File

@ -34,6 +34,7 @@ new #[Layout('layouts.app')] class extends Component {
<div> <div>
<div class="clu-topbar"> <div class="clu-topbar">
<x-clu.burger />
<div> <div>
<h1 class="clu-page-title">Einstellungen</h1> <h1 class="clu-page-title">Einstellungen</h1>
<div class="clu-page-sub">workspace · benachrichtigungen · billing · api</div> <div class="clu-page-sub">workspace · benachrichtigungen · billing · api</div>

View File

@ -75,6 +75,7 @@ new #[Layout('layouts.app')] class extends Component {
<div> <div>
{{-- ───── Topbar ───── --}} {{-- ───── Topbar ───── --}}
<div class="clu-topbar"> <div class="clu-topbar">
<x-clu.burger />
<div> <div>
<h1 class="clu-page-title">Sites</h1> <h1 class="clu-page-title">Sites</h1>
<div class="clu-page-sub">{{ $totalSites }} wordpress-installationen</div> <div class="clu-page-sub">{{ $totalSites }} wordpress-installationen</div>
@ -174,13 +175,13 @@ new #[Layout('layouts.app')] class extends Component {
@forelse ($sites as $site) @forelse ($sites as $site)
<tr> <tr>
<td> <td>
<div class="clu-site-cell"> <a href="{{ route('sites.show', $site) }}" wire:navigate class="clu-site-cell" style="text-decoration:none;color:inherit;">
<div class="clu-favicon">{{ mb_substr($site->domain, 0, 1) }}</div> <div class="clu-favicon">{{ mb_substr($site->domain, 0, 1) }}</div>
<div class="clu-site-meta"> <div class="clu-site-meta">
<span class="clu-site-domain">{{ $site->domain }}</span> <span class="clu-site-domain">{{ $site->domain }}</span>
<span class="clu-site-server">ssl · {{ $site->cache_enabled ? 'cache an' : 'cache aus' }}</span> <span class="clu-site-server">ssl · {{ $site->cache_enabled ? 'cache an' : 'cache aus' }}</span>
</div> </div>
</div> </a>
</td> </td>
<td><span class="clu-pill muted">{{ $site->server->name }}</span></td> <td><span class="clu-pill muted">{{ $site->server->name }}</span></td>
<td><span class="clu-pill @if($site->updates_pending > 0) warning @else success @endif">WP {{ $site->wp_version }}</span></td> <td><span class="clu-pill @if($site->updates_pending > 0) warning @else success @endif">WP {{ $site->wp_version }}</span></td>

View File

@ -72,6 +72,7 @@ new #[Layout('layouts.app')] class extends Component {
<div> <div>
<div class="clu-topbar"> <div class="clu-topbar">
<x-clu.burger />
<div class="flex items-center gap-[12px]"> <div class="flex items-center gap-[12px]">
<a href="{{ route('sites.index') }}" wire:navigate class="clu-icon-btn" aria-label="Zurück"> <a href="{{ route('sites.index') }}" wire:navigate class="clu-icon-btn" aria-label="Zurück">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 5l-7 7 7 7"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 5l-7 7 7 7"/></svg>

View File

@ -28,6 +28,7 @@ new #[Layout('layouts.app')] class extends Component {
<div> <div>
<div class="clu-topbar"> <div class="clu-topbar">
<x-clu.burger />
<div> <div>
<h1 class="clu-page-title">Team</h1> <h1 class="clu-page-title">Team</h1>
<div class="clu-page-sub">{{ $totalMembers }} mitglieder · {{ $pendingInvites }} einladungen offen</div> <div class="clu-page-sub">{{ $totalMembers }} mitglieder · {{ $pendingInvites }} einladungen offen</div>