mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
Replace datalist and searchable-listbox components with the shared listbox and migrate form fields. Deduplicate superseded Livewire updates to prevent repeated array update hooks, with regression coverage.
194 lines
7.1 KiB
JavaScript
194 lines
7.1 KiB
JavaScript
import { initializeCopyButtonComponent } from './copy-button.js';
|
|
import { initializeRuntimeLogsComponent } from './runtime-logs.js';
|
|
import { initializeSettingsSidebarAccordionComponent } from './settings-sidebar-accordion.js';
|
|
import { initializeTerminalComponent } from './terminal.js';
|
|
import './traffic-globe.js';
|
|
import { registerLivewireRequestFailureHandler } from './livewire-request-failure.js';
|
|
import { registerLivewireUpdateDedupe } from './livewire-update-dedupe.js';
|
|
import { parseSubmitAction } from './modal-confirmation.js';
|
|
|
|
// Used by the modal-confirmation Blade component to call its submitAction.
|
|
window.parseModalSubmitAction = parseSubmitAction;
|
|
|
|
document.addEventListener('livewire:init', () => {
|
|
registerLivewireRequestFailureHandler(window.Livewire);
|
|
registerLivewireUpdateDedupe(window.Livewire);
|
|
});
|
|
|
|
// Livewire 3.5.19+ re-applies `x-cloak` to morphed elements during wire:navigate
|
|
// (via replaceHtmlAttributes). With `[x-cloak]{display:none}` on the app wrapper,
|
|
// this blanks the whole page on every navigation until Alpine re-processes it.
|
|
// Strip leftover x-cloak after each navigation; the initial-load FOUC guard stays.
|
|
document.addEventListener('livewire:navigated', () => {
|
|
document.querySelectorAll('[x-cloak]').forEach((el) => el.removeAttribute('x-cloak'));
|
|
});
|
|
|
|
// Register the terminal data provider before Alpine initializes the page.
|
|
// Keeping this registration independent from the current route also makes it
|
|
// available before Alpine processes terminal markup after wire:navigate.
|
|
document.addEventListener('alpine:init', initializeTerminalComponent);
|
|
document.addEventListener('alpine:init', initializeCopyButtonComponent);
|
|
document.addEventListener('alpine:init', initializeRuntimeLogsComponent);
|
|
document.addEventListener('alpine:init', initializeSettingsSidebarAccordionComponent);
|
|
|
|
/**
|
|
* Smooth-scroll a settings section into view, then flash its border for 500ms
|
|
* after the scroll has settled. Starting the flash immediately makes long
|
|
* jumps (top → bottom) finish scrolling after the animation has already ended.
|
|
*
|
|
* @param {string|HTMLElement} target Section id or element
|
|
*/
|
|
window.scrollToSettingsSection = function scrollToSettingsSection(target) {
|
|
const el = typeof target === 'string' ? document.getElementById(target) : target;
|
|
if (!el) {
|
|
return;
|
|
}
|
|
|
|
if (typeof el._sectionHighlightCleanup === 'function') {
|
|
el._sectionHighlightCleanup();
|
|
}
|
|
|
|
const runHighlight = () => {
|
|
el.classList.remove('is-section-highlight');
|
|
// Force reflow so the 500ms highlight can re-run on repeated clicks.
|
|
void el.offsetWidth;
|
|
el.classList.add('is-section-highlight');
|
|
el._sectionHighlightTimer = window.setTimeout(() => {
|
|
el.classList.remove('is-section-highlight');
|
|
}, 500);
|
|
};
|
|
|
|
let finished = false;
|
|
let rafId = 0;
|
|
let scrollEndHandler = null;
|
|
|
|
const cleanup = () => {
|
|
if (rafId) {
|
|
window.cancelAnimationFrame(rafId);
|
|
rafId = 0;
|
|
}
|
|
if (scrollEndHandler) {
|
|
window.removeEventListener('scrollend', scrollEndHandler);
|
|
scrollEndHandler = null;
|
|
}
|
|
if (el._sectionHighlightTimer) {
|
|
window.clearTimeout(el._sectionHighlightTimer);
|
|
el._sectionHighlightTimer = null;
|
|
}
|
|
};
|
|
|
|
const finish = () => {
|
|
if (finished) {
|
|
return;
|
|
}
|
|
finished = true;
|
|
cleanup();
|
|
runHighlight();
|
|
};
|
|
|
|
el._sectionHighlightCleanup = () => {
|
|
finished = true;
|
|
cleanup();
|
|
el.classList.remove('is-section-highlight');
|
|
el._sectionHighlightCleanup = null;
|
|
};
|
|
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
|
|
// Prefer the native scrollend event when the browser fires it.
|
|
scrollEndHandler = () => finish();
|
|
window.addEventListener('scrollend', scrollEndHandler, { once: true });
|
|
|
|
// Fallback: wait until the target's Y position is stable for a few frames
|
|
// (covers browsers without scrollend, and no-op scrolls when already in view).
|
|
let lastTop = null;
|
|
let stableFrames = 0;
|
|
let frames = 0;
|
|
const maxFrames = 180; // ~3s safety cap
|
|
|
|
const tick = () => {
|
|
if (finished) {
|
|
return;
|
|
}
|
|
|
|
frames += 1;
|
|
const top = el.getBoundingClientRect().top;
|
|
|
|
if (lastTop !== null && Math.abs(top - lastTop) < 0.5) {
|
|
stableFrames += 1;
|
|
} else {
|
|
stableFrames = 0;
|
|
}
|
|
lastTop = top;
|
|
|
|
// Skip the first couple frames so we don't flash before smooth scroll starts.
|
|
if (frames > 4 && stableFrames >= 4) {
|
|
finish();
|
|
return;
|
|
}
|
|
|
|
if (frames >= maxFrames) {
|
|
finish();
|
|
return;
|
|
}
|
|
|
|
rafId = window.requestAnimationFrame(tick);
|
|
};
|
|
|
|
rafId = window.requestAnimationFrame(tick);
|
|
};
|
|
|
|
/**
|
|
* Items for the "This page" scope of the command palette: the pages of the
|
|
* settings sidebar (rendered by the server), then the visible sections of the
|
|
* current page. Sections that the sidebar already lists are skipped.
|
|
*
|
|
* @returns {Array<{label: string, breadcrumb: string, search_text: string, href: string, navigate?: boolean, element?: HTMLElement}>}
|
|
*/
|
|
window.currentPageSearchItems = function currentPageSearchItems() {
|
|
let items = [];
|
|
try {
|
|
items = JSON.parse(document.querySelector('[data-settings-search-items]')?.dataset.settingsSearchItems || '[]');
|
|
} catch (e) {
|
|
items = [];
|
|
}
|
|
|
|
const toPath = (href) => {
|
|
const url = new URL(href, window.location.href);
|
|
return url.pathname + url.hash;
|
|
};
|
|
const sidebarPaths = new Set(items.map((item) => toPath(item.href)));
|
|
const headingSelector = ':scope > header :is(h1, h2, h3), :scope > .application-settings-section-header :is(h1, h2, h3)';
|
|
|
|
document.querySelectorAll('.application-settings-section').forEach((section, index) => {
|
|
if (section.closest('[role=dialog], .command-palette') || section.getClientRects().length === 0) {
|
|
return;
|
|
}
|
|
if (section.id && sidebarPaths.has(window.location.pathname + '#' + section.id)) {
|
|
return;
|
|
}
|
|
const label = (section.dataset.settingsSectionTitle ?? section.querySelector(headingSelector)?.textContent ?? '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
if (!label) {
|
|
return;
|
|
}
|
|
items.push({ label, breadcrumb: 'Section', search_text: label + ' section', href: 'section-' + index, element: section });
|
|
});
|
|
|
|
return items;
|
|
};
|
|
|
|
// When a settings sub-section link navigates across pages (href="route#section-id"),
|
|
// scroll to that section once the destination page has rendered.
|
|
function scrollToHashSettingsSection() {
|
|
const hash = window.location.hash;
|
|
if (!hash || hash.length < 2) {
|
|
return;
|
|
}
|
|
const id = decodeURIComponent(hash.slice(1));
|
|
window.requestAnimationFrame(() => window.scrollToSettingsSection?.(id));
|
|
}
|
|
document.addEventListener('livewire:navigated', scrollToHashSettingsSection);
|
|
document.addEventListener('DOMContentLoaded', scrollToHashSettingsSection);
|