fix(portal): move notices into the header, so the page stops scrolling twice

The banners sat outside the h-screen shell, so the page scrolled and the content
scrolled — two scrollbars whenever a maintenance window was active.

Maintenance notices are now a bell with a count in the header, next to the user
menu that was otherwise the only thing up there. Impersonation stays visible as
a header chip rather than moving into the dropdown: it is a mode, not a notice,
and acting as someone else without noticing is how mistakes happen.

Also: the VPN status pill wrapped onto two lines in a narrow column.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-26 00:13:20 +02:00
parent 3fad319053
commit 062d337dd0
4 changed files with 53 additions and 26 deletions

View File

@ -1,6 +1,7 @@
<?php <?php
return [ return [
'notices' => 'Hinweise',
'title' => 'Wartungen', 'title' => 'Wartungen',
'subtitle' => 'Wartungsfenster anlegen, mehreren Hosts zuweisen und betroffene Kunden benachrichtigen.', 'subtitle' => 'Wartungsfenster anlegen, mehreren Hosts zuweisen und betroffene Kunden benachrichtigen.',
'empty' => 'Noch keine Wartungsfenster.', 'empty' => 'Noch keine Wartungsfenster.',

View File

@ -1,6 +1,7 @@
<?php <?php
return [ return [
'notices' => 'Notices',
'title' => 'Maintenance', 'title' => 'Maintenance',
'subtitle' => 'Create maintenance windows, assign them to multiple hosts and notify affected customers.', 'subtitle' => 'Create maintenance windows, assign them to multiple hosts and notify affected customers.',
'empty' => 'No maintenance windows yet.', 'empty' => 'No maintenance windows yet.',

View File

@ -34,31 +34,6 @@
</div> </div>
@endif @endif
@foreach ($maintenanceWindows as $mw)
@php $isActive = now()->greaterThanOrEqualTo($mw->starts_at) && now()->lessThanOrEqualTo($mw->ends_at); @endphp
<div class="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-b {{ $isActive ? 'border-warning-border bg-warning-bg' : 'border-info-border bg-info-bg' }} px-4 py-2 text-center text-sm text-ink">
<x-ui.icon name="alert-triangle" class="size-4 shrink-0 {{ $isActive ? 'text-warning' : 'text-info' }}" />
<span class="font-medium">{{ $mw->title }}</span>
<span class="text-muted">
{{ $isActive
? __('maintenance.banner_active', ['end' => $mw->ends_at->isoFormat('LT')])
: __('maintenance.banner_upcoming', ['start' => $mw->starts_at->isoFormat('LLL'), 'end' => $mw->ends_at->isoFormat('LT')]) }}
</span>
</div>
@endforeach
@if (session('impersonator_id'))
{{-- Admin impersonation banner visible on every portal page until the admin returns. --}}
<div class="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-b border-warning-border bg-warning-bg px-4 py-2 text-center text-sm font-medium text-ink">
<span>{{ __('impersonate.banner', ['name' => auth()->user()->name]) }}</span>
<form method="POST" action="{{ route('impersonate.leave') }}" class="inline">
@csrf
<button type="submit" class="inline-flex items-center gap-1 rounded-pill border border-warning-border px-3 py-0.5 font-semibold text-warning underline-offset-2 hover:underline">
<x-ui.icon name="log-out" class="size-4" />
{{ __('impersonate.leave') }}
</button>
</form>
</div>
@endif
<div class="flex min-h-screen lg:h-screen lg:overflow-hidden"> <div class="flex min-h-screen lg:h-screen lg:overflow-hidden">
{{-- Sidebar: fixed drawer on small screens, fixed full-height column on large (R7). --}} {{-- Sidebar: fixed drawer on small screens, fixed full-height column on large (R7). --}}
<aside <aside
@ -101,8 +76,58 @@
<button type="button" class="lg:hidden inline-flex min-h-11 min-w-11 items-center justify-center rounded text-muted hover:bg-surface-hover" @click="nav = true" aria-label="{{ __('dashboard.open_nav') }}"> <button type="button" class="lg:hidden inline-flex min-h-11 min-w-11 items-center justify-center rounded text-muted hover:bg-surface-hover" @click="nav = true" aria-label="{{ __('dashboard.open_nav') }}">
<x-ui.icon name="menu" /> <x-ui.icon name="menu" />
</button> </button>
{{-- Impersonation is a MODE, not a notice: it stays visible rather
than hiding in a dropdown, because acting as someone else
without noticing is how mistakes happen. --}}
@if (session('impersonator_id'))
<div class="ml-2 flex min-w-0 items-center gap-2 rounded-pill border border-warning-border bg-warning-bg px-3 py-1">
<x-ui.icon name="users" class="size-4 shrink-0 text-warning" />
<span class="truncate text-xs font-medium text-ink">{{ __('impersonate.banner', ['name' => auth()->user()->name]) }}</span>
<form method="POST" action="{{ route('impersonate.leave') }}" class="inline shrink-0">
@csrf
<button type="submit" class="inline-flex items-center gap-1 rounded-pill px-2 py-0.5 text-xs font-semibold text-warning hover:underline">
<x-ui.icon name="log-out" class="size-3.5" />
<span class="hidden sm:inline">{{ __('impersonate.leave') }}</span>
</button>
</form>
</div>
@endif
<div class="flex-1"></div> <div class="flex-1"></div>
{{-- Notices --}}
@if ($maintenanceWindows->isNotEmpty())
<div class="relative mr-1" x-data="{ open: false }" @keydown.escape="open = false">
<button type="button" @click="open = !open" :aria-expanded="open" aria-haspopup="menu"
class="relative grid size-9 place-items-center rounded-md text-muted transition hover:bg-surface-hover hover:text-ink"
aria-label="{{ __('maintenance.notices') }}">
<x-ui.icon name="bell" class="size-5" />
<span class="absolute right-1 top-1 grid size-4 place-items-center rounded-pill bg-warning text-[10px] font-bold text-white">{{ $maintenanceWindows->count() }}</span>
</button>
<div x-show="open" x-cloak @click.outside="open = false"
class="absolute right-0 z-50 mt-1 w-80 overflow-hidden rounded-lg border border-line bg-surface shadow-md">
<p class="border-b border-line px-4 py-2.5 text-xs font-semibold uppercase tracking-wide text-faint">{{ __('maintenance.notices') }}</p>
<div class="max-h-80 divide-y divide-line overflow-y-auto">
@foreach ($maintenanceWindows as $mw)
@php $isActive = now()->betweenIncluded($mw->starts_at, $mw->ends_at); @endphp
<div class="flex gap-2.5 px-4 py-3">
<x-ui.icon name="alert-triangle" class="mt-0.5 size-4 shrink-0 {{ $isActive ? 'text-warning' : 'text-info' }}" />
<div class="min-w-0">
<p class="text-sm font-medium text-ink">{{ $mw->title }}</p>
<p class="mt-0.5 text-xs leading-relaxed text-muted">
{{ $isActive
? __('maintenance.banner_active', ['end' => $mw->ends_at->isoFormat('LT')])
: __('maintenance.banner_upcoming', ['start' => $mw->starts_at->isoFormat('LLL'), 'end' => $mw->ends_at->isoFormat('LT')]) }}
</p>
</div>
</div>
@endforeach
</div>
</div>
</div>
@endif
@auth @auth
<div class="relative" x-data="{ open: false }" @keydown.escape="open = false"> <div class="relative" x-data="{ open: false }" @keydown.escape="open = false">
<button type="button" class="flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface-hover" @click="open = !open" :aria-expanded="open" aria-haspopup="menu"> <button type="button" class="flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface-hover" @click="open = !open" :aria-expanded="open" aria-haspopup="menu">

View File

@ -131,7 +131,7 @@
{{ $peer->last_handshake_at?->diffForHumans() ?? __('vpn.never') }} {{ $peer->last_handshake_at?->diffForHumans() ?? __('vpn.never') }}
</td> </td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium {{ $tone }}"> <span class="inline-flex items-center gap-1.5 whitespace-nowrap rounded-pill border px-2.5 py-0.5 text-xs font-medium {{ $tone }}">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span> <span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
{{ __('vpn.status_'.$status) }} {{ __('vpn.status_'.$status) }}
</span> </span>