feat(domains): add structured URL editing and responsive domain controls

Add reusable domain URL parsing and input components, expose per-domain
redirect and indexing controls, and improve application and service domain
layouts across responsive breakpoints.
This commit is contained in:
Andras Bacsai committed 2026-08-11 00:06:55 +02:00
1 parent 61a2b183d3
commit 32bf1860d4
30 files changed
+693 -292

No files matched your search

@@ -18,15 +18,11 @@
localEditingIndex: @js($editingIndex),
localEditingDomain: @js($editingDomain),
localEditingService: @js($editingService),
localDirection: 'both',
localIndexing: 'index',
openEditDomain(index, url, service, indexing, direction) {
openEditDomain(index, url, service) {
this.localEditingIndex = index;
this.localEditingDomain = url;
this.localEditingService = service;
this.editingServiceLabel = service || '';
this.localDirection = direction || 'both';
this.localIndexing = indexing || 'index';
this.modalOpen = true;
this.$nextTick(() => document.getElementById('editingDomainLocal')?.focus?.());
},
@@ -42,8 +38,6 @@
$wire.editingIndex = this.localEditingIndex;
$wire.editingDomain = this.localEditingDomain;
$wire.editingService = this.localEditingService;
$wire.editingDirection = this.localDirection;
$wire.editingIndexing = this.localIndexing;
$wire.showEditDomainModal = true;
},
matchesDomainSearch(value) {
@@ -53,7 +47,7 @@
return values.some((value) => this.matchesDomainSearch(value));
},
}"
@open-edit-domain.window="openEditDomain($event.detail.index, $event.detail.url, $event.detail.service, $event.detail.indexing, $event.detail.direction)"
@open-edit-domain.window="openEditDomain($event.detail.index, $event.detail.url, $event.detail.service)"
@edit-domain-saved.window="closeEditDomain()">
<x-application.settings-section id="domains-section" title="Domains" :helper="$helperText">
@can('update', $application)
@@ -84,37 +78,6 @@
</x-callout>
@endcannot
@if (! $isCompose)
@if ($labelsAreWritable)
<x-forms.input label="Direction" value="{{ match ($application->redirect) {
'www' => 'Redirect to www',
'non-www' => 'Redirect to non-www',
default => 'Allow www & non-www',
} }}" readonly helper="Readonly labels are disabled. You can set the direction in the labels section." />
@else
<div class="flex w-full flex-col gap-3 sm:flex-row sm:items-end">
<div class="min-w-0 flex-1">
<x-forms.listbox id="redirect" label="Direction" required :options="[
['value' => 'both', 'label' => 'Allow www & non-www'],
['value' => 'www', 'label' => 'Redirect to www'],
['value' => 'non-www', 'label' => 'Redirect to non-www'],
]" helper="Add <strong>both</strong> www and non-www in Coolify. Both hostnames must resolve to this server so the proxy can serve or redirect them. Do not use a DNS-provider URL redirect record for the non-canonical host; Coolify handles the HTTP redirect. Changes apply when you click Set direction."
:disabled="! auth()->user()?->can('update', $application)" />
</div>
@can('update', $application)
<div class="w-full shrink-0 sm:w-auto">
<x-modal-confirmation title="Confirm redirection setting?" buttonTitle="Set direction"
submitAction="setRedirect" :actions="['All traffic will be redirected to the selected direction.']"
confirmationText="{{ ($application->fqdn ?: 'domains') . '/' }}"
confirmationLabel="Please confirm the execution of the action by entering the Application URL below"
shortConfirmationLabel="Application URL" :confirmWithPassword="false"
step2ButtonText="Set direction" canGate="update" :canResource="$application" />
</div>
@endcan
</div>
@endif
@endif
</x-application.settings-section>
{{-- Toolbar --}}
@@ -153,16 +116,15 @@
</x-slot:content>
<form wire:submit="addDomain" class="application-settings-form flex flex-col gap-4">
@if ($isCompose && count($composeServices) > 0)
<x-forms.select label="Service" id="newDomainService" required>
@foreach ($composeServices as $serviceName)
<option value="{{ $serviceName }}">{{ $serviceName }}</option>
@endforeach
</x-forms.select>
<x-forms.listbox label="Service" id="newDomainService" required
:options="collect($composeServices)->map(fn ($serviceName) => [
'value' => $serviceName,
'label' => $serviceName,
])->values()->all()"
:disabled="! auth()->user()->can('update', $application)" />
@endif
<x-forms.input id="newDomain" label="Domain URL" placeholder="https://app.example.com"
helper="Full URL including scheme. Optional path and container port are supported.<br><br><span class='text-helper'>Examples</span><br>- https://app.coolify.io<br>- https://app.coolify.io/api/v3<br>- https://app.coolify.io:3000<br>- https://app.coolify.io:8080/api"
required />
<x-forms.domain-input id="newDomain" />
@if ($addDomainDnsFailed)
<x-callout type="danger" title="DNS is not pointing to the right IP">
@@ -252,6 +214,8 @@
<span>Domain</span>
<span>DNS</span>
<span>Last checked</span>
<span>Search engine indexing</span>
<span>Direction</span>
<span></span>
</div>
@foreach ($rows as $row)
@@ -287,6 +251,8 @@
<span>Domain</span>
<span>DNS Check</span>
<span>Last checked</span>
<span>Search engine indexing</span>
<span>Direction</span>
<span></span>
</div>
@foreach ($domainRows as $index => $row)
@@ -340,40 +306,8 @@
<input type="text" class="input" readonly x-bind:value="editingServiceLabel" />
</div>
<div class="w-full">
<div class="mb-1.5 flex h-4 w-full items-center gap-1.5">
<label class="mb-0! flex items-center gap-1 text-sm font-medium leading-4" for="editingDomainLocal">
Domain URL <x-highlighted text="*" />
</label>
</div>
<input id="editingDomainLocal" type="url" class="input" required
placeholder="https://app.example.com"
x-model="localEditingDomain" />
<p class="mt-1 text-[12px] leading-5 text-neutral-500 dark:text-fg-dim">
Full URL including scheme. Optional path and container port are supported.
</p>
@error('editingDomain')
<p class="mt-1 text-[12px] text-red-500">{{ $message }}</p>
@enderror
</div>
@unless ($labelsAreWritable)
<div class="grid gap-4 {{ $isCompose ? 'sm:grid-cols-2' : '' }}">
@if ($isCompose)
<x-forms.listbox id="edit-domain-direction" label="Direction"
:wire="false" value="both" x-model="localDirection" portal :options="[
['value' => 'both', 'label' => 'Allow www & non-www'],
['value' => 'www', 'label' => 'Redirect to www'],
['value' => 'non-www', 'label' => 'Redirect to non-www'],
]" />
@endif
<x-forms.listbox id="edit-domain-indexing" label="Search engine indexing"
:wire="false" value="index" x-model="localIndexing" portal :options="[
['value' => 'index', 'label' => 'Indexable'],
['value' => 'noindex', 'label' => 'Noindex'],
]" />
</div>
@endunless
<x-forms.domain-input id="editingDomainLocal" errorId="editingDomain" :wire="false"
x-model="localEditingDomain" />
@if ($editDomainDnsFailed)
<x-callout type="danger" title="DNS is not pointing to the right IP">
@@ -6,7 +6,8 @@
}
}">
<form wire:submit='submit' class="application-settings-form flex flex-col">
<x-unsaved-bar action="submit" />
<x-unsaved-bar action="submit"
targets="name,description,buildPack,staticImage,baseDirectory,dockerComposeLocation,dockerComposeCustomBuildCommand,dockerComposeCustomStartCommand,watchPaths,dockerfileLocation,dockerfileTargetBuild,publishDirectory,installCommand,buildCommand,startCommand,customNginxConfiguration,dockerfile,dockerRegistryImageName,dockerRegistryImageTag,portsExposes,portsMappings,customNetworkAliases,customDockerRunOptions,httpBasicAuthUsername,httpBasicAuthPassword,preDeploymentCommand,preDeploymentCommandContainer,postDeploymentCommand,postDeploymentCommandContainer,isContainerLabelReadonlyEnabled,isContainerLabelEscapeEnabled,customLabels" />
<div class="application-settings-grid flex flex-col gap-6">
<x-application.settings-section id="application-details-section" title="Application details" helper="Name the application and choose the build strategy Coolify should use to deploy it." class="application-details-card">
@if ($buildPack === 'dockercompose')
@@ -302,7 +303,7 @@
@endif
@if ($buildPack === 'dockercompose')
<div x-data="{ showRaw: true }" class="mt-5">
<div class="flex items-center justify-between gap-4">
<div class="mb-2 flex items-center justify-between gap-4">
<h3>Docker Compose</h3>
<x-forms.button x-show="{{ $application->settings->is_raw_compose_deployment_enabled ? 'false' : 'true' }}"
@click.prevent="showRaw = !showRaw"
@@ -17,6 +17,28 @@
? \Illuminate\Support\Carbon::parse($row['checked_at'])->diffForHumans()
: null;
$gridClass = ($isCompose ?? false) ? 'domains-table-grid-compose' : 'domains-table-grid';
$domainParts = $isSuggested ? null : parse_url($row['url']);
$faviconUrl = is_array($domainParts) && isset($domainParts['scheme'], $domainParts['host'])
? $domainParts['scheme'].'://'.$domainParts['host'].(isset($domainParts['port']) ? ':'.$domainParts['port'] : '').'/favicon.ico'
: null;
$redirectPairKey = function (string $url): string {
$parts = parse_url($url);
if (! is_array($parts) || ! isset($parts['host'])) {
return $url;
}
$host = preg_replace('/^www\./i', '', $parts['host']);
return strtolower(($parts['scheme'] ?? '').'://'.$host.':'.($parts['port'] ?? '').($parts['path'] ?? ''));
};
$pairKey = $redirectPairKey($row['url']);
$firstPairRowIndex = collect($domainRows)
->reject(fn ($item) => (bool) ($item['is_suggested'] ?? false))
->filter(fn ($item) => ($item['service'] ?? null) === ($row['service'] ?? null))
->filter(fn ($item) => $redirectPairKey($item['url']) === $pairKey)
->keys()
->first();
$showDirection = ! $isSuggested && $firstPairRowIndex === $index;
@endphp
<div wire:key="domain-row-{{ $index }}-{{ md5(($isSuggested ? 's:' : '') . $row['url'] . '|' . ($row['service'] ?? '')) }}"
@@ -25,6 +47,7 @@
'data-table-row',
$gridClass,
'domains-row-suggested' => $isSuggested,
'domains-row-without-direction' => ! $showDirection,
])>
<div class="flex min-w-0 flex-col gap-1">
<div class="flex min-w-0 flex-wrap items-center gap-2">
@@ -35,6 +58,11 @@
{{ $row['url'] }}
</span>
@else
@if ($faviconUrl)
<img src="{{ $faviconUrl }}" alt="" loading="lazy" decoding="async"
referrerpolicy="no-referrer" x-on:error="$el.remove()"
class="size-4 shrink-0 rounded-sm" />
@endif
<a href="{{ getFqdnWithoutPort($row['url']) }}" target="_blank"
class="min-w-0 text-[13px] text-black underline decoration-neutral-300 underline-offset-2 hover:decoration-coollabs sm:truncate dark:text-fg dark:decoration-white/20 dark:hover:decoration-warning"
title="{{ $row['url'] }}">
@@ -78,6 +106,51 @@
{{ $checkedAt ?: '-' }}
</div>
<div class="min-w-0" title="Search engine indexing">
@unless ($isSuggested)
<span class="domains-mobile-label">Search engine indexing</span>
@endunless
@if ($isSuggested)
<span class="text-[13px] text-neutral-500 dark:text-fg-dim">-</span>
@elseif (auth()->user()?->can('update', $application) && ! $labelsAreWritable)
<x-forms.listbox id="domain-indexing-{{ $index }}" :wire="false"
:value="$application->isDomainNoindexed($row['url']) ? 'noindex' : 'index'"
onChange="toggleNoindexDomain" :onChangeArgs="[$row['url']]" portal :options="[
['value' => 'index', 'label' => 'Indexable'],
['value' => 'noindex', 'label' => 'Noindex'],
]" />
@else
<span class="text-[13px] text-neutral-500 dark:text-fg-dim">
{{ $application->isDomainNoindexed($row['url']) ? 'Noindex' : 'Indexable' }}
</span>
@endif
</div>
<div class="min-w-0" title="Direction">
@php
$rowDirection = $domainDirection ?? $redirect;
$directionLabel = match ($rowDirection) {
'www' => 'Redirect to www',
'non-www' => 'Redirect to non-www',
default => 'Allow both',
};
@endphp
@if ($showDirection)
<span class="domains-mobile-label">Direction</span>
@endif
@if ($showDirection && auth()->user()?->can('update', $application) && ! $labelsAreWritable)
<x-forms.listbox id="domain-direction-{{ $index }}" :wire="false" :value="$rowDirection"
:onChange="$isCompose ? 'updateServiceRedirect' : 'updateRedirect'"
:onChangeArgs="$isCompose ? [$row['service']] : []" portal :options="[
['value' => 'both', 'label' => 'Allow www & non-www'],
['value' => 'www', 'label' => 'Redirect to www'],
['value' => 'non-www', 'label' => 'Redirect to non-www'],
]" />
@elseif ($showDirection)
<span class="text-[13px] text-neutral-500 dark:text-fg-dim">{{ $directionLabel }}</span>
@endif
</div>
<div class="flex items-center justify-end gap-1">
@can('update', $application)
<button type="button" wire:click="checkDomainDns({{ $index }})"
@@ -107,8 +180,6 @@
index: {{ $index }},
url: @js($row['url']),
service: @js($row['service'] ?? null),
indexing: @js($application->isDomainNoindexed($row['url']) ? 'noindex' : 'index'),
direction: @js($domainDirection ?? $redirect),
})"
class="icon-button shrink-0"
title="Edit domain" aria-label="Edit domain">