coolify/resources/views/components/application/configuration-sidebar.blade.php
Andras Bacsai 0afa59bc02 fix(ui): keep settings subnav in-page and persist sidebar collapse
Scroll active application settings sections with buttons instead of Livewire navigation, persist sidebar collapse even with auto-collapse, drop the unused modal id, and right-align destination status.
2026-09-08 09:20:38 +02:00

342 lines
19 KiB
PHP

@props(['application', 'currentRoute', 'flush' => false])
@php
$applicationRouteParameters = [
'project_uuid' => $application->environment->project->uuid,
'environment_uuid' => $application->environment->uuid,
'application_uuid' => $application->uuid,
];
$configurationMenuItems = [
[
'label' => 'General',
'route' => 'project.application.configuration',
'active' => $currentRoute === 'project.application.configuration',
],
[
'label' => 'Domains',
'route' => 'project.application.domains',
'active' => $currentRoute === 'project.application.domains',
],
[
'label' => 'Advanced',
'route' => 'project.application.advanced',
'active' => $currentRoute === 'project.application.advanced',
],
[
'label' => 'Swarm',
'route' => 'project.application.swarm',
'active' => $currentRoute === 'project.application.swarm',
'visible' => $application->destination->server->isSwarm(),
],
[
'label' => 'Environment Variables',
'route' => 'project.application.environment-variables',
'active' => $currentRoute === 'project.application.environment-variables',
],
[
'label' => 'Persistent Storage',
'route' => 'project.application.persistent-storage',
'active' => $currentRoute === 'project.application.persistent-storage',
],
[
'label' => 'Backups',
'route' => 'project.application.backup.index',
'active' => str($currentRoute)->startsWith('project.application.backup'),
],
[
'label' => 'Terminal',
'route' => 'project.application.command',
'active' => $currentRoute === 'project.application.command',
'navigate' => false,
'visible' => ! $application->destination->server->isSwarm() && auth()->user()?->can('canAccessTerminal'),
],
[
'label' => 'Deployment Logs',
'route' => 'project.application.deployment.index',
'active' => str($currentRoute)->startsWith('project.application.deployment'),
],
[
'label' => 'Runtime Logs',
'route' => 'project.application.logs',
'active' => $currentRoute === 'project.application.logs',
],
[
'label' => 'Git Source',
'route' => 'project.application.source',
'active' => $currentRoute === 'project.application.source',
'visible' => $application->git_based(),
],
[
'label' => 'Servers',
'route' => 'project.application.servers',
'active' => $currentRoute === 'project.application.servers',
'badge' => true,
],
[
'label' => 'Scheduled Tasks',
'route' => 'project.application.scheduled-tasks.show',
'active' => str($currentRoute)->startsWith('project.application.scheduled-tasks'),
],
[
'label' => 'Webhooks',
'route' => 'project.application.webhooks',
'active' => $currentRoute === 'project.application.webhooks',
],
[
'label' => 'Preview Deployments',
'route' => 'project.application.preview-deployments',
'active' => $currentRoute === 'project.application.preview-deployments',
'visible' => $application->git_based() || $application->build_pack === 'dockerimage',
],
[
'label' => 'Healthcheck',
'route' => 'project.application.healthcheck',
'active' => $currentRoute === 'project.application.healthcheck',
'visible' => $application->build_pack !== 'dockercompose',
],
[
'label' => 'Rollback',
'route' => 'project.application.rollback',
'active' => $currentRoute === 'project.application.rollback',
],
[
'label' => 'Resource Limits',
'route' => 'project.application.resource-limits',
'active' => $currentRoute === 'project.application.resource-limits',
],
[
'label' => 'Resource Operations',
'route' => 'project.application.resource-operations',
'active' => $currentRoute === 'project.application.resource-operations',
],
[
'label' => 'Metrics',
'route' => 'project.application.metrics',
'active' => $currentRoute === 'project.application.metrics',
],
[
'label' => 'Tags',
'route' => 'project.application.tags',
'active' => $currentRoute === 'project.application.tags',
],
[
'label' => 'Danger Zone',
'route' => 'project.application.danger',
'active' => $currentRoute === 'project.application.danger',
],
];
$configurationMenuItems = array_values(array_filter(
$configurationMenuItems,
fn (array $item): bool => $item['visible'] ?? true,
));
// Icons follow the main sidebar's reicon set
$menuIcons = [
'General' => 'settings',
'Domains' => 'globe',
'Advanced' => 'grid',
'Swarm' => 'destinations',
'Environment Variables' => 'variables',
'Persistent Storage' => 'storages',
'Backups' => 'database',
'Terminal' => 'browser-terminal',
'Deployment Logs' => 'time-back',
'Runtime Logs' => 'unordered-list',
'Git Source' => 'sources',
'Servers' => 'servers',
'Scheduled Tasks' => 'calendar',
'Webhooks' => 'notifications',
'Preview Deployments' => 'eye',
'Healthcheck' => 'feedback',
'Rollback' => 'time-back',
'Resource Limits' => 'cpu',
'Resource Operations' => 'server-update',
'Metrics' => 'graph',
'Tags' => 'tags',
'Danger Zone' => 'shield-alert',
];
// Discord-style groups for the settings sidebar
$menuGroups = [
'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage', 'Advanced', 'Swarm', 'Healthcheck'],
'Observe & troubleshoot' => ['Runtime Logs', 'Deployment Logs', 'Terminal', 'Metrics'],
'Deploy' => ['Git Source', 'Servers', 'Preview Deployments'],
'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups'],
'Operations' => ['Resource Operations', 'Resource Limits', 'Rollback', 'Tags', 'Danger Zone'],
];
$groupedMenuItems = collect($menuGroups)
->map(fn (array $labels) => collect($labels)
->map(fn (string $label) => collect($configurationMenuItems)->firstWhere('label', $label))
->filter()
->values())
->filter(fn ($items) => $items->isNotEmpty());
// In-page sections (cards) shown as sub-items under the active page
$isComposeApp = $application->build_pack === 'dockercompose';
$pageSections = [
'project.application.configuration' => array_values(array_filter([
['id' => 'application-details-section', 'label' => 'Application details'],
['id' => 'access-section', 'label' => 'Access'],
['id' => 'build-pipeline-section', 'label' => 'Build pipeline'],
$isComposeApp ? null : ['id' => 'container-image-section', 'label' => 'Container image'],
$isComposeApp ? null : ['id' => 'networking-section', 'label' => 'Networking'],
$isComposeApp ? null : ['id' => 'runtime-section', 'label' => 'Runtime'],
$isComposeApp ? null : ['id' => 'security-section', 'label' => 'Security'],
['id' => 'deployment-lifecycle-section', 'label' => 'Deployment lifecycle'],
$isComposeApp ? null : ['id' => 'container-labels-section', 'label' => 'Container labels'],
])),
'project.application.advanced' => array_values(array_filter([
['id' => 'advanced-build-section', 'label' => 'Build'],
['id' => 'advanced-container-section', 'label' => 'Container'],
$application->git_based() ? ['id' => 'advanced-deployment-section', 'label' => 'Deployment'] : null,
$application->git_based() ? ['id' => 'advanced-git-section', 'label' => 'Git'] : null,
$isComposeApp ? ['id' => 'advanced-compose-section', 'label' => 'Docker compose'] : null,
['id' => 'advanced-proxy-section', 'label' => 'Proxy'],
['id' => 'advanced-operations-section', 'label' => 'Operations'],
['id' => 'advanced-logs-section', 'label' => 'Logs'],
$isComposeApp ? null : ['id' => 'advanced-gpu-section', 'label' => 'GPU'],
])),
'project.application.webhooks' => [
['id' => 'deploy-webhook-section', 'label' => 'Deploy webhook'],
['id' => 'manual-git-webhooks-section', 'label' => 'Manual Git webhooks'],
],
'project.application.preview-deployments' => array_values(array_filter([
['id' => 'preview-template-section', 'label' => 'URL template'],
$application->is_github_based()
? ['id' => 'preview-pull-requests-section', 'label' => 'Pull requests']
: null,
$application->build_pack === 'dockerimage'
? ['id' => 'manual-preview-section', 'label' => 'Manual preview']
: null,
['id' => 'preview-deployments-section', 'label' => 'Deployments'],
])),
'project.application.healthcheck' => [
['id' => 'healthcheck-configuration-section', 'label' => 'Configuration'],
[
'id' => $application->health_check_type === 'cmd'
? 'healthcheck-command-section'
: 'healthcheck-request-section',
'label' => $application->health_check_type === 'cmd' ? 'Command' : 'HTTP request',
],
['id' => 'healthcheck-timing-section', 'label' => 'Timing and retries'],
],
'project.application.rollback' => [
['id' => 'rollback-retention-section', 'label' => 'Image retention'],
['id' => 'rollback-images-section', 'label' => 'Available images'],
],
'project.application.resource-limits' => [
['id' => 'cpu-limits-section', 'label' => 'CPU'],
['id' => 'memory-limits-section', 'label' => 'Memory'],
],
'project.application.resource-operations' => [
['id' => 'clone-destination-section', 'label' => 'Clone destination'],
['id' => 'clone-environment-section', 'label' => 'Clone environment'],
['id' => 'move-resource-section', 'label' => 'Move resource'],
],
];
@endphp
@php
$activeMenuLabel = collect($groupedMenuItems)->flatMap(fn ($items) => $items)->firstWhere('active', true)['label'] ?? 'Settings';
@endphp
<aside @class([
'application-settings-navigation min-w-0 xl:self-start',
'is-flush' => $flush,
])
x-data="{
menuOpen: false,
desktop: window.matchMedia('(min-width: 1280px)').matches,
init() {
const mq = window.matchMedia('(min-width: 1280px)');
mq.addEventListener('change', (e) => { this.desktop = e.matches; if (e.matches) { this.menuOpen = false; } });
},
}"
x-on:click.outside="menuOpen = false"
x-on:keydown.escape.window="menuOpen = false">
{{-- Mobile disclosure: tap to reveal the full grouped nav; hidden at xl (the pinned rail). --}}
<button type="button" x-show="!desktop" x-cloak x-on:click="menuOpen = !menuOpen"
:aria-expanded="menuOpen" aria-label="Configuration menu"
:class="menuOpen && 'ring-1 ring-black/10 dark:ring-white/15'"
class="flex h-10 w-full items-center justify-between gap-2 rounded-xl border border-neutral-200 bg-white px-3 text-[13px] font-medium text-black transition-transform duration-100 ease-out hover:bg-neutral-50 active:scale-[0.985] dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg dark:hover:bg-white/[0.08]">
<span class="flex min-w-0 items-center gap-2">
<x-reicon name="settings" class="size-4 shrink-0 text-nav-muted" />
<span class="truncate">{{ $activeMenuLabel }}</span>
</span>
<svg class="size-3.5 shrink-0 text-nav-muted transition-transform duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
:class="menuOpen && 'rotate-180'" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="m3.5 4.75 2.5 2.5 2.5-2.5" stroke="currentColor" stroke-width="1.25"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<nav aria-label="Configuration sections" x-show="desktop || menuOpen" x-collapse.duration.200ms x-cloak
class="mt-2 flex flex-col gap-0.5 rounded-xl border border-neutral-200 bg-white p-2 shadow-[var(--shadow-dropdown)] dark:border-white/[0.08] dark:bg-white/[0.03] xl:mt-0 xl:rounded-none xl:border-0 xl:bg-transparent xl:p-0 xl:shadow-none xl:dark:bg-transparent">
@foreach ($groupedMenuItems as $groupLabel => $groupItems)
@unless ($loop->first)
<div class="my-2 border-t border-neutral-200 dark:border-white/[0.06]" aria-hidden="true"></div>
@endunless
<div class="nav-section">{{ $groupLabel }}</div>
@foreach ($groupItems as $menuItem)
@php $sections = $pageSections[$menuItem['route']] ?? []; @endphp
<div wire:key="application-settings-group-{{ str($menuItem['label'])->slug() }}"
@if (filled($sections)) x-data="{ open: @js($menuItem['active']), activeSection: '' }" @endif
class="relative">
<a wire:key="application-settings-link-{{ str($menuItem['label'])->slug() }}"
@class([
'menu-item',
'menu-item-active' => $menuItem['active'],
'pr-9' => filled($sections),
])
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $applicationRouteParameters) }}"
x-on:click="menuOpen = false">
<x-reicon :name="$menuIcons[$menuItem['label']] ?? 'settings'" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
@if ($menuItem['badge'] ?? false)
<span class="shrink-0">
<livewire:project.application.server-status-badge :application="$application"
:key="'application-server-status-'.$application->uuid" />
</span>
@endif
</a>
@if (filled($sections))
{{-- Expand/collapse the page's sub-sections. Active page opens by
default; the label still navigates, the chevron only toggles. --}}
<button type="button"
class="absolute right-1 top-1 flex size-6 items-center justify-center rounded-md text-nav-muted transition-colors hover:bg-black/[0.04] hover:text-nav-active dark:hover:bg-white/[0.06]"
x-on:click.stop.prevent="open = !open" :aria-expanded="open"
aria-label="Toggle {{ $menuItem['label'] }} sections">
<svg class="size-3.5 transition-transform duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
:class="open && 'rotate-90'" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="m4.5 3 3 3-3 3" stroke="currentColor" stroke-width="1.25"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<div>
<div x-show="open" x-collapse.duration.200ms x-cloak
class="nav-children flex flex-col gap-0.5 py-1">
@foreach ($sections as $section)
@if ($menuItem['active'])
<button type="button" class="menu-subitem"
:class="activeSection === '{{ $section['id'] }}' && 'menu-subitem-active'"
x-on:click="menuOpen = false; activeSection = '{{ $section['id'] }}'; history.replaceState(null, '', '#{{ $section['id'] }}'); window.scrollToSettingsSection?.('{{ $section['id'] }}')">
<span class="menu-item-label text-left">{{ $section['label'] }}</span>
</button>
@else
<a class="menu-subitem"
href="{{ route($menuItem['route'], $applicationRouteParameters) }}#{{ $section['id'] }}"
{{ wireNavigate() }} x-on:click="menuOpen = false">
<span class="menu-item-label text-left">{{ $section['label'] }}</span>
</a>
@endif
@endforeach
</div>
</div>
@endif
</div>
@endforeach
@endforeach
</nav>
</aside>