feat(ui): Shadow UI redesign, domain management, and DNS autoconfigure (#11119)

Co-authored-by: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com>
This commit is contained in:
Andras BacsaiandShadowArcanist authored and GitHub committed 2026-08-03 23:11:54 +02:00
1 parent 0b843bb07c
commit 8ae56587d5
497 files changed
+44045 -22630

No files matched your search

@@ -1,3 +1,29 @@
@php
$wireClickValue = $attributes->whereStartsWith('wire:click')->first();
$wireTargetValue = $attributes->get('wire:target');
$hasExplicitWireTarget = $attributes->has('wire:target')
&& filled($wireTargetValue)
&& $wireTargetValue !== true
&& $wireTargetValue !== '1';
$loadingTarget = null;
if ($showLoadingIndicator) {
if (filled($wireClickValue)) {
$loadingTarget = trim(explode('(', (string) $wireClickValue, 2)[0]);
} elseif ($hasExplicitWireTarget) {
$loadingTarget = trim(explode('(', (string) $wireTargetValue, 2)[0]);
}
}
$loadingAttributes = [];
if (filled($loadingTarget)) {
$loadingAttributes['wire:loading.attr'] = 'disabled';
$loadingAttributes['wire:loading.class'] = 'is-loading';
if (! $hasExplicitWireTarget) {
$loadingAttributes['wire:target'] = $loadingTarget;
}
}
@endphp
@if ($authDisabled || filled($tooltip))
<span class="relative inline-flex"
x-data="{ visible: false, _t: null }"
@@ -19,8 +45,7 @@
}, 300)"
@mouseleave="clearTimeout(_t); visible = false">
@endif
<button @disabled($disabled) {{ $attributes->merge(['class' => $defaultClass]) }}
{{ $attributes->merge(['type' => 'button']) }}
<button @disabled($disabled) {{ $attributes->merge(['class' => $defaultClass, 'type' => 'button'])->merge($loadingAttributes) }}
@isset($confirm)
x-on:click="toggleConfirmModal('{{ $confirm }}', '{{ explode('(', $confirmAction)[0] }}')"
@endisset
@@ -28,16 +53,10 @@
x-on:{{ explode('(', $confirmAction)[0] }}.window="$wire.{{ explode('(', $confirmAction)[0] }}"
@endisset>
{{ $slot }}
@if ($showLoadingIndicator)
@if ($attributes->whereStartsWith('wire:click')->first())
<x-loading-on-button wire:target="{{ $attributes->whereStartsWith('wire:click')->first() }}"
wire:loading.delay />
@elseif($attributes->whereStartsWith('wire:target')->first())
<x-loading-on-button wire:target="{{ $attributes->whereStartsWith('wire:target')->first() }}"
wire:loading.delay />
@endif
@if (filled($loadingTarget))
<x-loading-on-button wire:target="{{ $loadingTarget }}" wire:loading />
@endif
{{ $slot }}
</button>
@if ($authDisabled || filled($tooltip))
<div x-ref="tip" x-show="visible" x-cloak class="auth-tooltip">
@@ -11,37 +11,50 @@
])
<div @class([
'form-control flex max-w-full flex-row items-center gap-4 py-1 pr-2',
'form-control group flex min-h-9 max-w-full items-center rounded-lg px-2.5 py-1.5 transition-colors',
'w-full' => $fullWidth,
'dark:hover:bg-coolgray-100 cursor-pointer' => !$disabled,
'cursor-pointer hover:bg-neutral-100/80 dark:hover:bg-white/[0.035]' => !$disabled,
'opacity-55' => $disabled,
])>
<label @class(['label flex w-full max-w-full min-w-0 items-center gap-4 px-0'])>
<span class="flex min-w-0 grow gap-2 break-words">
<label @class([
'label flex w-full max-w-full min-w-0 items-center gap-3 px-0',
'cursor-pointer' => !$disabled,
'cursor-not-allowed' => $disabled,
])>
<span
class="flex min-w-0 grow items-center gap-1.5 break-words text-[12px] text-neutral-600 dark:text-fg-dim">
@if ($label)
@if ($disabled)
<span class="opacity-60">{!! $label !!}</span>
@else
{!! $label !!}
@endif
{!! $label !!}
@if ($helper)
<x-helper :helper="$helper" />
@endif
@endif
</span>
@if ($instantSave)
<input type="checkbox" @disabled($disabled) {{ $attributes->class([$defaultClass, 'shrink-0']) }}
wire:loading.attr="disabled"
wire:click='{{ $instantSave === 'instantSave' || $instantSave == '1' ? 'instantSave' : $instantSave }}'
wire:model={{ $modelBinding }} id="{{ $htmlId }}" @if ($checked) checked @endif />
@else
@if ($domValue)
<input type="checkbox" @disabled($disabled) {{ $attributes->class([$defaultClass, 'shrink-0']) }}
value={{ $domValue }} id="{{ $htmlId }}" @if ($checked) checked @endif />
<span class="relative flex size-[18px] shrink-0">
@if ($instantSave)
<input type="checkbox" @disabled($disabled) {{ $attributes->class([$defaultClass]) }}
wire:loading.attr="disabled"
wire:click='{{ $instantSave === 'instantSave' || $instantSave == '1' ? 'instantSave' : $instantSave }}'
wire:model={{ $modelBinding }} id="{{ $htmlId }}" @if ($checked) checked @endif />
@else
<input type="checkbox" @disabled($disabled) {{ $attributes->class([$defaultClass, 'shrink-0']) }}
@if ($live) wire:model.live={{ $value ?? $modelBinding }} @else wire:model={{ $value ?? $modelBinding }} @endif
id="{{ $htmlId }}" @if ($checked) checked @endif />
@if ($domValue)
<input type="checkbox" @disabled($disabled) {{ $attributes->class([$defaultClass]) }}
value={{ $domValue }} id="{{ $htmlId }}" @if ($checked) checked @endif />
@else
<input type="checkbox" @disabled($disabled) {{ $attributes->class([$defaultClass]) }}
@if ($live) wire:model.live={{ $value ?? $modelBinding }} @else wire:model={{ $value ?? $modelBinding }} @endif
id="{{ $htmlId }}" @if ($checked) checked @endif />
@endif
@endif
@endif
<span
class="pointer-events-none absolute inset-0 rounded-[5px] border border-neutral-300 bg-white shadow-[inset_0_1px_1px_rgb(0_0_0/0.04)] transition-colors group-hover:border-neutral-400 peer-checked:border-coollabs peer-checked:bg-coollabs peer-focus-visible:ring-2 peer-focus-visible:ring-coollabs/25 peer-focus-visible:ring-offset-2 peer-disabled:opacity-50 dark:border-white/[0.14] dark:bg-white/[0.045] dark:shadow-none dark:group-hover:border-white/[0.22] dark:peer-checked:border-warning dark:peer-checked:bg-warning dark:peer-focus-visible:ring-warning/30 dark:peer-focus-visible:ring-offset-base"></span>
<svg class="pointer-events-none absolute inset-0 m-auto size-3 scale-75 text-white opacity-0 transition-[opacity,transform] peer-checked:scale-100 peer-checked:opacity-100 dark:text-black"
viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="m2.25 6.15 2.35 2.3 5.15-5" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</span>
</label>
</div>
@@ -144,9 +144,18 @@
<div @click="toggleOption(option.value)"
class="px-3 py-2 cursor-pointer hover:bg-neutral-100 dark:hover:bg-coolgray-200 flex items-center gap-3"
:class="{ 'bg-neutral-50 dark:bg-coolgray-300': isSelected(option.value) }">
<input type="checkbox" :checked="isSelected(option.value)"
class="w-4 h-4 rounded border-neutral-300 dark:border-neutral-600 bg-white dark:bg-coolgray-100 text-black dark:text-white checked:bg-white dark:checked:bg-coolgray-100 focus:ring-coollabs dark:focus:ring-warning pointer-events-none"
tabindex="-1">
<span class="relative flex size-[18px] shrink-0">
<input type="checkbox" :checked="isSelected(option.value)"
class="peer absolute inset-0 m-0 h-full w-full appearance-none opacity-0"
tabindex="-1">
<span
class="pointer-events-none absolute inset-0 rounded-[5px] border border-neutral-300 bg-white transition-colors peer-checked:border-coollabs peer-checked:bg-coollabs dark:border-white/[0.14] dark:bg-white/[0.045] dark:peer-checked:border-warning dark:peer-checked:bg-warning"></span>
<svg class="pointer-events-none absolute inset-0 m-auto size-3 scale-75 text-white opacity-0 transition-[opacity,transform] peer-checked:scale-100 peer-checked:opacity-100 dark:text-black"
viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="m2.25 6.15 2.35 2.3 5.15-5" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</span>
<span class="text-sm flex-1" x-text="option.text"></span>
</div>
</template>
@@ -297,4 +306,4 @@
<span class="text-red-500 label-text-alt">{{ $message }}</span>
</label>
@enderror
</div>
</div>
@@ -0,0 +1,91 @@
@props([
'label' => 'Domains',
'helper' => 'Add one domain per entry. Press Enter (or type a comma) to add it. You can specify a path and a port to bind the domain to.<br><br><span class=\'text-helper\'>Example</span><br>- https://app.coolify.io/api/v3<br>- https://app.coolify.io:3000 -> app.coolify.io will point to port 3000 inside the container.<br>- https://app.coolify.io:8080/api -> app.coolify.io/api will point to port 8080 inside the container.',
'model' => 'fqdn',
'disabled' => false,
'required' => false,
'placeholder' => 'https://coolify.io',
'canUpdate' => null,
])
@php
$canEdit = $canUpdate ?? ! $disabled;
@endphp
<div {{ $attributes->class(['w-full']) }}>
@if ($label)
<label class="mb-1 flex w-fit items-center gap-1.5 text-sm font-medium">
{{ $label }}
@if ($required)
<x-highlighted text="*" />
@endif
@if ($helper)
<x-helper :helper="$helper" />
@endif
</label>
@endif
<div class="chip-input" x-data="{
raw: @entangle($model),
entry: '',
canUpdate: @js((bool) $canEdit),
get domains() {
return (this.raw ?? '').split(',').map((domain) => domain.trim()).filter(Boolean);
},
commit(list) {
this.raw = list.join(',');
},
addValue(value) {
value = value.trim().replace(/,+$/, '');
if (!value) return;
const list = this.domains;
if (!list.includes(value)) {
list.push(value);
this.commit(list);
}
},
add() {
const value = this.entry;
this.entry = '';
this.addValue(value);
},
onInput() {
if (this.entry.includes(',')) {
const parts = this.entry.split(',');
this.entry = parts.pop().trim();
parts.forEach((part) => this.addValue(part));
}
},
remove(index) {
if (!this.canUpdate) return;
const list = this.domains;
list.splice(index, 1);
this.commit(list);
},
onKeydown(event) {
if (event.key === ',') {
event.preventDefault();
this.add();
} else if (event.key === 'Backspace' && this.entry === '') {
this.remove(this.domains.length - 1);
}
}
}" @click="$refs.domainEntry.focus()">
<template x-for="(domain, index) in domains" :key="domain + '-' + index">
<span class="chip">
<span x-text="domain"></span>
<button type="button" class="chip-remove" x-show="canUpdate"
:aria-label="'Remove ' + domain" @click.stop="remove(index)">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor" class="size-3">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</span>
</template>
<input x-ref="domainEntry" x-model="entry"
:placeholder="domains.length === 0 ? @js($placeholder) : ''"
autocomplete="off" spellcheck="false" @input="onInput()"
@keydown.enter.prevent.stop="add()" x-on:keydown="onKeydown($event)"
@blur="add()" @disabled($disabled) x-bind:disabled="!canUpdate" />
</div>
</div>
@@ -1,13 +1,15 @@
<div class="w-full">
@if ($label)
<label class="flex gap-1 items-center mb-1 text-sm font-medium">{{ $label }}
@if ($required)
<x-highlighted text="*" />
@endif
<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">{{ $label }}
@if ($required)
<x-highlighted text="*" />
@endif
</label>
@if ($helper)
<x-helper :helper="$helper" />
@endif
</label>
</div>
@endif
<div class="relative" @success.window="type = '{{ $type }}'" x-data="{
@@ -223,21 +225,10 @@
@if ($type === 'password' && $allowToPeak)
<button type="button" x-on:click="type = type === 'password' ? 'text' : 'password'"
class="flex absolute inset-y-0 right-0 z-10 items-center pr-2 cursor-pointer dark:hover:text-white"
class="password-toggle flex absolute inset-y-0 right-0 z-10 items-center pr-2 cursor-pointer text-neutral-500 hover:text-black dark:text-neutral-400 dark:hover:text-white"
aria-label="Toggle password visibility">
<svg x-show="type === 'password'" xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
<path d="M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6" />
</svg>
<svg x-cloak x-show="type === 'text'" xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10.585 10.587a2 2 0 0 0 2.829 2.828" />
<path d="M16.681 16.673a8.717 8.717 0 0 1 -4.681 1.327c-3.6 0 -6.6 -2 -9 -6c1.272 -2.12 2.712 -3.678 4.32 -4.674m2.86 -1.146a9.055 9.055 0 0 1 1.82 -.18c3.6 0 6.6 2 9 6c-.666 1.11 -1.379 2.067 -2.138 2.87" />
<path d="M3 3l18 18" />
</svg>
<x-reicon name="eye" x-show="type === 'password'" class="size-[18px]" />
<x-reicon name="eye-off2" x-cloak x-show="type === 'text'" class="size-[18px]" />
</button>
@endif
@@ -3,17 +3,24 @@
'w-full' => !$isMultiline,
])>
@if ($label)
<label class="flex gap-1 items-center mb-1 text-sm font-medium">{{ $label }}
@if ($required)
<x-highlighted text="*" />
@endif
{{--
Fixed-height label row (h-4 = helper icon). Helper lives outside <label> so
side-by-side fields align whether a field has a helper, required mark, both, or neither.
Margin is only on this wrapper — mb-0! beats .application-settings-form label rules.
--}}
<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">{{ $label }}
@if ($required)
<x-highlighted text="*" />
@endif
@isset($labelSuffix)
{{ $labelSuffix }}
@endisset
</label>
@if ($helper)
<x-helper :helper="$helper" />
@endif
@isset($labelSuffix)
{{ $labelSuffix }}
@endisset
</label>
</div>
@endif
@if ($type === 'password')
<div class="relative" x-data="{ type: 'password' }" @success.window="type = 'password'">
@@ -29,23 +36,12 @@
@if ($autofocus) x-ref="autofocusInput" @endif>
@if ($allowToPeak)
<button type="button" x-on:click="type = type === 'password' ? 'text' : 'password'"
class="flex absolute inset-y-0 right-0 items-center pr-2 cursor-pointer dark:hover:text-white"
class="password-toggle flex absolute inset-y-0 right-0 z-10 items-center pr-2 cursor-pointer text-neutral-500 hover:text-black dark:text-neutral-400 dark:hover:text-white"
aria-label="Toggle password visibility">
{{-- Eye icon (shown when password is hidden) --}}
<svg x-show="type === 'password'" xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
<path d="M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6" />
</svg>
{{-- Eye-off icon (shown when password is visible) --}}
<svg x-cloak x-show="type === 'text'" xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10.585 10.587a2 2 0 0 0 2.829 2.828" />
<path d="M16.681 16.673a8.717 8.717 0 0 1 -4.681 1.327c-3.6 0 -6.6 -2 -9 -6c1.272 -2.12 2.712 -3.678 4.32 -4.674m2.86 -1.146a9.055 9.055 0 0 1 1.82 -.18c3.6 0 6.6 2 9 6c-.666 1.11 -1.379 2.067 -2.138 2.87" />
<path d="M3 3l18 18" />
</svg>
<x-reicon name="eye" x-show="type === 'password'" class="size-[18px]" />
{{-- Eye-off2 icon (shown when password is visible) --}}
<x-reicon name="eye-off2" x-cloak x-show="type === 'text'" class="size-[18px]" />
</button>
@endif
@@ -0,0 +1,77 @@
@props([
'id' => null,
'label' => null,
'helper' => null,
'required' => false,
'options' => [], // list of ['value' => ..., 'label' => ..., 'disabled' => bool]
'placeholder' => 'Select…',
'live' => false,
'onChange' => null, // optional $wire method to call after a selection
'wire' => true, // false = purely client-side value (no Livewire binding)
'value' => null, // initial value when wire=false
'disabled' => false,
])
<div class="w-full min-w-0">
@if ($label)
{{--
Keep helper outside the label so taps do not open the listbox trigger.
Margin lives only on this wrapper (mb-0 on the label) so spacing matches
plain text labels and is not doubled by .application-settings-form label rules.
--}}
{{-- Fixed h-4 matches the helper icon so side-by-side fields align with or without a helper. --}}
<div class="mb-1.5 flex h-4 w-full items-center gap-1.5">
<label for="{{ $id }}-trigger" class="mb-0! flex items-center gap-1.5 leading-4">
{{ $label }}
@if ($required)
<x-highlighted text="*" />
@endif
</label>
@if ($helper)
<x-helper :helper="$helper" />
@endif
</div>
@endif
<div class="relative min-w-0" x-data="{
open: false,
options: @js(array_values($options)),
value: @if (!$wire) @js($value) @elseif ($live) @entangle($id).live @else @entangle($id) @endif,
get current() {
const found = this.options.find((option) => String(option.value) === String(this.value));
return found ? found.label : @js($placeholder);
},
choose(option) {
if (option.disabled) return;
this.open = false;
if (String(option.value) === String(this.value)) return;
this.value = option.value;
@if ($onChange) this.$nextTick(() => this.$wire.{{ $onChange }}()); @endif
}
}" x-modelable="value" {{ $attributes->whereStartsWith('x-model') }}
{{ $attributes->whereStartsWith('x-effect') }}
@click.outside="open = false" @keydown.escape="open = false">
<button id="{{ $id }}-trigger" type="button" class="listbox-trigger" @click="open = !open"
@disabled($disabled) aria-haspopup="listbox"
:aria-expanded="open" :title="current">
<span class="listbox-trigger-label" x-text="current"></span>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" class="size-3.5 shrink-0 opacity-60">
<path stroke-linecap="round" stroke-linejoin="round" d="m8 9 4-4 4 4m0 6-4 4-4-4" />
</svg>
</button>
<div class="listbox-panel" x-show="open" x-cloak role="listbox">
<template x-for="option in options" :key="String(option.value)">
<button type="button" class="listbox-option" role="option"
:class="{ 'listbox-option-disabled': option.disabled }"
:aria-selected="String(option.value) === String(value)" @click="choose(option)">
<span class="truncate" x-text="option.label"></span>
<svg x-show="String(option.value) === String(value)" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"
class="size-3.5 shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</button>
</template>
</div>
</div>
</div>
@@ -55,9 +55,55 @@
let proxy = URL.createObjectURL(new Blob([`self.MonacoEnvironment={baseUrl:'${window.location.origin}/js/monaco-editor-${monacoVersion}/min'};importScripts('${window.location.origin}/js/monaco-editor-${monacoVersion}/min/vs/base/worker/workerMain.js');`], { type: 'text/javascript' }));
window.MonacoEnvironment = { getWorkerUrl: () => proxy };
require(['vs/editor/editor.main'], () => {
if (!window.__coolifyMonacoThemeDefined) {
monaco.editor.defineTheme('coolify-dark', {
base: 'vs-dark',
inherit: true,
rules: [],
colors: {
'editor.background': '#0b0b0c',
'editorGutter.background': '#0b0b0c',
'editorStickyScroll.background': '#0b0b0c',
'minimap.background': '#0b0b0c',
'scrollbarSlider.background': '#ffffff1a',
'scrollbarSlider.hoverBackground': '#ffffff2e',
'scrollbarSlider.activeBackground': '#ffffff40',
'scrollbar.shadow': '#00000000'
}
});
window.__coolifyMonacoThemeDefined = true;
}
@if ($language === 'nginx')
if (!monaco.languages.getLanguages().some((registered) => registered.id === 'nginx')) {
monaco.languages.register({ id: 'nginx' });
monaco.languages.setLanguageConfiguration('nginx', {
comments: { lineComment: '#' },
brackets: [['{', '}']],
autoClosingPairs: [
{ open: '{', close: '}' },
],
});
monaco.languages.setMonarchTokensProvider('nginx', {
defaultToken: '',
tokenizer: {
root: [
[/#.*$/, 'comment'],
[/\$[A-Za-z_]\w*/, 'variable'],
[/^\s*(server|location|upstream|http|events|types|map|if|include|listen|server_name|root|index|alias|return|rewrite|try_files|proxy_pass|proxy_set_header|proxy_http_version|fastcgi_pass|fastcgi_param|add_header|expires|error_page|access_log|error_log|gzip|gzip_types|charset|sendfile|keepalive_timeout|client_max_body_size|default_type|worker_processes|worker_connections|ssl_certificate|ssl_certificate_key|allow|deny|autoindex|set|break|internal|limit_req|limit_conn|resolver)\b/, 'keyword'],
[/^\s*[A-Za-z_][\w.]*/, 'type'],
[/\u0022([^\u0022\\]|\\.)*\u0022/, 'string'],
[/'([^'\\]|\\.)*'/, 'string'],
[/\b\d+(\.\d+)?(ms|[smhdwMy]|[kKmMgG])?\b/, 'number'],
[/[{}();]/, 'delimiter'],
[/(~\*?|\^~|=|\!=)/, 'operator'],
],
},
});
}
@endif
const editor = monaco.editor.create($refs.monacoEditorElement, {
value: monacoContent,
theme: document.documentElement.classList.contains('dark') ? 'vs-dark' : 'vs',
theme: document.documentElement.classList.contains('dark') ? 'coolify-dark' : 'vs',
wordWrap: 'on',
readOnly: '{{ $readonly ?? false }}',
minimap: { enabled: false },
@@ -67,15 +113,26 @@
language: '{{ $language }}',
domReadOnly: '{{ $readonly ?? false }}',
contextmenu: '!{{ $readonly ?? false }}',
renderLineHighlight: '{{ $readonly ?? false }} ? none : all',
stickyScroll: { enabled: false }
renderLineHighlight: 'none',
stickyScroll: { enabled: false },
padding: { top: 12, bottom: 12 },
overviewRulerLanes: 0,
overviewRulerBorder: false,
hideCursorInOverviewRuler: true,
scrollbar: {
vertical: 'auto',
horizontal: 'auto',
verticalScrollbarSize: 8,
horizontalScrollbarSize: 8,
useShadows: false
}
});
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'class') {
const isDark = document.documentElement.classList.contains('dark');
monaco.editor.setTheme(isDark ? 'vs-dark' : 'vs');
monaco.editor.setTheme(isDark ? 'coolify-dark' : 'vs');
}
});
});
@@ -114,7 +171,7 @@
}, 5);" :id="monacoId">
</div>
<div class="relative z-10 w-full h-full">
<div x-ref="monacoEditorElement" class="w-full text-md {{ $readonly ? 'opacity-65' : '' }}" style="height: var(--editor-height, calc(100vh - 20rem)); min-height: 300px;"></div>
<div x-ref="monacoEditorElement" class="w-full text-md {{ $readonly ? 'opacity-65' : '' }}" style="height: var(--editor-height, calc(100vh - 20rem)); min-height: 150px;"></div>
<div x-ref="monacoPlaceholderElement" x-show="monacoPlaceholder" @click="monacoEditorFocus()"
:style="'font-size: ' + monacoFontSize"
class="w-full text-sm font-mono absolute z-50 text-gray-500 ml-14 -translate-x-0.5 mt-0.5 left-0 top-0"
@@ -0,0 +1,131 @@
@props([
'id' => null,
'label' => null,
'helper' => null,
'required' => false,
'options' => [], // list of ['value' => ..., 'label' => ..., 'disabled' => bool]
'placeholder' => 'Select…',
'searchPlaceholder' => 'Search…',
'emptyText' => 'No matching options',
'live' => false,
'onChange' => null, // optional $wire method to call after a selection
'wire' => true, // false = purely client-side value (no Livewire binding)
'value' => null, // initial value when wire=false
'disabled' => false,
])
<div class="w-full min-w-0">
@if ($label)
{{--
Keep helper outside the label so taps do not open the listbox trigger.
Margin lives only on this wrapper (mb-0 on the label) so spacing matches
plain text labels and is not doubled by .application-settings-form label rules.
--}}
{{-- Fixed h-4 matches the helper icon so side-by-side fields align with or without a helper. --}}
<div class="mb-1.5 flex h-4 w-full items-center gap-1.5">
<label for="{{ $id }}-trigger" class="mb-0! flex items-center gap-1.5 leading-4">
{{ $label }}
@if ($required)
<x-highlighted text="*" />
@endif
</label>
@if ($helper)
<x-helper :helper="$helper" />
@endif
</div>
@endif
<div class="relative min-w-0" x-data="{
open: false,
query: '',
options: @js(array_values($options)),
value: @if (!$wire) @js($value) @elseif ($live) @entangle($id).live @else @entangle($id) @endif,
get current() {
const found = this.options.find((option) => String(option.value) === String(this.value));
return found ? found.label : @js($placeholder);
},
get filtered() {
const query = this.query.toLowerCase().trim();
if (query === '') {
return this.options;
}
return this.options.filter((option) => {
const label = String(option.label ?? '').toLowerCase();
const value = String(option.value ?? '').toLowerCase();
return label.includes(query) || value.includes(query);
});
},
toggle() {
if (@js((bool) $disabled)) {
return;
}
this.open = !this.open;
if (this.open) {
this.$nextTick(() => this.$refs.search?.focus());
} else {
this.query = '';
}
},
close() {
this.open = false;
this.query = '';
},
choose(option) {
if (option.disabled) {
return;
}
this.close();
if (String(option.value) === String(this.value)) {
return;
}
this.value = option.value;
@if ($onChange) this.$nextTick(() => this.$wire.{{ $onChange }}()); @endif
}
}" x-modelable="value" {{ $attributes->whereStartsWith('x-model') }}
{{ $attributes->whereStartsWith('x-effect') }}
@click.outside="close()" @keydown.escape.window="open && close()">
<button id="{{ $id }}-trigger" type="button" class="listbox-trigger" @click="toggle()"
@disabled($disabled)
{{ $attributes->whereStartsWith('x-bind:disabled') }} aria-haspopup="listbox"
:aria-expanded="open" :title="current">
<span class="listbox-trigger-label" x-text="current"></span>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" class="size-3.5 shrink-0 opacity-60">
<path stroke-linecap="round" stroke-linejoin="round" d="m8 9 4-4 4 4m0 6-4 4-4-4" />
</svg>
</button>
<div class="listbox-panel searchable-listbox-panel" x-show="open" x-cloak role="listbox"
@click.stop>
<div class="searchable-listbox-search">
<x-reicon name="search"
class="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
<input x-ref="search" type="search" x-model="query" autocomplete="off"
placeholder="{{ $searchPlaceholder }}"
class="searchable-listbox-search-input"
@keydown.enter.prevent="filtered.length === 1 && choose(filtered[0])"
@keydown.escape.stop="close()" />
</div>
<div class="searchable-listbox-options">
<template x-for="option in filtered" :key="String(option.value)">
<button type="button" class="listbox-option" role="option"
:class="{ 'listbox-option-disabled': option.disabled }"
:aria-selected="String(option.value) === String(value)" @click="choose(option)">
<span class="truncate" x-text="option.label"></span>
<svg x-show="String(option.value) === String(value)" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"
class="size-3.5 shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</button>
</template>
<p x-show="filtered.length === 0" class="searchable-listbox-empty">
{{ $emptyText }}
</p>
</div>
</div>
</div>
</div>
@@ -1,14 +1,16 @@
<div class="w-full">
@if ($label)
<label
class="flex gap-1 items-center mb-1 text-sm font-medium {{ $disabled ? 'text-neutral-600' : '' }}">{{ $label }}
@if ($required)
<x-highlighted text="*" />
@endif
<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 {{ $disabled ? 'text-neutral-600' : '' }}">{{ $label }}
@if ($required)
<x-highlighted text="*" />
@endif
</label>
@if ($helper)
<x-helper :helper="$helper" />
@endif
</label>
</div>
@endif
<select {{ $attributes->merge(['class' => $defaultClass]) }} @disabled($disabled) @required($required)
wire:loading.attr="disabled" name={{ $modelBinding }} id="{{ $htmlId }}"
@@ -15,19 +15,24 @@
<div class="flex-1 form-control">
@if ($label)
<label class="flex items-center gap-1 mb-1 text-sm font-medium">{{ $label }}
@if ($required)
<x-highlighted text="*" />
@endif
<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">{{ $label }}
@if ($required)
<x-highlighted text="*" />
@endif
</label>
@if ($helper)
<x-helper :helper="$helper" />
@endif
</label>
</div>
@endif
@if ($useMonacoEditor)
<x-forms.monaco-editor id="{{ $modelBinding }}" language="{{ $monacoEditorLanguage }}" name="{{ $name }}"
name="{{ $modelBinding }}" model="{{ $value ?? $modelBinding }}" wire:model="{{ $value ?? $modelBinding }}"
readonly="{{ $readonly }}" label="dockerfile" autofocus="{{ $autofocus }}" />
@php $monacoRows = (int) $attributes->get('rows', 0); @endphp
<div class="w-full" @if ($monacoRows > 0) style="--editor-height: {{ $monacoRows * 23 + 38 }}px" @endif>
<x-forms.monaco-editor id="{{ $modelBinding }}" language="{{ $monacoEditorLanguage }}" name="{{ $name }}"
name="{{ $modelBinding }}" model="{{ $value ?? $modelBinding }}" wire:model="{{ $value ?? $modelBinding }}"
readonly="{{ $readonly }}" label="dockerfile" autofocus="{{ $autofocus }}" />
</div>
@else
@if ($type === 'password')
<div class="relative" x-data="{ type: 'password' }" @success.window="type = 'password'">