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

@@ -0,0 +1,76 @@
@props([
'id',
'wire' => true,
'value' => '',
'errorId' => null,
])
<div class="grid gap-4 sm:grid-cols-[8rem_minmax(0,1fr)_8rem]" x-data="{
value: @if ($wire) @entangle($id) @else @js($value) @endif,
scheme: 'https',
host: '',
port: '',
path: '',
syncing: false,
init() {
this.read(this.value);
this.$watch('value', value => {
if (!this.syncing) this.read(value);
});
['scheme', 'host', 'port', 'path'].forEach(part => this.$watch(part, () => this.write()));
},
read(value) {
if (!value) return;
try {
const url = new URL(value);
const authority = value.match(/^[a-z][a-z0-9+.-]*:\/\/(?:\[[^\]]+\]|[^\/:?#]+)(?::(\d+))?/i);
this.syncing = true;
this.scheme = url.protocol.replace(':', '') === 'http' ? 'http' : 'https';
this.host = url.hostname;
this.port = authority?.[1] || url.port;
this.path = `${url.pathname === '/' ? '' : url.pathname}${url.search}${url.hash}`;
this.$nextTick(() => this.syncing = false);
} catch (_) {}
},
write() {
if (this.syncing) return;
const path = this.path.trim();
const normalizedPath = path && !['/', '?', '#'].includes(path[0]) ? `/${path}` : path;
const next = `${this.scheme}://${this.host.trim()}${this.port ? `:${this.port}` : ''}${normalizedPath}`;
if (this.value !== next) this.value = next;
},
}" x-modelable="value" {{ $attributes->whereStartsWith('x-model') }}>
<div class="min-w-0">
<x-forms.listbox id="{{ $id }}-protocol" label="Protocol" :wire="false" value="https"
x-model="scheme" portal :options="[
['value' => 'https', 'label' => 'HTTPS'],
['value' => 'http', 'label' => 'HTTP'],
]" />
</div>
<div class="min-w-0">
<label for="{{ $id }}" class="mb-1.5 block text-sm font-medium">
Domain <x-highlighted text="*" />
</label>
<input id="{{ $id }}" type="text" class="input" x-model="host" placeholder="app.example.com"
autocomplete="off" required />
@error($errorId ?? $id)
<p class="mt-1 text-[12px] text-red-500">{{ $message }}</p>
@enderror
</div>
<div class="min-w-0">
<label for="{{ $id }}-port" class="mb-1.5 block text-sm font-medium">Port</label>
<input id="{{ $id }}-port" type="number" class="input" x-model="port" placeholder="3000"
min="1" max="65535" inputmode="numeric" />
</div>
<div class="min-w-0 sm:col-span-3">
<label for="{{ $id }}-path" class="mb-1.5 block text-sm font-medium">Path</label>
<input id="{{ $id }}-path" type="text" class="input" x-model="path" placeholder="/api/v3"
autocomplete="off" />
<p class="mt-1 text-[12px] text-neutral-500 dark:text-fg-dim">
Optional path, query, or fragment appended after the domain and port.
</p>
</div>
</div>
@@ -38,11 +38,11 @@
this.visible = true;
const rect = target.getBoundingClientRect();
this.below = rect.top < 48;
this.x = rect.left + rect.width / 2;
this.x = rect.left;
this.y = this.below ? rect.bottom + 8 : rect.top - 8;
this.$nextTick(() => {
const width = this.$refs.tooltip?.offsetWidth || 0;
this.x = Math.max(width / 2 + 8, Math.min(window.innerWidth - width / 2 - 8, this.x));
this.x = Math.max(8, Math.min(window.innerWidth - width - 8, this.x));
this.$nextTick(() => this.positioned = true);
});
},
@@ -71,6 +71,6 @@
<div x-ref="tooltip" x-show="visible" x-cloak role="tooltip" x-text="text"
:style="`left: ${x}px; top: ${y}px;`"
:class="[below ? '' : '-translate-y-full', positioned ? 'visible' : 'invisible']"
class="pointer-events-none fixed z-[100] -translate-x-1/2 whitespace-nowrap rounded-lg border border-neutral-700 bg-neutral-900 px-2 py-1 text-xs font-medium text-white shadow-lg dark:border-white/10 dark:bg-raised">
class="pointer-events-none fixed z-[100] whitespace-nowrap rounded-lg border border-neutral-700 bg-neutral-900 px-2 py-1 text-xs font-medium text-white shadow-lg dark:border-white/10 dark:bg-raised">
</div>
</div>
@@ -50,14 +50,23 @@
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="translate-y-0 opacity-100"
x-transition:leave-end="translate-y-3 opacity-0"
class="fixed bottom-4 right-4 z-999"
:class="iconOnly
? 'w-auto max-w-[calc(100%-2rem)]'
: (compact
? 'w-[calc(100%-2rem)] sm:w-auto sm:max-w-[calc(100%-2rem)]'
: 'w-[calc(100%-2rem)] max-w-sm')">
<div class="relative flex items-start gap-2.5 rounded-lg p-3 pr-10"
:class="compact ? (iconOnly ? 'cursor-pointer p-2! pr-2!' : 'cursor-pointer') : ''" @click="restore()"
class="fixed bottom-4 right-4 z-999">
<button x-show="iconOnly" type="button" @click="restore()" aria-label="Restore warning"
class="flex rounded-lg p-2"
style="background: var(--coollabs-elevated); box-shadow: 0 0 0 1px var(--coollabs-line), var(--shadow-modal);">
@isset($icon)
<span
class="flex size-7 shrink-0 items-center justify-center rounded-md bg-amber-100 text-amber-700 dark:bg-warning/10 dark:text-warning">
{{ $icon }}
</span>
@endisset
</button>
<div x-show="!iconOnly" class="relative flex items-start gap-2.5 rounded-lg p-3 pr-10"
:class="compact
? 'w-[calc(100vw-2rem)] cursor-pointer sm:w-auto sm:max-w-[calc(100vw-2rem)]'
: 'w-[calc(100vw-2rem)] max-w-sm'"
@click="restore()"
style="background: var(--coollabs-elevated); box-shadow: 0 0 0 1px var(--coollabs-line), var(--shadow-modal);">
@isset($icon)
<div
@@ -66,7 +75,7 @@
</div>
@endisset
<div x-show="!iconOnly" class="min-w-0 flex-1">
<div class="min-w-0 flex-1">
<h4 class="text-[13px] font-semibold leading-4 text-neutral-950 dark:text-fg">
{{ $title }}
</h4>
@@ -76,7 +85,7 @@
</div>
</div>
<button x-show="!iconOnly" type="button" @click.stop="minimizeToIcon()" aria-label="Minimize warning"
<button type="button" @click.stop="minimizeToIcon()" aria-label="Minimize warning"
class="absolute right-2 top-2 flex size-6 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-black/5 hover:text-neutral-700 dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg">
<x-reicon name="x" class="size-3.5" />
</button>
@@ -176,20 +176,12 @@
const toggle = qs(root, '[data-sth-toggle]');
if (toggle) {
if (isMobileSlot) {
// Compact pill: just "83ms" — no full metric breakdown in the bar.
toggle.style.padding = '4px 8px';
toggle.style.fontSize = '11px';
toggle.style.maxWidth = 'none';
toggle.style.overflow = 'visible';
toggle.style.whiteSpace = 'nowrap';
} else {
toggle.style.padding = '';
toggle.style.fontSize = '';
toggle.style.maxWidth = '100%';
toggle.style.overflow = 'hidden';
toggle.style.textOverflow = 'ellipsis';
}
// Keep the navbar pill compact at every breakpoint; details stay in the panel.
toggle.style.padding = '4px 8px';
toggle.style.fontSize = '11px';
toggle.style.maxWidth = 'none';
toggle.style.overflow = 'visible';
toggle.style.whiteSpace = 'nowrap';
}
const panel = qs(root, '[data-sth-panel]');
@@ -680,10 +672,8 @@
const q = latest.metrics.queries !== undefined ? Math.round(Number(latest.metrics.queries)) + 'q' : '—';
const db = latest.metrics.db !== undefined ? Number(latest.metrics.db).toFixed(0) + 'ms db' : '—';
const n = history.length;
// Mobile navbar is tight — show only total app time; full breakdown lives in the panel.
const compactSummary = root.getAttribute('data-sth-mode') === 'docked'
&& root.parentElement
&& root.parentElement.id === 'server-timing-hud-slot-mobile';
// Navbar pills show only total app time; full breakdown lives in the panel.
const compactSummary = root.getAttribute('data-sth-mode') === 'docked';
summary.textContent = compactSummary
? app
: ('ST ' + app + ' · ' + db + ' · ' + q + (n > 1 ? ' · ×' + n : ''));