mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-17 02:14:05 +00:00
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:
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'">
|
||||
|
||||
Reference in new issue
Block a user