feat(ui): dock resource actions in top bar, user menu in sidebar

Teleport application/database/service heading actions into a fixed
#resource-action-hud-slot so they no longer overlay page content.
Render the account menu in the sidebar footer (with collapse-aware
layout) instead of the header, and drop the project card hover arrow.
This commit is contained in:
Andras Bacsai
2026-08-05 20:17:21 +02:00
parent 85613b01ba
commit ec165412c3
12 changed files with 115 additions and 34 deletions
+4 -2
View File
@@ -206,9 +206,11 @@
@endif
</ul>
{{-- Sticky sidebar collapser (desktop only; mobile uses a temporary slide-over) --}}
<div class="sticky bottom-0 mt-auto -mx-2 hidden border-t border-neutral-200 bg-white px-2 py-2 dark:border-white/[0.06] dark:bg-panel lg:-mx-3 lg:block lg:px-3">
<div class="sticky bottom-0 mt-auto -mx-2 hidden items-center gap-1 border-t border-neutral-200 bg-white px-2 py-2 dark:border-white/[0.06] dark:bg-panel lg:-mx-3 lg:flex lg:px-3"
:class="collapsed ? 'flex-col justify-center' : 'justify-between'">
<x-top-user-menu sidebar />
<button type="button" @click="toggleSidebar()" title="Toggle sidebar" aria-label="Toggle sidebar"
class="menu-item mx-auto w-8 justify-center px-0">
class="menu-item w-8 shrink-0 justify-center px-0">
<svg class="menu-item-icon" viewBox="0 0 24 24" fill="none">
<rect x="3" y="4" width="18" height="16" rx="2" stroke="currentColor" stroke-width="1.6" />
<path d="M9 4v16" stroke="currentColor" stroke-width="1.6" />
@@ -1,10 +1,14 @@
@props([
'sidebar' => false,
])
@php
$user = auth()->user();
$userName = $user?->name ?? 'Account';
$userEmail = $user?->email ?? '';
$userInitial = strtoupper(mb_substr($user?->name ?: ($user?->email ?: 'A'), 0, 1));
@endphp
<div class="relative" x-data="{
<div @class(['relative', 'min-w-0' => $sidebar]) x-data="{
open: false,
appearanceOpen: false,
theme: localStorage.getItem('theme') || 'dark',
@@ -22,19 +26,31 @@
@click.outside="open = false; appearanceOpen = false">
<button type="button" @click="open = !open"
title="{{ $userName }}" aria-label="Account menu for {{ $userName }}"
class="flex h-8 items-center gap-1.5 rounded-full border border-neutral-200 bg-neutral-100 px-2 shadow-sm transition-colors hover:bg-neutral-200 dark:border-white/[0.08] dark:bg-white/[0.06] dark:hover:bg-white/[0.1]">
@if ($sidebar) :class="collapsed && 'w-8 justify-center px-0'" @endif
@class([
'flex h-8 items-center gap-1.5 rounded-full border border-neutral-200 bg-neutral-100 px-2 shadow-sm transition-colors hover:bg-neutral-200 dark:border-white/[0.08] dark:bg-white/[0.06] dark:hover:bg-white/[0.1]',
'max-w-36' => $sidebar,
])>
<span
class="flex size-5 shrink-0 items-center justify-center rounded-full bg-neutral-200 text-[11px] font-semibold text-neutral-700 dark:bg-white/[0.1] dark:text-fg">
{{ $userInitial }}
</span>
<svg class="size-3.5 shrink-0 text-neutral-400 dark:text-fg-faint transition-transform" :class="open && 'rotate-180'"
@if ($sidebar)
<span class="min-w-0 truncate text-xs font-medium" :class="collapsed && 'hidden'">{{ $userName }}</span>
@endif
<svg class="size-3.5 shrink-0 text-neutral-400 dark:text-fg-faint transition-transform"
:class="[open && 'rotate-180', {{ $sidebar ? "collapsed && 'hidden'" : 'false' }}]"
viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<div x-show="open" x-cloak x-transition.opacity.duration.120ms
class="listbox-panel right-0! left-auto! z-[90]! max-h-none! w-52! min-w-0! overflow-visible!">
@class([
'listbox-panel z-[90]! max-h-none! w-52! min-w-0! overflow-visible!',
'right-0! left-auto!' => ! $sidebar,
'bottom-full! left-0! right-auto! top-auto! mb-1!' => $sidebar,
])>
<div class="min-w-0 px-2 py-1.5">
<div class="truncate text-[13px] font-semibold text-black dark:text-fg">{{ $userName }}</div>
<div class="truncate text-[11px] text-neutral-500 dark:text-fg-faint">{{ $userEmail }}</div>
+3 -3
View File
@@ -56,8 +56,8 @@
<div class="flex-1"></div>
{{-- Dev Server-Timing HUD docks here (local only; empty in production) --}}
<div id="server-timing-hud-slot" data-server-timing-hud-slot class="hidden shrink-0 items-center"></div>
{{-- Right cluster --}}
<x-top-user-menu />
{{-- Resource actions dock here on desktop. --}}
<div id="resource-action-hud-slot" class="hidden shrink-0 items-center xl:flex"></div>
</div>
</header>
@@ -85,7 +85,7 @@
{{-- ============ DESKTOP SIDEBAR (below top bar) ============ --}}
<div class="hidden lg:fixed lg:top-12 lg:bottom-0 lg:left-0 lg:z-40 lg:flex lg:flex-col min-w-0"
:class="[collapsed ? 'lg:w-16' : 'lg:w-56', sidebarReady ? 'transition-[width] duration-200' : '']">
<div class="flex flex-col overflow-y-auto grow scrollbar min-w-0">
<div class="flex grow min-w-0 flex-col overflow-visible">
<x-navbar />
</div>
</div>
@@ -5,7 +5,7 @@
</x-slot>
<livewire:project.shared.configuration-checker :resource="$application" />
<livewire:project.application.heading :application="$application" wire:key="application-heading-deployment-show" />
<section class="application-settings-workspace flex min-h-0 w-full max-w-[1180px] flex-1">
<section class="application-settings-workspace mt-4 flex min-h-0 w-full max-w-[1180px] flex-1 lg:mt-0">
<div class="grid min-h-0 min-w-0 flex-1 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
<x-application.configuration-sidebar :application="$application" :flush="true"
current-route="project.application.deployment.show" />
@@ -183,11 +183,11 @@
</div>
</div>
{{-- Layer-2 top nav: one unified bar menus left, actions right --}}
<div
class="hidden w-full items-center xl:fixed xl:top-14 xl:right-4 xl:z-30 xl:flex xl:h-12 xl:w-auto xl:border-0 xl:bg-transparent xl:p-0 xl:backdrop-blur-none xl:dark:bg-transparent">
{{-- Desktop actions live in the fixed top bar so they cannot overlap page content. --}}
@teleport('#resource-action-hud-slot')
<div class="hidden w-full items-center xl:flex xl:w-auto">
<div
class="resource-heading-navbar application-heading-actions flex w-full min-w-0 items-center justify-start gap-2 overflow-visible rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 xl:w-auto xl:justify-end dark:border-white/[0.07] dark:bg-white/[0.035]">
class="resource-heading-navbar application-heading-actions flex w-full min-w-0 items-center justify-start gap-1 overflow-visible xl:w-auto xl:justify-end">
<div class="resource-heading-actions flex shrink-0 items-center gap-0.5">
{{-- Status badge temporarily hidden will be redesigned later:
<x-status.index :resource="$application" :title="$lastDeploymentInfo" :lastDeploymentLink="$lastDeploymentLink" /> --}}
@@ -282,5 +282,6 @@
</div>
</div>
</div>
@endteleport
</div>
</nav>
@@ -151,9 +151,10 @@
</div>
<div class="hidden w-full items-center xl:fixed xl:top-14 xl:right-4 xl:z-30 xl:flex xl:h-12 xl:w-auto">
@teleport('#resource-action-hud-slot')
<div class="hidden w-full items-center xl:flex xl:w-auto">
<div
class="resource-heading-navbar application-heading-actions flex w-auto min-w-0 items-center justify-end gap-2 overflow-visible rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
class="resource-heading-navbar application-heading-actions flex w-auto min-w-0 items-center justify-end gap-1 overflow-visible">
<div class="resource-heading-actions flex shrink-0 items-center gap-0.5">
@if ($database->destination->server->isFunctional())
@if (! $databaseStatus->startsWith('exited'))
@@ -193,6 +194,7 @@
</div>
</div>
</div>
@endteleport
</div>
@@ -107,10 +107,7 @@
class="group relative flex min-h-28 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]">
<a :href="project.href" {{ wireNavigate() }} class="absolute inset-0 rounded-xl"
:aria-label="`Open ${project.name}`"></a>
<x-reicon name="arrow-right"
class="pointer-events-none absolute top-1/2 right-3 size-3.5 -translate-y-1/2 text-neutral-300 opacity-0 transition-all group-hover:translate-x-0.5 group-hover:opacity-100 dark:text-fg-faint" />
<div class="flex items-start gap-3 pr-6">
<div class="flex items-start gap-3">
<div
class="flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim">
<x-reicon name="projects" class="size-4" />
@@ -183,9 +183,10 @@
</div>
</div>
<div class="hidden w-full items-center xl:fixed xl:top-14 xl:right-4 xl:z-30 xl:flex xl:h-12 xl:w-auto">
@teleport('#resource-action-hud-slot')
<div class="hidden w-full items-center xl:flex xl:w-auto">
<div
class="resource-heading-navbar application-heading-actions flex w-auto min-w-0 items-center justify-end gap-2 overflow-visible rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
class="resource-heading-navbar application-heading-actions flex w-auto min-w-0 items-center justify-end gap-1 overflow-visible">
<div class="resource-heading-actions flex shrink-0 items-center gap-0.5">
@if ($service->isDeployable)
<x-services.advanced :service="$service" />
@@ -229,6 +230,7 @@
</div>
</div>
</div>
@endteleport
</div>
@@ -100,9 +100,7 @@
<template x-for="server in filteredServers" :key="server.uuid">
<a :href="server.href" {{ wireNavigate() }}
class="group relative flex min-h-28 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]">
<x-reicon name="arrow-right"
class="pointer-events-none absolute top-1/2 right-3 size-3.5 -translate-y-1/2 text-neutral-300 opacity-0 transition-all group-hover:translate-x-0.5 group-hover:opacity-100 dark:text-fg-faint" />
<div class="flex items-start gap-3 pr-6">
<div class="flex items-start gap-3">
<div
class="flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim">
<x-reicon name="servers" class="size-4" />
@@ -250,9 +250,10 @@
</div>
</div>
<div class="hidden xl:fixed xl:top-14 xl:right-4 xl:z-30 xl:flex xl:h-12 xl:w-auto xl:items-center">
@teleport('#resource-action-hud-slot')
<div class="hidden xl:flex xl:w-auto xl:items-center">
<div
class="resource-heading-navbar application-heading-actions flex w-auto min-w-0 items-center justify-end gap-2 overflow-visible rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
class="resource-heading-navbar application-heading-actions flex w-auto min-w-0 items-center justify-end gap-1 overflow-visible">
<x-resource-heading-tabs class="hidden" aria-hidden="true">
@foreach ($serverMenuItems as $menuItem)
<a wire:key="server-primary-nav-{{ str($menuItem['label'])->slug() }}"
@@ -327,6 +328,7 @@
@endif
</div>
</div>
@endteleport
</div>
@script
@@ -1,6 +1,6 @@
<?php
it('shows the shared hover arrow on project and server cards', function () {
it('uses card hover animation without a right arrow on project and server cards', function () {
$views = [
resource_path('views/livewire/project/index.blade.php'),
resource_path('views/livewire/server/index.blade.php'),
@@ -10,8 +10,9 @@ it('shows the shared hover arrow on project and server cards', function () {
$contents = file_get_contents($view);
expect($contents)
->toContain('<x-reicon name="arrow-right"')
->toContain('group-hover:translate-x-0.5 group-hover:opacity-100')
->toContain('pointer-events-none absolute top-1/2 right-3');
->toContain('hover:-translate-y-px')
->toContain('hover:shadow-md')
->not->toContain('group-hover:translate-x-0.5 group-hover:opacity-100')
->not->toContain('pointer-events-none absolute top-1/2 right-3');
}
});
@@ -22,9 +22,9 @@ it('uses a single unified navbar for application, service, database, and server
->toContain('justify-start')
->toContain('xl:justify-end')
->toContain('xl:w-auto')
->toContain('xl:bg-transparent')
->toContain('xl:fixed')
->toContain('xl:top-14')
->toContain("@teleport('#resource-action-hud-slot')")
->not->toContain('xl:fixed')
->not->toContain('xl:top-14')
->toContain('xl:hidden')
->not->toContain('application-primary-tabs')
->not->toContain("typeof collapsed !== 'undefined'")
@@ -33,6 +33,55 @@ it('uses a single unified navbar for application, service, database, and server
->not->toContain('border-l border-neutral-200 pl-1');
});
it('docks desktop resource actions in the top bar instead of floating over content', function () {
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
$files = [
resource_path('views/livewire/project/application/heading.blade.php'),
resource_path('views/livewire/project/service/heading.blade.php'),
resource_path('views/livewire/project/database/heading.blade.php'),
resource_path('views/livewire/server/navbar.blade.php'),
];
expect($layout)->toContain('id="resource-action-hud-slot"');
foreach ($files as $path) {
$contents = file_get_contents($path);
expect($contents)
->toContain("@teleport('#resource-action-hud-slot')")
->not->toContain('xl:fixed xl:top-14 xl:right-4');
$desktopHud = str($contents)->after("@teleport('#resource-action-hud-slot')")->before('@endteleport')->toString();
expect($desktopHud)
->not->toContain('rounded-[10px]')
->not->toContain('border-neutral-200')
->not->toContain('bg-neutral-100')
->not->toContain('dark:bg-white/[0.035]');
}
});
it('places the account menu beside the desktop sidebar toggle while retaining it on mobile', function () {
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
$navbar = file_get_contents(resource_path('views/components/navbar.blade.php'));
$accountMenu = file_get_contents(resource_path('views/components/top-user-menu.blade.php'));
expect($layout)
->not->toContain("{{-- Right cluster --}}\n <x-top-user-menu />")
->toContain('<x-top-user-menu />')
->toContain('flex grow min-w-0 flex-col overflow-visible');
expect(substr_count($layout, '<x-top-user-menu />'))->toBe(1);
expect($navbar)
->toContain('<x-top-user-menu sidebar />')
->toContain('Toggle sidebar');
expect($accountMenu)
->toContain("'sidebar' => false")
->toContain("'bottom-full! left-0! right-auto! top-auto! mb-1!' => \$sidebar");
});
it('keeps application links next to advanced actions on the right', function () {
$application = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
$links = file_get_contents(resource_path('views/components/applications/links.blade.php'));
@@ -168,6 +217,17 @@ it('keeps the deployment log sidebar fixed in the layout without a top gap', fun
->and($css)->toContain('overflow: visible;');
});
it('uses the same mobile heading gap on deployment pages as application settings', function () {
$configuration = file_get_contents(resource_path('views/livewire/project/application/configuration.blade.php'));
$deploymentIndex = file_get_contents(resource_path('views/livewire/project/application/deployment/index.blade.php'));
$deploymentShow = file_get_contents(resource_path('views/livewire/project/application/deployment/show.blade.php'));
expect($configuration)->toContain('application-settings-workspace mt-4')
->and($deploymentIndex)->toContain("'mt-4 max-w-[1180px] lg:mt-0' => ! \$embedded")
->and($deploymentShow)->toContain('application-settings-workspace mt-4')
->toContain('lg:mt-0');
});
it('removes desktop top spacing from the deployment log viewer', function () {
$deployment = file_get_contents(resource_path('views/livewire/project/application/deployment/show.blade.php'));