feat(ui): polish email modal, search loading, and volume tables

Move profile email change into a modal with Alpine focus/close handling,
show a loading overlay during command-palette navigation, and adjust
volumes/config-diff grids plus related service/storage UI. Cover with
feature tests for search, profile, storage, and domains.
This commit is contained in:
Andras Bacsai
2026-08-05 14:07:19 +02:00
parent a7a06aa6c8
commit 0312cc8ffb
18 changed files with 236 additions and 109 deletions
+12 -7
View File
@@ -1881,17 +1881,17 @@ input[type="search"]::-webkit-search-results-decoration {
grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem minmax(8rem, 1fr) 5rem;
}
/* Persistent storage volumes: Name | Source | Destination | [PR suffix] | [Actions] */
/* Persistent storage volumes: Name | Source | Destination | [PR suffix] | Backup | [Actions] */
.volumes-table-grid-readonly {
grid-template-columns: minmax(12rem, 1.5fr) minmax(8rem, 1fr) minmax(8rem, 1fr);
grid-template-columns: minmax(12rem, 1.5fr) minmax(8rem, 1fr) minmax(8rem, 1fr) 5rem;
}
.volumes-table-grid {
grid-template-columns: minmax(10rem, 1.4fr) minmax(6rem, 1fr) minmax(6rem, 1fr) minmax(10.5rem, auto);
grid-template-columns: minmax(10rem, 1.4fr) minmax(6rem, 1fr) minmax(6rem, 1fr) 5rem 17.5rem;
}
.volumes-table-grid-with-pr {
grid-template-columns: minmax(9rem, 1.2fr) minmax(5.5rem, 0.85fr) minmax(5.5rem, 0.85fr) 9.25rem minmax(10.5rem, auto);
grid-template-columns: minmax(9rem, 1.2fr) minmax(5.5rem, 0.85fr) minmax(5.5rem, 0.85fr) 9.25rem 5rem 17.5rem;
}
.volumes-mobile-label {
@@ -1927,7 +1927,7 @@ input[type="search"]::-webkit-search-results-decoration {
@media (max-width: 1100px) {
.volumes-table-grid {
grid-template-columns: minmax(9rem, 1.2fr) minmax(6rem, 1fr) minmax(9rem, auto);
grid-template-columns: minmax(9rem, 1.2fr) minmax(6rem, 1fr) 5rem 17.5rem;
}
.volumes-table-grid > .volumes-col-source,
@@ -1936,7 +1936,7 @@ input[type="search"]::-webkit-search-results-decoration {
}
.volumes-table-grid-with-pr {
grid-template-columns: minmax(9rem, 1.1fr) minmax(6rem, 1fr) 8.5rem minmax(9rem, auto);
grid-template-columns: minmax(9rem, 1.1fr) minmax(6rem, 1fr) 8.5rem 5rem 17.5rem;
}
.volumes-table-grid-with-pr > .volumes-col-source,
@@ -1945,7 +1945,7 @@ input[type="search"]::-webkit-search-results-decoration {
}
.volumes-table-grid-readonly {
grid-template-columns: minmax(10rem, 1.4fr) minmax(8rem, 1fr);
grid-template-columns: minmax(10rem, 1.4fr) minmax(8rem, 1fr) 5rem;
}
.volumes-table-grid-readonly > .volumes-col-source,
@@ -2001,6 +2001,7 @@ input[type="search"]::-webkit-search-results-decoration {
}
.volumes-cell-name,
.volumes-col-backup,
.volumes-cell-dest,
.volumes-cell-actions {
display: flex;
@@ -2010,6 +2011,10 @@ input[type="search"]::-webkit-search-results-decoration {
width: 100%;
}
.volumes-col-backup {
align-items: flex-start;
}
.volumes-cell-actions {
flex-direction: row;
flex-wrap: wrap;
@@ -19,7 +19,7 @@
<div class="overflow-x-auto rounded-lg ring-1 ring-neutral-200 dark:ring-white/[0.08]">
<div class="min-w-[640px]">
<div
class="grid grid-cols-[minmax(12rem,1.5fr)_minmax(8rem,1fr)_1.5rem_minmax(8rem,1fr)_1.75rem] items-center gap-3 bg-neutral-100 px-3 py-2 text-[11px] font-medium text-neutral-500 dark:bg-white/[0.04] dark:text-fg-faint">
class="grid grid-cols-[minmax(10rem,0.8fr)_minmax(14rem,1fr)_1.5rem_minmax(14rem,1fr)_1.75rem] items-center gap-3 bg-neutral-100 px-3 py-2 text-[11px] font-medium text-neutral-500 dark:bg-white/[0.04] dark:text-fg-faint">
<div>Field</div>
<div>Current</div>
<div></div>
@@ -42,7 +42,7 @@
@endphp
<div
class="grid grid-cols-[minmax(12rem,1.5fr)_minmax(8rem,1fr)_1.5rem_minmax(8rem,1fr)_1.75rem] items-start gap-3 px-3 py-2.5 text-sm transition-colors hover:bg-neutral-50 dark:hover:bg-white/[0.025]">
class="grid grid-cols-[minmax(10rem,0.8fr)_minmax(14rem,1fr)_1.5rem_minmax(14rem,1fr)_1.75rem] items-start gap-3 px-3 py-2.5 text-sm transition-colors hover:bg-neutral-50 dark:hover:bg-white/[0.025]">
<div class="min-w-0 font-medium text-neutral-900 dark:text-fg">
@if ($rowExpandable)
<div class="break-words"
@@ -3,6 +3,7 @@
selectedIndex: -1,
isSearching: false,
isLoadingInitialData: false,
isPaletteTransitioning: false,
allSearchableItems: [],
searchQuery: '',
creatableItems: [],
@@ -97,6 +98,7 @@
this.isLoadingInitialData = false;
this.searchQuery = '';
this.allSearchableItems = [];
this.isPaletteTransitioning = false;
// Ensure scroll is restored
document.body.style.overflow = '';
// Use $wire instead of @this for SPA navigation compatibility
@@ -104,6 +106,12 @@
$wire.closeSearchModal();
}
},
runPaletteTransition(callback) {
this.isPaletteTransitioning = true;
return Promise.resolve(callback()).finally(() => {
this.isPaletteTransitioning = false;
});
},
navigateResults(direction) {
const results = document.querySelectorAll('.search-result-item');
if (results.length === 0) return;
@@ -162,7 +170,7 @@
});
if (matchingItem && typeof $wire !== 'undefined' && $wire) {
$wire.navigateToResource(matchingItem.type);
this.runPaletteTransition(() => $wire.navigateToResource(matchingItem.type));
}
}
});
@@ -313,14 +321,21 @@
</div>
<!-- Search results -->
<div x-show="searchQuery.length >= 1" x-cloak class="command-palette-body">
<div x-show="searchQuery.length >= 1" x-cloak class="command-palette-body relative">
<div x-show="isPaletteTransitioning" x-cloak
class="absolute inset-0 z-30 flex items-center justify-center bg-white/50 backdrop-blur-[2px] dark:bg-black/40">
<x-loading text="Loading…" />
</div>
@if ($isSelectingResource)
<!-- Resource selection flow -->
<div class="command-palette-section">
<div class="relative min-h-32">
<div class="command-palette-section transition-all"
wire:loading.class="pointer-events-none opacity-40 blur-[2px]"
wire:target="selectServer,selectDestination,selectProject,selectEnvironment">
@if ($selectedServerId === null)
<div x-init="selectedIndex = -1">
<div class="command-palette-step-header">
<button type="button" @click="$wire.goBack()" class="command-palette-step-back"
<button type="button" @click="runPaletteTransition(() => $wire.goBack())" class="command-palette-step-back"
title="Back">
<x-reicon name="arrow-right" class="size-3.5 rotate-180" />
</button>
@@ -367,7 +382,7 @@
@if ($selectedServerId !== null && $selectedDestinationUuid === null)
<div x-init="selectedIndex = -1">
<div class="command-palette-step-header">
<button type="button" @click="$wire.goBack()" class="command-palette-step-back"
<button type="button" @click="runPaletteTransition(() => $wire.goBack())" class="command-palette-step-back"
title="Back">
<x-reicon name="arrow-right" class="size-3.5 rotate-180" />
</button>
@@ -415,7 +430,7 @@
@if ($selectedDestinationUuid !== null && $selectedProjectUuid === null)
<div x-init="selectedIndex = -1">
<div class="command-palette-step-header">
<button type="button" @click="$wire.goBack()" class="command-palette-step-back"
<button type="button" @click="runPaletteTransition(() => $wire.goBack())" class="command-palette-step-back"
title="Back">
<x-reicon name="arrow-right" class="size-3.5 rotate-180" />
</button>
@@ -463,7 +478,7 @@
@if ($selectedProjectUuid !== null && $selectedEnvironmentUuid === null)
<div x-init="selectedIndex = -1">
<div class="command-palette-step-header">
<button type="button" @click="$wire.goBack()" class="command-palette-step-back"
<button type="button" @click="runPaletteTransition(() => $wire.goBack())" class="command-palette-step-back"
title="Back">
<x-reicon name="arrow-right" class="size-3.5 rotate-180" />
</button>
@@ -509,6 +524,12 @@
@endif
</div>
@endif
</div>
<div wire:loading.flex
wire:target="selectServer,selectDestination,selectProject,selectEnvironment"
class="absolute inset-0 z-10 hidden items-center justify-center bg-white/40 backdrop-blur-[1px] dark:bg-black/30">
<x-loading text="Loading selection…" />
</div>
</div>
@elseif ($isCreateMode && count($this->filteredCreatableItems) > 0 && !$autoOpenResource)
<!-- Create mode (server-rendered path) -->
@@ -569,7 +590,7 @@
<div class="command-palette-section">
<div class="command-palette-group-label">{{ $category }}</div>
@foreach ($items as $item)
<button type="button" wire:click="navigateToResource('{{ $item['type'] }}')"
<button type="button" @click="runPaletteTransition(() => $wire.navigateToResource('{{ $item['type'] }}'))"
class="search-result-item command-palette-item">
@if (!empty($item['logo']))
<div class="command-palette-item-icon">
@@ -646,7 +667,7 @@
<div class="command-palette-section">
<div class="command-palette-group-label" x-text="categoryName"></div>
<template x-for="item in items" :key="item.type">
<button type="button" @click="$wire.navigateToResource(item.type)"
<button type="button" @click="runPaletteTransition(() => $wire.navigateToResource(item.type))"
class="search-result-item command-palette-item">
<template x-if="item.logo">
<div class="command-palette-item-icon">
@@ -1,4 +1,11 @@
<div>
<div x-data="{
emailModalOpen: @js($show_verification),
openEmailModal() {
this.emailModalOpen = true;
this.$nextTick(() => this.$refs.newEmailInput?.focus());
},
}"
@close-email-change-modal.window="emailModalOpen = false">
<x-slot:title>Profile | Coolify</x-slot>
<x-profile.navbar />
@@ -16,8 +23,8 @@
<x-forms.input id="name" label="Name" required />
<div class="flex items-end gap-2">
<x-forms.input id="email" label="Email" readonly />
<x-forms.button wire:click="showEmailChangeForm" type="button"
:disabled="$show_email_change || $show_verification">
<x-forms.button @click="openEmailModal()" type="button"
x-bind:disabled="emailModalOpen">
Change
</x-forms.button>
</div>
@@ -25,53 +32,56 @@
</section>
</form>
@if ($show_email_change)
<form wire:submit="requestEmailChange">
<section class="application-settings-section">
<div class="application-settings-section-header">
<template x-teleport="body">
<div x-show="emailModalOpen" x-cloak
class="fixed inset-0 z-99 flex h-screen w-screen items-center justify-center p-4">
<div class="absolute inset-0 h-full w-full bg-black/55 backdrop-blur-[3px]"></div>
<div x-show="emailModalOpen" x-trap.inert.noscroll="emailModalOpen"
class="application-settings-form application-settings-section relative w-full max-w-xl overflow-hidden"
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
<header>
<div>
<h2>Change email</h2>
<p>A six-digit verification code will be sent to the new address.</p>
</div>
</div>
<div class="application-settings-section-body">
<div class="flex max-w-xl items-end gap-2">
<x-forms.input id="new_email" label="New email address" required type="email" />
<x-forms.button type="submit">Send code</x-forms.button>
<x-forms.button wire:click="$set('show_email_change', false)" type="button">
Cancel
</x-forms.button>
</div>
</div>
</section>
</form>
@endif
@if ($show_verification)
<form wire:submit="verifyEmailChange">
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
<h2>Verify new email</h2>
<p>
Code sent to {{ $new_email ?? auth()->user()->pending_email }}.
It expires after {{ config('constants.email_change.verification_code_expiry_minutes', 10) }}
minutes.
<h3>{{ $show_verification ? 'Verify new email' : 'Change email' }}</h3>
<p class="mt-1 text-xs text-neutral-500 dark:text-fg-dim">
@if ($show_verification)
Code sent to {{ $new_email ?: auth()->user()->pending_email }}.
@else
A six-digit verification code will be sent to the new address.
@endif
</p>
</div>
</div>
<div class="application-settings-section-body">
<div class="flex max-w-xl items-end gap-2">
<button type="button"
@click="@if ($show_verification) $wire.cancelEmailChange().then(() => emailModalOpen = false) @else emailModalOpen = false @endif"
class="icon-button shrink-0" aria-label="Close">
<x-reicon name="x" class="size-4" />
</button>
</header>
@if ($show_verification)
<form wire:submit="verifyEmailChange" class="application-settings-section-body space-y-4">
<x-forms.input id="email_verification_code" label="Verification code" required
inputmode="numeric" maxlength="6" />
<x-forms.button type="submit">Verify email</x-forms.button>
<x-forms.button wire:click="resendVerificationCode" type="button">Resend</x-forms.button>
<x-forms.button wire:click="cancelEmailChange" type="button">Cancel</x-forms.button>
</div>
</div>
</section>
</form>
@endif
<p class="text-xs text-neutral-500 dark:text-fg-dim">
The code expires after
{{ config('constants.email_change.verification_code_expiry_minutes', 10) }} minutes.
</p>
<div class="flex justify-end gap-2">
<x-forms.button wire:click="resendVerificationCode" type="button">Resend code</x-forms.button>
<x-forms.button type="submit" isHighlighted>Verify email</x-forms.button>
</div>
</form>
@else
<form wire:submit="requestEmailChange" class="application-settings-section-body space-y-4">
<x-forms.input id="new_email" label="New email address" required type="email"
x-ref="newEmailInput" />
<div class="flex justify-end">
<x-forms.button type="submit" isHighlighted>Send code</x-forms.button>
</div>
</form>
@endif
</div>
</div>
</template>
<form wire:submit="resetPassword">
<section class="application-settings-section">
@@ -202,7 +202,7 @@
{{ $backup->targetName() }}
</span>
<span>{{ $backup->targetType() }}</span>
<span class="font-mono text-xs">{{ $backup->frequency }}</span>
<span>{{ $backup->frequency }}</span>
<span><x-status-badge :status="$statusLabel" :type="$statusType" /></span>
<span>
{{ $latestExecution?->finished_at?->diffForHumans() ?? ($status === 'running' ? 'Running now' : 'Never') }}
@@ -38,6 +38,13 @@
$groupedItems = collect($menuGroups)
->map(fn (array $labels) => $configurationItems->whereIn('label', $labels)->values())
->filter(fn ($items) => $items->isNotEmpty());
$storageSections = $applications
->concat($databases)
->map(fn ($resource): array => [
'id' => 'storage-service-'.$resource->uuid,
'label' => Str::headline($resource->name),
]);
@endphp
<section class="application-settings-workspace mt-4 w-full max-w-[1180px] lg:mt-0">
@@ -61,6 +68,19 @@
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
</a>
@if ($menuItem['active'] && $menuItem['route'] === 'project.service.storages' && $storageSections->isNotEmpty())
<div class="nav-children hidden flex-col gap-0.5 py-1 xl:flex"
x-data="{ activeSection: '' }">
@foreach ($storageSections as $section)
<button type="button" class="menu-subitem"
:class="activeSection === '{{ $section['id'] }}' && 'menu-subitem-active'"
@click="activeSection = '{{ $section['id'] }}'; window.scrollToSettingsSection?.('{{ $section['id'] }}')">
<span class="menu-item-label truncate text-left"
title="{{ $section['label'] }}">{{ $section['label'] }}</span>
</button>
@endforeach
</div>
@endif
@endforeach
@endforeach
</nav>
@@ -112,6 +132,10 @@
<livewire:project.shared.environment-variable.all :resource="$service" />
@elseif ($currentRoute === 'project.service.storages')
<div class="space-y-6">
<div
class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-[13px] leading-5 text-amber-800 dark:border-warning/15 dark:bg-warning/[0.07] dark:text-amber-300/90">
Service volume mounts are read-only here. Edit the Docker Compose file and reload it to change volumes.
</div>
@foreach ($applications as $application)
<livewire:project.service.storage wire:key="application-{{ $application->id }}"
:resource="$application" />
@@ -67,9 +67,6 @@
@if ($isSuggested && ! empty($row['suggestion_label']))
<span class="table-badge table-badge-warning shrink-0">{{ $row['suggestion_label'] }}</span>
@endif
@if ($showServiceColumn)
<span class="domains-service-mobile table-badge shrink-0">{{ $serviceLabel }}</span>
@endif
</div>
@if ($isSuggested && filled($row['dns_message']))
<p class="text-[12px] leading-4 text-amber-700 sm:truncate dark:text-amber-400/90"
@@ -416,7 +416,7 @@
</x-application.settings-section>
@else
{{-- Service stack resources: one settings card + table per service --}}
<x-application.settings-section :id="'storage-service-'.$resource->id"
<x-application.settings-section :id="'storage-service-'.$resource->uuid"
:title="Str::headline($resource->name)" :flush="true"
helper="Volume mounts for this compose service. Compose-managed mounts are read-only in the dashboard.">
<x-slot:actions>
@@ -47,7 +47,7 @@
x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
class="application-settings-form application-settings-section relative max-h-[calc(100dvh-2rem)] w-full max-w-3xl overflow-hidden"
class="application-settings-form application-settings-section relative max-h-[calc(100dvh-2rem)] w-full max-w-6xl overflow-hidden"
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
<header>
<div class="flex items-center gap-2">
@@ -7,14 +7,10 @@
@endphp
<div class="flex w-full flex-col">
@if ($isComposeOrService)
@if (data_get($resource, 'build_pack') === 'dockercompose')
<div
class="border-b border-neutral-200 px-4 py-3 text-[13px] leading-5 text-amber-800 dark:border-white/[0.08] dark:text-amber-300/90">
@if ($resource->type() === 'service')
Service volume mounts are read-only here. Edit the Docker Compose file and reload it to change volumes.
@else
Docker Compose volume mounts are read-only here. Edit the compose file and reload it to change volumes.
@endif
Docker Compose volume mounts are read-only here. Edit the compose file and reload it to change volumes.
</div>
@endif
@@ -30,6 +26,7 @@
PR suffix
</span>
@endif
<span class="volumes-col-backup text-center">Backup</span>
@if ($showActionsColumn)
<span class="volumes-col-actions text-right">Actions</span>
@endif
@@ -58,16 +55,6 @@
<span
class="min-w-0 truncate font-mono text-[13px] font-medium text-neutral-950 dark:text-fg"
title="{{ $form['name'] }}">{{ $form['name'] }}</span>
@if ($hasEnabledBackup)
@if ($backupUrl)
<a href="{{ $backupUrl }}"
class="table-badge table-badge-success shrink-0 underline-offset-2 hover:underline"
title="Volume backup is enabled">Backup</a>
@else
<span class="table-badge table-badge-success shrink-0"
title="Volume backup is enabled">Backup</span>
@endif
@endif
</div>
</div>
@@ -91,6 +78,20 @@
</div>
@endif
<div class="volumes-col-backup data-table-cell-check">
<span class="volumes-mobile-label volumes-field-label">Backup</span>
@if ($hasEnabledBackup)
<a @if ($backupUrl) href="{{ $backupUrl }}" @endif title="Volume backup is enabled">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</a>
@else
<span class="data-table-cell-dash">-</span>
@endif
</div>
@if ($showActionsColumn)
<div
class="volumes-col-actions volumes-cell-actions flex flex-wrap items-center justify-end gap-1.5">
@@ -115,16 +116,6 @@
<div class="min-w-0 flex-1">
<x-forms.input id="forms.{{ $id }}.name" required />
</div>
@if ($hasEnabledBackup)
@if ($backupUrl)
<a href="{{ $backupUrl }}"
class="table-badge table-badge-success shrink-0 underline-offset-2 hover:underline"
title="Volume backup is enabled">Backup</a>
@else
<span class="table-badge table-badge-success shrink-0"
title="Volume backup is enabled">Backup</span>
@endif
@endif
</div>
</div>
@@ -149,6 +140,20 @@
</div>
@endif
<div class="volumes-col-backup data-table-cell-check">
<span class="volumes-mobile-label volumes-field-label">Backup</span>
@if ($hasEnabledBackup)
<a @if ($backupUrl) href="{{ $backupUrl }}" @endif title="Volume backup is enabled">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</a>
@else
<span class="data-table-cell-dash">-</span>
@endif
</div>
<div
class="volumes-col-actions volumes-cell-actions flex flex-wrap items-center justify-end gap-1.5">
<x-forms.button type="submit" class="!px-2.5 !text-xs">