fix(ui): replace the project layer-2 bar with a page header

The project navbar rendered a single Resources tab that was only ever active
on project.resource.index, a page that never rendered the bar, and rendered
nothing at all without an environment, leaving project and environment
settings with no title.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yannick Seeger
2026-08-12 14:20:38 +02:00
co-authored by Claude Opus 5
parent decf6c7a9a
commit a052b4b9e5
9 changed files with 96 additions and 75 deletions
+8
View File
@@ -162,6 +162,14 @@ primary action in the page header instead. When tabs are useful, their left edge
uses the same compact `pl-2` alignment as application navigation rather than
the content container's wide horizontal padding.
A layer-2 tab must be active on the page that renders it. A bar whose only tab
points at a different route reads as broken navigation, so project and
environment pages (`project.show`, `project.edit`, `project.environment.edit`,
`project.clone-me`) carry a plain page header with a 24px title and a 13px
muted summary instead of a bar. The environment identity and the way back to
its resources already live in `x-top-breadcrumb`; do not restate them in a
sub-header.
The dashboard is a compact overview, not a metrics wall. Use two full-width
sections that follow the projects-page grid pattern: projects first, then
servers. Keep one `New` action in the page header and let its modal choose the
@@ -1,58 +0,0 @@
@props([
'project',
'environment' => null,
])
@php
$projectParameters = ['project_uuid' => $project->uuid];
$environmentParameters = $environment
? [...$projectParameters, 'environment_uuid' => $environment->uuid]
: [];
$items = $environment
? [
['label' => 'Resources', 'route' => 'project.resource.index', 'active' => request()->routeIs('project.resource.index'), 'icon' => 'grid'],
]
: [];
$routeParameters = $environment ? $environmentParameters : $projectParameters;
@endphp
@if ($environment)
<nav class="mb-6 w-full lg:mb-0">
<div class="flex w-full items-center lg:fixed lg:top-12 lg:right-0 lg:z-30 lg:h-12 lg:w-auto lg:border-b lg:border-neutral-200 lg:bg-white/95 lg:pr-4 lg:pl-2 lg:backdrop-blur lg:transition-[left] lg:duration-200 lg:dark:border-white/[0.06] lg:dark:bg-panel/95"
:class="[typeof collapsed !== 'undefined' && collapsed ? 'lg:left-16' : 'lg:left-56']">
<div
class="resource-heading-navbar application-heading-actions flex w-full min-w-0 items-center justify-between 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]">
<x-resource-heading-tabs class="min-w-0">
@foreach ($items as $item)
<a @class([
'app-tab shrink-0',
'app-tab-active' => $item['active'],
])
@if ($item['active']) aria-current="page" @endif
{{ wireNavigate() }} href="{{ route($item['route'], $routeParameters) }}">
<x-reicon :name="$item['icon']" class="size-3.5" />
{{ $item['label'] }}
</a>
@endforeach
</x-resource-heading-tabs>
<div class="resource-heading-actions flex shrink-0 items-center gap-0.5 border-l border-neutral-200 pl-1 dark:border-white/[0.08]">
@isset($actions)
{{ $actions }}
@endisset
@can('createAnyResource')
<a href="{{ route('project.resource.create', $environmentParameters) }}" {{ wireNavigate() }}
class="button button-highlighted">
<x-reicon name="plus" class="size-3.5" />
New resource
</a>
@endcan
</div>
</div>
</div>
<div class="hidden lg:block lg:h-12" aria-hidden="true"></div>
</nav>
@endif
@@ -1,8 +1,14 @@
<div>
<x-slot:title>{{ data_get_str($project, 'name')->limit(10) }} > Clone | Coolify</x-slot>
<x-project.navbar :project="$project" :environment="$environment" />
<div class="w-full max-w-[1180px]">
<header class="mb-5">
<h1 class="truncate text-[24px]! leading-7! font-semibold! tracking-tight!">{{ $environment->name }}</h1>
<p class="mt-1 text-[13px] text-neutral-500 dark:text-fg-dim">
Clone this environment inside {{ $project->name }}
</p>
</header>
<div class="mt-8 flex w-full max-w-[1180px] flex-col gap-6 lg:mt-3">
<div class="flex flex-col gap-6">
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
@@ -143,5 +149,6 @@
</div>
</div>
</section>
</div>
</div>
</div>
@@ -1,8 +1,12 @@
<div>
<x-slot:title>{{ data_get_str($project, 'name')->limit(10) }} > Edit | Coolify</x-slot>
<x-project.navbar :project="$project" />
<div class="w-full max-w-[1180px]">
<header class="mb-5">
<h1 class="truncate text-[24px]! leading-7! font-semibold! tracking-tight!">{{ $project->name }}</h1>
<p class="mt-1 text-[13px] text-neutral-500 dark:text-fg-dim">Project settings</p>
</header>
<div class="mt-8 flex w-full max-w-[1180px] flex-col gap-6 lg:mt-3">
<div class="flex flex-col gap-6">
<form wire:submit="submit">
<x-unsaved-bar action="submit" />
<section class="application-settings-section">
@@ -31,5 +35,6 @@
<livewire:project.delete-project :disabled="! $project->isEmpty()" :project_id="$project->id" />
</div>
</section>
</div>
</div>
</div>
@@ -1,8 +1,25 @@
<div>
<x-slot:title>{{ data_get_str($environment, 'name')->limit(10) }} > Edit | Coolify</x-slot>
<x-project.navbar :project="$project" :environment="$environment" />
<div class="w-full max-w-[1180px]">
<header class="mb-5 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0">
<h1 class="truncate text-[24px]! leading-7! font-semibold! tracking-tight!">{{ $environment->name }}</h1>
<p class="mt-1 text-[13px] text-neutral-500 dark:text-fg-dim">
Environment settings in {{ $project->name }}
</p>
</div>
@can('createAnyResource')
<div class="flex w-fit shrink-0 items-center gap-2">
<a class="button whitespace-nowrap" {{ wireNavigate() }}
href="{{ route('project.clone-me', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid]) }}">
<x-reicon name="layers" class="size-3.5 opacity-70" />
Clone environment
</a>
</div>
@endcan
</header>
<div class="mt-8 flex w-full max-w-[1180px] flex-col gap-6 lg:mt-3">
<div class="flex flex-col gap-6">
<form wire:submit="submit">
<x-unsaved-bar action="submit" />
<section class="application-settings-section">
@@ -11,13 +28,6 @@
<h2>Environment details</h2>
<p>Name and describe this environment inside {{ $project->name }}.</p>
</div>
@can('createAnyResource')
<a class="button" {{ wireNavigate() }}
href="{{ route('project.clone-me', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid]) }}">
<x-reicon name="layers" class="size-3.5 opacity-70" />
Clone environment
</a>
@endcan
</div>
<div class="application-settings-section-body grid gap-4 sm:grid-cols-2">
<x-forms.input label="Name" id="name" canGate="update" :canResource="$environment" />
@@ -44,5 +54,6 @@
</div>
</section>
@endcan
</div>
</div>
</div>
@@ -2,8 +2,6 @@
<x-slot:title>
{{ data_get_str($project, 'name')->limit(10) }} > Environments | Coolify
</x-slot>
<x-project.navbar :project="$project" />
<div x-data="projectEnvironments()" class="w-full">
<header class="mb-5 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0">
-1
View File
@@ -16,7 +16,6 @@ test('dashboard navbar hides family titles at lg to match the desktop shell', fu
test('fixed layer-2 spacers match the fixed bar height', function () {
$paths = [
resource_path('views/components/dashboard/navbar.blade.php'),
resource_path('views/components/project/navbar.blade.php'),
resource_path('views/livewire/server/navbar.blade.php'),
resource_path('views/livewire/project/application/heading.blade.php'),
resource_path('views/livewire/project/database/heading.blade.php'),
@@ -0,0 +1,52 @@
<?php
/**
* Project and environment pages carry their own page header instead of a
* layer-2 bar. The former `x-project.navbar` rendered a single "Resources"
* tab that was only ever active on `project.resource.index` (a page that did
* not render the bar), and rendered nothing at all without an environment.
*/
$projectPages = [
'views/livewire/project/show.blade.php',
'views/livewire/project/edit.blade.php',
'views/livewire/project/environment-edit.blade.php',
'views/livewire/project/clone-me.blade.php',
];
it('drops the project layer-2 bar that never matched the current route', function () use ($projectPages) {
expect(file_exists(resource_path('views/components/project/navbar.blade.php')))->toBeFalse();
foreach ($projectPages as $page) {
expect(file_get_contents(resource_path($page)))
->not->toContain('<x-project.navbar')
->not->toContain('resource-heading-navbar');
}
});
it('gives every project and environment page an in-flow title', function () use ($projectPages) {
foreach ($projectPages as $page) {
expect(file_get_contents(resource_path($page)))
->toContain('<h1 class="truncate text-[24px]! leading-7! font-semibold! tracking-tight!">')
->toContain('class="mt-1 text-[13px] text-neutral-500 dark:text-fg-dim"');
}
});
it('keeps the environment clone action in the page header', function () {
$view = file_get_contents(resource_path('views/livewire/project/environment-edit.blade.php'));
expect($view)
->toContain('{{ $environment->name }}</h1>')
->toContain('Environment settings in {{ $project->name }}')
->toContain('Clone environment')
// The action moved out of the card header into the page header.
->toMatch('/<header[^>]*>.*Clone environment.*<\/header>/s');
});
it('does not repeat the resources action outside the resources page', function () {
foreach (['views/livewire/project/environment-edit.blade.php', 'views/livewire/project/clone-me.blade.php'] as $page) {
expect(file_get_contents(resource_path($page)))->not->toContain('New resource');
}
expect(file_get_contents(resource_path('views/livewire/project/resource/index.blade.php')))
->toContain('New resource');
});
@@ -470,7 +470,6 @@ it('shows application and service Links on mobile headings', function () {
it('uses overflow scroll arrows on resource heading navbars', function () {
$files = [
resource_path('views/livewire/server/navbar.blade.php'),
resource_path('views/components/project/navbar.blade.php'),
];
foreach ($files as $path) {