Merge remote-tracking branch 'origin/next' into feat/noindex-domains

This commit is contained in:
Andras Bacsai committed 2026-08-08 11:26:20 +02:00
commit 003e44d123
1274 files changed
+156179 -25249

No files matched your search

+3742 -10
View File
File diff suppressed because it is too large. Load diff
+79 -23
View File
@@ -59,11 +59,11 @@
/* input, select before */
@utility input-select {
@apply block py-1.5 w-full text-sm text-black rounded-sm border-0 dark:bg-coolgray-100 dark:text-white disabled:bg-neutral-200 disabled:text-neutral-500 dark:disabled:bg-coolgray-100/40;
box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #e5e5e5;
@apply block h-9 px-3 py-1.5 w-full text-sm text-black rounded-md border border-neutral-200 bg-white dark:bg-surface dark:text-fg dark:border-white/[0.08] transition-colors disabled:bg-neutral-100 disabled:text-neutral-400 dark:disabled:bg-white/[0.03] dark:disabled:text-fg-faint;
box-shadow: none;
&:where(.dark, .dark *) {
box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #242424;
box-shadow: none;
}
&:disabled {
@@ -82,11 +82,13 @@
@apply focus-visible:outline-none;
&:focus-visible {
box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5;
border-color: var(--color-accent);
box-shadow: 0 0 0 1px var(--color-accent);
}
&:where(.dark, .dark *):focus-visible {
box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424;
border-color: var(--color-accent);
box-shadow: 0 0 0 1px var(--color-accent);
}
&:read-only {
@@ -113,20 +115,55 @@
}
&:focus-visible {
box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5;
border-color: var(--color-accent);
box-shadow: 0 0 0 1px var(--color-accent);
}
&:where(.dark, .dark *):focus-visible {
box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424;
border-color: var(--color-accent);
box-shadow: 0 0 0 1px var(--color-accent);
}
}
@utility button {
@apply flex gap-2 justify-center items-center px-2 h-8 text-sm text-black normal-case rounded-sm border-2 outline-0 cursor-pointer font-medium bg-white border-neutral-200 hover:bg-neutral-100 dark:bg-coolgray-100 dark:text-white dark:hover:text-white dark:hover:bg-coolgray-200 dark:border-coolgray-300 hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-neutral-600 disabled:border-neutral-200 dark:disabled:border-coolgray-300 disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base;
/* h-9 matches input-select; nowrap + shrink-0 keep side-by-side action rows equal height */
@apply inline-flex shrink-0 gap-1.5 justify-center items-center whitespace-nowrap px-2.5 h-9 min-h-9 text-[13px] text-black normal-case rounded-md border outline-0 cursor-pointer font-medium transition-colors bg-white border-neutral-200 hover:bg-neutral-100 dark:bg-white/[0.06] dark:text-fg dark:hover:text-fg dark:hover:bg-white/[0.1] dark:border-white/[0.08] hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-fg-faint disabled:border-neutral-200 dark:disabled:border-white/[0.06] disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent;
}
@utility button-highlighted {
@apply border-2 text-coollabs-200 dark:text-white bg-coollabs-50 dark:bg-coollabs/20 border-coollabs dark:border-coollabs-100 hover:bg-coollabs hover:text-white dark:hover:bg-coollabs-100 dark:hover:text-white;
}
@utility control-selected {
@apply bg-coollabs text-white dark:bg-coollabs dark:text-white;
}
@utility loading-indicator {
@apply text-coollabs dark:text-warning;
}
/* Compact icon-only control (gear, chevrons, etc.) */
@utility icon-button {
@apply inline-flex size-7 shrink-0 items-center justify-center rounded-md border border-transparent text-neutral-400 outline-0 transition-colors hover:bg-neutral-100 hover:text-black focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent disabled:pointer-events-none disabled:opacity-35 dark:text-fg-faint dark:hover:bg-white/[0.07] dark:hover:text-fg;
}
/* Hide static icons while a Livewire loading spinner is shown on the button. */
.button.is-loading > svg:not(.animate-spin) {
display: none;
}
/* Compact resource tab pill */
@utility app-tab {
@apply inline-flex items-center gap-1 h-7 px-2.5 rounded-md text-[13px] font-medium text-neutral-500 dark:text-fg-dim hover:bg-neutral-100 dark:hover:bg-white/[0.05] hover:text-black dark:hover:text-fg transition-colors;
}
/* Active resource tab (used with aria-current="page") */
@utility app-tab-active {
@apply bg-coollabs/10 text-coollabs shadow-sm ring-1 ring-coollabs/25 hover:bg-coollabs/15 dark:bg-warning/15 dark:text-warning dark:ring-warning/25 dark:hover:bg-warning/20;
}
@utility auth-tooltip {
@apply fixed z-[99] px-2.5 py-1.5 text-xs rounded-sm pointer-events-none whitespace-nowrap text-neutral-700 bg-neutral-200 dark:text-neutral-300 dark:bg-coolgray-400;
@apply fixed z-[99] px-2.5 py-1.5 text-xs font-medium rounded-lg pointer-events-none whitespace-nowrap text-white bg-neutral-900 border border-neutral-700 shadow-lg dark:text-fg dark:bg-raised dark:border-white/10;
}
@utility alert-success {
@@ -145,6 +182,10 @@
@apply flex items-center px-2 text-xs cursor-pointer dark:text-neutral-500/20 text-neutral-500 group-hover:text-neutral-700 dark:group-hover:text-white dark:hover:bg-coolgray-300 hover:bg-neutral-200;
}
@utility user-menu-item {
@apply flex items-center gap-2.5 px-3 h-8 w-full text-[13px] font-medium text-neutral-600 dark:text-fg-dim hover:bg-neutral-100 dark:hover:bg-white/[0.06] hover:text-black dark:hover:text-fg transition-colors cursor-pointer;
}
@utility dropdown-item {
@apply flex relative gap-2 justify-start items-center py-1 pr-4 pl-2 w-full text-xs transition-colors cursor-pointer select-none dark:text-white hover:bg-neutral-100 dark:hover:bg-coollabs outline-none data-disabled:pointer-events-none data-disabled:opacity-50 focus-visible:bg-neutral-100 dark:focus-visible:bg-coollabs;
}
@@ -182,10 +223,10 @@
}
@utility menu-item {
@apply flex gap-3 items-center px-2 py-1 w-full text-sm dark:hover:bg-coolgray-100 dark:hover:text-white hover:bg-neutral-300 rounded-sm truncate min-w-0;
@apply relative flex gap-2.5 items-center h-8 px-2.5 w-full text-[13px] font-medium rounded-md truncate min-w-0 transition-colors text-nav-text hover:bg-neutral-100 hover:text-nav-active dark:hover:bg-white/[0.05];
}
@utility menu-item-icon {
@apply shrink-0 size-4 dark:hover:text-white;
@apply shrink-0 size-[18px] opacity-90;
}
@utility menu-item-label {
@@ -193,15 +234,30 @@
}
@utility menu-item-active {
@apply text-black rounded-sm dark:bg-coolgray-200 dark:text-warning bg-neutral-200 overflow-hidden;
/* Solid selected pill only — no accent rail / border. */
@apply overflow-hidden rounded-md bg-black/[0.05] text-nav-active hover:bg-black/[0.05] dark:bg-white/[0.06] dark:hover:bg-white/[0.06];
}
/* Subtle Title-Case section header */
@utility nav-section {
@apply px-2.5 pt-1 pb-1 text-[11px] font-medium text-nav-muted select-none;
}
/* Indented child rows in a collapsible nav group */
@utility menu-subitem {
/* Label owns text ellipsis; keep this row overflow-visible so the focus ring is not clipped. */
@apply relative flex gap-2.5 items-center h-8 pl-3 pr-2.5 w-full text-[13px] font-medium rounded-md min-w-0 transition-colors text-nav-text hover:bg-neutral-100 hover:text-nav-active dark:hover:bg-white/[0.05];
}
@utility menu-subitem-active {
@apply rounded-md bg-black/[0.05] text-nav-active hover:bg-black/[0.05] dark:bg-white/[0.06] dark:hover:bg-white/[0.06];
}
@utility sub-menu-wrapper {
@apply flex flex-col items-start gap-2 min-w-40 sm:min-w-48 w-auto max-w-full sm:flex-shrink;
@apply flex flex-col gap-0.5 w-full md:w-52 shrink-0;
}
@utility sub-menu-item {
@apply flex gap-2 items-center px-2 py-1 w-full text-sm dark:hover:bg-coolgray-100 dark:hover:text-white hover:bg-neutral-300 rounded-sm truncate min-w-0;
@apply relative flex gap-2 items-center h-8 px-2.5 w-full text-[13px] font-medium rounded-none truncate min-w-0 transition-colors text-nav-text hover:bg-neutral-100 dark:hover:bg-white/[0.05] hover:text-nav-active;
}
@utility sub-menu-item-icon {
@@ -217,7 +273,7 @@
}
@utility scrollbar {
@apply scrollbar-thumb-coollabs-100 scrollbar-track-neutral-200 dark:scrollbar-track-coolgray-200 scrollbar-thin;
@apply scrollbar-thumb-coollabs-100 scrollbar-track-neutral-200 dark:scrollbar-thumb-warning dark:scrollbar-track-coolgray-200 scrollbar-thin;
}
@utility main {
@@ -229,7 +285,7 @@
}
@utility navbar-main {
@apply flex flex-col gap-4 justify-items-start pb-2 border-b-2 border-solid h-fit md:flex-row sm:justify-between dark:border-coolgray-200 border-neutral-200 md:items-center text-neutral-700 dark:text-neutral-400;
@apply flex flex-col gap-4 justify-items-start pb-3 border-b border-solid h-fit md:flex-row sm:justify-between dark:border-white/[0.06] border-neutral-200 md:items-center text-neutral-700 dark:text-fg-dim;
}
@utility loading {
@@ -241,7 +297,7 @@
}
@utility box {
@apply relative flex lg:flex-row flex-col p-2 transition-colors cursor-pointer min-h-[4rem] dark:bg-coolgray-100 shadow-sm bg-white border text-black dark:text-white hover:text-black border-neutral-200 dark:border-coolgray-300 hover:bg-neutral-100 dark:hover:bg-coollabs-100 dark:hover:text-white hover:no-underline rounded-sm;
@apply relative flex lg:flex-row flex-col p-3 transition-colors cursor-pointer min-h-[4rem] bg-white dark:bg-surface border text-black dark:text-fg hover:text-black border-neutral-200 dark:border-white/[0.06] hover:bg-neutral-50 dark:hover:bg-raised dark:hover:border-white/[0.1] dark:hover:text-fg hover:no-underline rounded-md;
}
@utility box-boarding {
@@ -257,7 +313,7 @@
}
@utility coolbox {
@apply relative flex transition-all duration-150 dark:bg-coolgray-100 bg-white p-2 rounded border border-neutral-200 dark:border-coolgray-400 hover:ring-2 dark:hover:ring-warning hover:ring-coollabs cursor-pointer min-h-[4rem];
@apply relative flex items-center transition-all duration-150 bg-white dark:bg-surface p-4 rounded-2xl border border-neutral-200 dark:border-white/[0.06] hover:border-neutral-300 dark:hover:border-white/[0.12] dark:hover:bg-raised hover:shadow-[0_4px_16px_rgba(0,0,0,0.35)] cursor-pointer min-h-[4.5rem];
}
@utility on-box {
@@ -265,11 +321,11 @@
}
@utility box-title {
@apply font-bold text-black dark:text-white dark:group-hover:text-white;
@apply font-semibold text-black dark:text-white dark:group-hover:text-white;
}
@utility box-description {
@apply text-xs font-bold text-neutral-500 dark:group-hover:text-white group-hover:text-black;
@apply text-xs font-medium text-neutral-500 dark:text-fg-faint dark:group-hover:text-fg-dim group-hover:text-black;
}
@utility description {
@@ -281,15 +337,15 @@
}
@utility text-helper {
@apply inline-block font-bold text-coollabs dark:text-warning;
@apply inline-block font-semibold text-coollabs dark:text-warning;
}
@utility info-helper {
@apply cursor-pointer text-coollabs dark:text-warning;
@apply cursor-pointer text-neutral-400 transition-colors hover:text-neutral-600 dark:text-fg-faint dark:hover:text-fg-dim;
}
@utility info-helper-popup {
@apply hidden absolute right-0 z-40 w-max max-w-[min(20rem,calc(100vw-2rem))] text-xs rounded-sm text-neutral-700 group-hover:block dark:border-coolgray-500 border-neutral-900 dark:bg-coolgray-400 bg-neutral-200 dark:text-neutral-300 whitespace-normal break-words;
@apply rounded-lg border border-neutral-200 bg-white text-neutral-600 shadow-modal whitespace-normal break-words dark:border-white/10 dark:bg-raised dark:text-fg-dim;
}
@utility buyme {
+182
View File
@@ -0,0 +1,182 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/geist";
@source '../../js/v5/**/*.{js,jsx,ts,tsx}';
@source '../../../storage/framework/views/*.php';
@custom-variant dark (&:where(.dark, .dark *));
@theme inline {
--font-sans: 'Geist Variable', sans-serif;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-warning: var(--warning);
--color-coollabs-50: #f5f0ff;
--color-coollabs: #6b16ed;
--color-coollabs-100: #7317ff;
--color-coollabs-200: #5a12c7;
--color-coollabs-300: #4a0fa3;
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
--default-ring-color: var(--ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--warning: #fcd452;
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: var(--warning);
--chart-1: oklch(0.871 0.006 286.286);
--chart-2: oklch(0.552 0.016 285.938);
--chart-3: oklch(0.442 0.017 285.786);
--chart-4: oklch(0.37 0.013 285.805);
--chart-5: oklch(0.274 0.006 286.033);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
}
.dark {
--background: #101010;
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--warning: #fcd452;
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: var(--warning);
--chart-1: oklch(0.871 0.006 286.286);
--chart-2: oklch(0.552 0.016 285.938);
--chart-3: oklch(0.442 0.017 285.786);
--chart-4: oklch(0.37 0.013 285.805);
--chart-5: oklch(0.274 0.006 286.033);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
}
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
@apply border-border outline-ring/50;
--tw-ring-offset-color: var(--background);
}
:where(a, button, input, select, textarea, [role="button"], [tabindex]:not([tabindex="-1"])):focus-visible {
@apply outline-none ring-2 ring-ring ring-offset-2;
}
html,
body,
#v5-app {
min-height: 100dvh;
}
html {
@apply font-sans;
}
@media (max-width: 767px) {
:root input,
:root textarea,
:root select {
font-size: 16px !important;
}
}
body {
@apply bg-background text-foreground;
min-height: 100dvh;
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
button:not(:disabled),
[role="button"]:not(:disabled) {
cursor: pointer;
}
}
@@ -0,0 +1,20 @@
{
"providerId": "coolify.io",
"providerName": "Coolify",
"serviceId": "hosting",
"serviceName": "Coolify Hosting",
"version": 1,
"logoUrl": "https://coolify.io/coolify-transparent.png",
"description": "Point your domain to a Coolify-managed server via an A record.",
"variableDescription": "ip is the Coolify server IP. host is the subdomain relative to the zone (empty for apex).",
"syncPubKeyDomain": "domainconnect.coolify.io",
"syncBlock": false,
"records": [
{
"type": "A",
"host": "%host%",
"pointsTo": "%ip%",
"ttl": 3600
}
]
}
+110 -7
View File
@@ -8,11 +8,114 @@ document.addEventListener('livewire:navigated', () => {
document.querySelectorAll('[x-cloak]').forEach((el) => el.removeAttribute('x-cloak'));
});
['livewire:navigated', 'alpine:init'].forEach((event) => {
document.addEventListener(event, () => {
// tree-shaking
if (document.getElementById('terminal-container')) {
initializeTerminalComponent()
// 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);
/**
* 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} id
*/
window.scrollToSettingsSection = function scrollToSettingsSection(id) {
const el = document.getElementById(id);
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);
};
+583 -59
View File
@@ -8,7 +8,143 @@ import {
} from './terminal-session-timer.js';
import { FitAddon } from '@xterm/addon-fit';
const terminalDebugEnabled = import.meta.env.DEV;
const terminalDebugParameter = new URLSearchParams(window.location.search).get('terminal-debug');
if (terminalDebugParameter === '1' || terminalDebugParameter === '0') {
localStorage.setItem('coolify-terminal-debug', terminalDebugParameter);
}
const terminalDebugEnabled = import.meta.env.DEV
|| localStorage.getItem('coolify-terminal-debug') === '1';
const baseApplicationTerminalTheme = {
black: '#675f70',
red: '#ef7272',
green: '#7bd88f',
yellow: '#e7bd68',
blue: '#85aacb',
magenta: '#c792ea',
cyan: '#72d5d0',
white: '#d8d2df',
brightBlack: '#8a8292',
brightRed: '#ff9b9b',
brightGreen: '#a5e7b2',
brightYellow: '#f2d596',
brightBlue: '#b0c8df',
brightMagenta: '#ddb3f4',
brightCyan: '#a7e8e4',
brightWhite: '#ffffff',
foreground: '#eee9f2',
background: '#00000000',
overviewRulerBorder: '#00000000',
};
function createApplicationTerminalTheme(accent, colors = {}) {
return {
...baseApplicationTerminalTheme,
cursor: accent,
cursorAccent: '#101012',
selectionBackground: `${accent}66`,
...colors,
};
}
function customThemeAccent() {
const color = localStorage.getItem('themeColor') || '#6b16ed';
if (!/^#[0-9a-f]{6}$/i.test(color)) {
return '#7c3aed';
}
const channels = color.match(/[a-f\d]{2}/gi).map((channel) => (
Math.round(parseInt(channel, 16) * 0.85 + 255 * 0.15)
));
return `#${channels.map((channel) => channel.toString(16).padStart(2, '0')).join('')}`;
}
function createSystemTerminalTheme() {
if (document.documentElement.dataset.theme === 'custom') {
return createApplicationTerminalTheme(customThemeAccent());
}
if (document.documentElement.classList.contains('dark')) {
return createApplicationTerminalTheme('#8C8E9C');
}
return createApplicationTerminalTheme('#52525b', {
black: '#18181b',
red: '#dc2626',
green: '#15803d',
yellow: '#a16207',
blue: '#2563eb',
magenta: '#9333ea',
cyan: '#0e7490',
white: '#52525b',
brightBlack: '#71717a',
brightWhite: '#18181b',
foreground: '#18181b',
});
}
const applicationTerminalThemes = {
'system': createSystemTerminalTheme(),
'shadows-midnight': createApplicationTerminalTheme('#6d7a7c', {
blue: '#7392ad',
cyan: '#7fa3a6',
brightBlue: '#9bb4c9',
brightCyan: '#a8c4c6',
}),
'shadows-golden-hour': createApplicationTerminalTheme('#bf8c3c', {
yellow: '#d9a759',
red: '#df7756',
brightYellow: '#edc987',
brightRed: '#efa086',
}),
'shadows-cosmic-purple': createApplicationTerminalTheme('#A76DBE', {
blue: '#8f86d9',
magenta: '#c58ad8',
brightBlue: '#b1a9ed',
brightMagenta: '#ddb0e9',
}),
'shadows-neon-glow': createApplicationTerminalTheme('#DB425A', {
red: '#ed5d72',
magenta: '#f35fc2',
brightRed: '#ff8c9d',
brightMagenta: '#ff93d7',
}),
'shadows-icy-mist': createApplicationTerminalTheme('#93b7c4', {
blue: '#8fb7d0',
cyan: '#9acbd0',
brightBlue: '#b9d5e5',
brightCyan: '#c0e3e5',
}),
'shadows-tropical-storm': createApplicationTerminalTheme('#1fa771', {
green: '#45c98b',
cyan: '#4ec7ad',
brightGreen: '#7de0ad',
brightCyan: '#80dfcc',
}),
'shadows-golden-nebula': createApplicationTerminalTheme('#d4a20e', {
yellow: '#e5bb35',
red: '#ee755f',
blue: '#718fc1',
brightYellow: '#f4d375',
}),
'shadows-cosmic-lagoon': createApplicationTerminalTheme('#00b5b8', {
blue: '#668de0',
magenta: '#ba6ad0',
cyan: '#38c6c8',
brightCyan: '#76e0e2',
}),
'shadows-neon-nebula': createApplicationTerminalTheme('#ff55aa', {
blue: '#6f91dd',
magenta: '#ff72c1',
cyan: '#51d5d5',
brightMagenta: '#ffa1d4',
}),
'shadows-transparent': createApplicationTerminalTheme('#8C8E9C'),
};
function logTerminal(level, message, ...context) {
if (!terminalDebugEnabled) {
@@ -23,6 +159,7 @@ export function initializeTerminalComponent() {
return {
fullscreen: false,
terminalActive: false,
starting: false,
message: '(connection closed)',
term: null,
fitAddon: null,
@@ -59,11 +196,36 @@ export function initializeTerminalComponent() {
isDocumentVisible: true,
wasConnectedBeforeHidden: false,
mobileToolbarCollapsed: false,
// Inline style snapshots for ancestors unlocked while fullscreen (no DOM reparenting).
fullscreenAncestorPatches: null,
pageScrollLocked: false,
scrollLockY: 0,
scrollLockStyles: null,
preventPageScrollHandler: null,
themeObserver: null,
terminalSessionStartedAt: null,
terminalSessionRemainingSeconds: null,
terminalSessionCountdownInterval: null,
selectedTheme: applicationTerminalThemes[localStorage.getItem('coolify-console-theme')]
? localStorage.getItem('coolify-console-theme')
: 'system',
init() {
this.starting = this.$el.dataset.autoStart === 'true';
this.themeObserver = new MutationObserver(() => {
if (this.selectedTheme === 'system') {
applicationTerminalThemes.system = createSystemTerminalTheme();
this.setTerminalTheme('system');
}
});
this.themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class', 'data-theme', 'style'],
});
// Recover if a previous portal build left the terminal on <body>.
this.$nextTick(() => this.salvageStrayFullscreenNodes());
this.setupTerminal();
// Add a small delay for initial connection to ensure everything is ready
@@ -103,7 +265,9 @@ export function initializeTerminalComponent() {
this.resizeObserver.observe(this.$refs.terminalWrapper);
}
} else {
this.$refs.terminalWrapper.style.display = 'none';
const terminalElement = document.getElementById('terminal');
this.$refs.terminalWrapper.style.display =
terminalElement?.dataset.terminalStyle === 'application' ? 'block' : 'none';
// Stop observing when terminal is inactive
if (this.resizeObserver) {
@@ -146,6 +310,8 @@ export function initializeTerminalComponent() {
this.connectionState = 'disconnected';
this.pendingCommand = null;
this.resetTerminalSessionCountdown();
this.exitFullscreen();
this.unlockPageScroll();
if (this.socket) {
this.socket.close(1000, 'Client cleanup');
}
@@ -232,6 +398,68 @@ export function initializeTerminalComponent() {
return 'text-neutral-300 bg-black/70 border-white/10';
},
setTerminalTheme(themeName) {
if (!applicationTerminalThemes[themeName]) {
logTerminal('warn', '[Terminal Theme] Unknown theme', {
requestedTheme: themeName,
availableThemes: Object.keys(applicationTerminalThemes),
});
return;
}
logTerminal('log', '[Terminal Theme] Applying theme', this.terminalThemeDebugSnapshot(themeName));
if (themeName === 'system') {
applicationTerminalThemes.system = createSystemTerminalTheme();
}
this.selectedTheme = themeName;
localStorage.setItem('coolify-console-theme', themeName);
if (this.term) {
const cursorBlink = this.term.options.cursorBlink;
this.term.options.cursorBlink = false;
this.term.options.theme = { ...applicationTerminalThemes[themeName] };
this.term.refresh(0, Math.max(0, this.term.rows - 1));
requestAnimationFrame(() => {
if (!this.term) {
return;
}
this.term.options.cursorBlink = cursorBlink;
this.term.refresh(0, Math.max(0, this.term.rows - 1));
this.term.focus();
logTerminal('log', '[Terminal Theme] Theme applied', this.terminalThemeDebugSnapshot(themeName));
});
}
},
terminalThemeDebugSnapshot(themeName) {
const shell = this.$el.closest('.application-console-shell');
const viewport = this.term?.element?.querySelector('.xterm-viewport');
const screen = this.term?.element?.querySelector('.xterm-screen');
return {
requestedTheme: themeName,
selectedTheme: this.selectedTheme,
terminalExists: Boolean(this.term),
terminalOpened: Boolean(this.term?.element),
terminalActive: this.terminalActive,
connectionState: this.connectionState,
shellTheme: shell?.dataset.consoleTheme,
shellBackground: shell ? getComputedStyle(shell).background : null,
shellThemeBackground: shell ? getComputedStyle(shell).getPropertyValue('--console-theme-background') : null,
shellThemeOpacity: shell ? getComputedStyle(shell).getPropertyValue('--console-theme-opacity') : null,
shellPseudoBackground: shell ? getComputedStyle(shell, '::before').background : null,
viewportBackground: viewport ? getComputedStyle(viewport).background : null,
screenBackground: screen ? getComputedStyle(screen).background : null,
xtermBackground: this.term?.options.theme?.background,
xtermForeground: this.term?.options.theme?.foreground,
xtermCursor: this.term?.options.theme?.cursor,
};
},
resetTerminal() {
if (this.term) {
this.$wire.dispatch('error', 'Terminal websocket connection lost. Reconnecting...');
@@ -265,14 +493,24 @@ export function initializeTerminalComponent() {
setupTerminal() {
const terminalElement = document.getElementById('terminal');
if (terminalElement) {
const isApplicationConsole = terminalElement.dataset.terminalStyle === 'application';
this.term = new Terminal({
cols: 80,
rows: 30,
fontFamily: '"Geist Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace, "Powerline Extra Symbols"',
fontSize: isApplicationConsole ? 13 : 14,
fontWeight: isApplicationConsole ? 550 : 'normal',
fontWeightBold: 700,
lineHeight: isApplicationConsole ? 1.15 : 1,
cursorBlink: true,
cursorStyle: 'block',
rendererType: 'canvas',
convertEol: true,
disableStdin: false
disableStdin: false,
scrollback: 5000,
theme: isApplicationConsole
? applicationTerminalThemes[this.selectedTheme] ?? applicationTerminalThemes.system
: undefined
});
this.fitAddon = new FitAddon();
this.term.loadAddon(this.fitAddon);
@@ -479,6 +717,7 @@ export function initializeTerminalComponent() {
}
if (event.data === 'pty-ready') {
this.starting = false;
if (!this.term._initialized) {
this.term.open(document.getElementById('terminal'));
this.term._initialized = true;
@@ -518,6 +757,7 @@ export function initializeTerminalComponent() {
// Notify parent component that terminal is connected
this.$wire.dispatch('terminalConnected');
} else if (event.data === 'unprocessable') {
this.starting = false;
if (this.term) this.term.reset();
this.terminalActive = false;
this.lastSentCommand = null;
@@ -527,7 +767,8 @@ export function initializeTerminalComponent() {
// Notify parent component that terminal connection failed
this.$wire.dispatch('terminalDisconnected');
} else if (event.data === 'pty-exited') {
this.fullscreen = false;
this.starting = false;
this.exitFullscreen();
this.mobileToolbarCollapsed = false;
this.terminalActive = false;
this.resetTerminalSessionCountdown();
@@ -605,6 +846,10 @@ export function initializeTerminalComponent() {
});
},
destroy() {
this.themeObserver?.disconnect();
},
sendTerminalInput(data) {
if (!this.term || !this.terminalActive) {
@@ -735,73 +980,352 @@ export function initializeTerminalComponent() {
},
makeFullscreen() {
this.fullscreen = !this.fullscreen;
this.$nextTick(() => {
// Force a layout reflow to ensure DOM changes are applied
this.$refs.terminalWrapper.offsetHeight;
if (this.fullscreen) {
this.exitFullscreen();
} else {
this.enterFullscreen();
}
},
// Add a small delay to ensure CSS transitions complete
setTimeout(() => {
/**
* Keep the terminal in-place (no document.body reparent). Livewire morphs
* recreate missing children when nodes leave the component tree, which left
* an empty console shell and dumped the real xterm below the page.
*
* Instead, neutralize ancestor isolation/transform/filter/overflow so
* position:fixed + z-index can cover the viewport above sidebar/top bar.
*/
enterFullscreen() {
const wrapper = this.$refs.terminalWrapper;
if (!wrapper || this.fullscreen) {
return;
}
this.salvageStrayFullscreenNodes();
this.patchAncestorsForFullscreen(wrapper);
this.lockPageScroll();
wrapper.style.removeProperty('display');
wrapper.style.removeProperty('height');
wrapper.style.removeProperty('min-height');
this.fullscreen = true;
document.documentElement.classList.add('terminal-is-fullscreen');
document.body.classList.add('terminal-is-fullscreen');
this.scheduleTerminalResize();
},
exitFullscreen() {
const wrapper = this.$refs.terminalWrapper;
this.restoreAncestorsAfterFullscreen();
this.unlockPageScroll();
this.fullscreen = false;
document.documentElement.classList.remove('terminal-is-fullscreen');
document.body.classList.remove('terminal-is-fullscreen');
if (wrapper) {
wrapper.style.removeProperty('display');
wrapper.style.removeProperty('height');
wrapper.style.removeProperty('min-height');
}
// Recover from older portal builds that left the terminal on <body>.
this.salvageStrayFullscreenNodes();
this.scheduleTerminalResize();
},
/**
* Freeze document scroll while fullscreen. Only xterm's own viewport may scroll.
* Uses position:fixed scroll-lock so nested overflow:visible ancestors cannot
* re-enable page scrolling under the overlay.
*/
lockPageScroll() {
if (this.pageScrollLocked) {
return;
}
this.pageScrollLocked = true;
this.scrollLockY = window.scrollY || document.documentElement.scrollTop || 0;
this.scrollLockStyles = {
htmlOverflow: document.documentElement.style.getPropertyValue('overflow'),
htmlOverscroll: document.documentElement.style.getPropertyValue('overscroll-behavior'),
bodyOverflow: document.body.style.getPropertyValue('overflow'),
bodyPosition: document.body.style.getPropertyValue('position'),
bodyTop: document.body.style.getPropertyValue('top'),
bodyLeft: document.body.style.getPropertyValue('left'),
bodyRight: document.body.style.getPropertyValue('right'),
bodyWidth: document.body.style.getPropertyValue('width'),
bodyPaddingRight: document.body.style.getPropertyValue('padding-right'),
bodyOverscroll: document.body.style.getPropertyValue('overscroll-behavior'),
};
const scrollbarGap = Math.max(0, window.innerWidth - document.documentElement.clientWidth);
document.documentElement.style.setProperty('overflow', 'hidden', 'important');
document.documentElement.style.setProperty('overscroll-behavior', 'none', 'important');
document.body.style.setProperty('overflow', 'hidden', 'important');
document.body.style.setProperty('overscroll-behavior', 'none', 'important');
document.body.style.setProperty('position', 'fixed', 'important');
document.body.style.setProperty('top', `-${this.scrollLockY}px`, 'important');
document.body.style.setProperty('left', '0', 'important');
document.body.style.setProperty('right', '0', 'important');
document.body.style.setProperty('width', '100%', 'important');
if (scrollbarGap > 0) {
document.body.style.setProperty('padding-right', `${scrollbarGap}px`, 'important');
}
this.preventPageScrollHandler = (event) => {
const target = event.target;
if (!(target instanceof Element)) {
event.preventDefault();
return;
}
// Allow terminal scrollback and mobile toolbar touches only.
if (
target.closest('.xterm-viewport') ||
target.closest('[data-terminal-mobile-toolbar]') ||
target.closest('.terminal-fullscreen-btn')
) {
return;
}
event.preventDefault();
};
window.addEventListener('wheel', this.preventPageScrollHandler, { passive: false });
window.addEventListener('touchmove', this.preventPageScrollHandler, { passive: false });
},
unlockPageScroll() {
if (!this.pageScrollLocked) {
return;
}
this.pageScrollLocked = false;
if (this.preventPageScrollHandler) {
window.removeEventListener('wheel', this.preventPageScrollHandler);
window.removeEventListener('touchmove', this.preventPageScrollHandler);
this.preventPageScrollHandler = null;
}
const restore = (el, prop, value) => {
if (value) {
el.style.setProperty(prop, value);
} else {
el.style.removeProperty(prop);
}
};
const styles = this.scrollLockStyles ?? {};
restore(document.documentElement, 'overflow', styles.htmlOverflow);
restore(document.documentElement, 'overscroll-behavior', styles.htmlOverscroll);
restore(document.body, 'overflow', styles.bodyOverflow);
restore(document.body, 'position', styles.bodyPosition);
restore(document.body, 'top', styles.bodyTop);
restore(document.body, 'left', styles.bodyLeft);
restore(document.body, 'right', styles.bodyRight);
restore(document.body, 'width', styles.bodyWidth);
restore(document.body, 'padding-right', styles.bodyPaddingRight);
restore(document.body, 'overscroll-behavior', styles.bodyOverscroll);
this.scrollLockStyles = null;
window.scrollTo(0, this.scrollLockY || 0);
this.scrollLockY = 0;
},
patchAncestorsForFullscreen(fromEl) {
this.restoreAncestorsAfterFullscreen();
this.fullscreenAncestorPatches = [];
let node = fromEl.parentElement;
while (node && node !== document.documentElement) {
this.fullscreenAncestorPatches.push({
el: node,
isolation: node.style.getPropertyValue('isolation'),
transform: node.style.getPropertyValue('transform'),
filter: node.style.getPropertyValue('filter'),
backdropFilter: node.style.getPropertyValue('backdrop-filter'),
contain: node.style.getPropertyValue('contain'),
overflow: node.style.getPropertyValue('overflow'),
overflowX: node.style.getPropertyValue('overflow-x'),
overflowY: node.style.getPropertyValue('overflow-y'),
willChange: node.style.getPropertyValue('will-change'),
perspective: node.style.getPropertyValue('perspective'),
zIndex: node.style.getPropertyValue('z-index'),
position: node.style.getPropertyValue('position'),
});
// Drop fixed-position containing blocks + nested stacking contexts.
// Critical: CSS classes like .application-console-block { z-index: 1 }
// trap position:fixed descendants under the sidebar (z-40) / top bar (z-50)
// unless z-index is forced back to auto on the whole ancestor chain.
node.style.setProperty('isolation', 'auto', 'important');
node.style.setProperty('transform', 'none', 'important');
node.style.setProperty('filter', 'none', 'important');
node.style.setProperty('backdrop-filter', 'none', 'important');
node.style.setProperty('contain', 'none', 'important');
node.style.setProperty('perspective', 'none', 'important');
node.style.setProperty('will-change', 'auto', 'important');
node.style.setProperty('overflow', 'visible', 'important');
node.style.setProperty('overflow-x', 'visible', 'important');
node.style.setProperty('overflow-y', 'visible', 'important');
node.style.setProperty('z-index', 'auto', 'important');
node = node.parentElement;
}
// Sidebar/top bar are layout siblings of <main>, not ancestors. Elevate
// main so the fullscreen stacking context paints above z-50 chrome.
const main = fromEl.closest('main');
if (main) {
const existing = this.fullscreenAncestorPatches.find((patch) => patch.el === main);
if (!existing) {
this.fullscreenAncestorPatches.push({
el: main,
isolation: main.style.getPropertyValue('isolation'),
transform: main.style.getPropertyValue('transform'),
filter: main.style.getPropertyValue('filter'),
backdropFilter: main.style.getPropertyValue('backdrop-filter'),
contain: main.style.getPropertyValue('contain'),
overflow: main.style.getPropertyValue('overflow'),
overflowX: main.style.getPropertyValue('overflow-x'),
overflowY: main.style.getPropertyValue('overflow-y'),
willChange: main.style.getPropertyValue('will-change'),
perspective: main.style.getPropertyValue('perspective'),
zIndex: main.style.getPropertyValue('z-index'),
position: main.style.getPropertyValue('position'),
});
}
if (getComputedStyle(main).position === 'static') {
main.style.setProperty('position', 'relative', 'important');
}
main.style.setProperty('z-index', '100001', 'important');
}
},
restoreAncestorsAfterFullscreen() {
if (!this.fullscreenAncestorPatches?.length) {
this.fullscreenAncestorPatches = null;
return;
}
for (const patch of this.fullscreenAncestorPatches) {
const el = patch.el;
if (!el?.style) {
continue;
}
const entries = [
['isolation', patch.isolation],
['transform', patch.transform],
['filter', patch.filter],
['backdrop-filter', patch.backdropFilter],
['contain', patch.contain],
['overflow', patch.overflow],
['overflow-x', patch.overflowX],
['overflow-y', patch.overflowY],
['will-change', patch.willChange],
['perspective', patch.perspective],
['z-index', patch.zIndex],
['position', patch.position],
];
for (const [prop, value] of entries) {
if (value) {
el.style.setProperty(prop, value);
} else {
el.style.removeProperty(prop);
}
}
}
this.fullscreenAncestorPatches = null;
},
/**
* Older fullscreen code reparented the wrapper to document.body. Livewire then
* recreated an empty shell in-place. Pull any stray terminal hosts back home.
*/
salvageStrayFullscreenNodes() {
const host = document.getElementById('terminal-container');
if (!host) {
return;
}
const wrapper = this.$refs.terminalWrapper;
if (wrapper && wrapper.parentElement === document.body) {
host.appendChild(wrapper);
}
document.querySelectorAll('body > .terminal-fullscreen-shell').forEach((node) => {
if (node === wrapper) {
host.appendChild(node);
return;
}
if (node.querySelector('#terminal') || node.id === 'terminal') {
host.appendChild(node);
} else {
node.remove();
}
});
},
scheduleTerminalResize() {
this.$nextTick(() => {
// Multi-pass fit: Alpine class swaps need a couple frames before the
// host has a stable clientHeight for FitAddon.
this.resizeTerminal();
requestAnimationFrame(() => {
this.resizeTerminal();
}, 100);
setTimeout(() => this.resizeTerminal(), 50);
setTimeout(() => this.resizeTerminal(), 150);
});
});
},
resizeTerminal() {
if (!this.terminalActive || !this.term || !this.fitAddon) return;
if (!this.terminalActive || !this.term || !this.fitAddon) {
return;
}
try {
// Force a refresh of the fit addon dimensions
const terminalElement = document.getElementById('terminal');
if (!terminalElement || !this.term.element) {
return;
}
// Host must already be laid out; otherwise FitAddon under-reads and
// later the canvas keeps a wrong pixel height (page stretches on exit).
if (terminalElement.clientHeight < 24 || terminalElement.clientWidth < 24) {
setTimeout(() => this.resizeTerminal(), 50);
return;
}
const previousCols = this.term.cols;
const previousRows = this.term.rows;
this.fitAddon.fit();
// Get fresh dimensions from the terminal element itself. The mobile
// toolbar can live beside the terminal in normal flow, so wrapper dimensions
// would include controls that should not be counted as terminal rows.
const terminalElement = document.getElementById('terminal');
const terminalHeight = terminalElement?.clientHeight || this.$refs.terminalWrapper.clientHeight;
const terminalWidth = terminalElement?.clientWidth || this.$refs.terminalWrapper.clientWidth;
// Account for terminal container padding. In fullscreen mobile mode,
// the fixed toolbar sits over the terminal container, so reserve its height
// when calculating rows to keep the prompt above the controls.
const horizontalPadding = 16; // px-2 = 8px * 2 (left + right)
const verticalPadding = 8; // py-1 = 4px * 2 (top + bottom)
const height = terminalHeight - verticalPadding;
const width = terminalWidth - horizontalPadding;
// Check if dimensions are valid
if (height <= 0 || width <= 0) {
logTerminal('warn', '[Terminal] Invalid wrapper dimensions, retrying...', { height, width });
setTimeout(() => this.resizeTerminal(), 100);
return;
// Keep the xterm chrome inside the host so overflow becomes scrollback,
// not document growth after leaving fullscreen.
if (this.term.element) {
this.term.element.style.width = '100%';
this.term.element.style.height = '100%';
this.term.element.style.maxHeight = '100%';
}
const charSize = this.term._core._renderService._charSizeService;
if (!charSize.height || !charSize.width) {
// Fallback values if char size not available yet
logTerminal('warn', '[Terminal] Character size not available, retrying...');
setTimeout(() => this.resizeTerminal(), 100);
return;
}
// Calculate new dimensions with padding considerations
const rows = Math.floor(height / charSize.height) - 1;
const cols = Math.floor(width / charSize.width) - 1;
if (rows > 0 && cols > 0) {
// Check if dimensions actually changed to avoid unnecessary resizes
const currentCols = this.term.cols;
const currentRows = this.term.rows;
if (cols !== currentCols || rows !== currentRows) {
this.term.resize(cols, rows);
this.sendMessage({
resize: { cols: cols, rows: rows }
});
}
} else {
logTerminal('warn', '[Terminal] Invalid calculated dimensions:', { rows, cols, height, width, charSize });
if (
this.term.cols > 0 &&
this.term.rows > 0 &&
(this.term.cols !== previousCols || this.term.rows !== previousRows)
) {
this.sendMessage({
resize: { cols: this.term.cols, rows: this.term.rows },
});
}
} catch (error) {
logTerminal('error', '[Terminal] Resize error:', error);
File diff suppressed because it is too large. Load diff
+806
View File
@@ -0,0 +1,806 @@
import { Head } from '@inertiajs/react';
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent, type PointerEvent } from 'react';
import { AppNavbar } from '@/components/app-navbar';
import { ApplicationCard } from '@/components/canvas/application-card';
import { ApplicationInspectorSheet } from '@/components/canvas/application-inspector-sheet';
import { CaddyIngressCard } from '@/components/canvas/caddy-ingress-card';
import { CanvasNotice } from '@/components/canvas/canvas-notice';
import { CanvasToolbar } from '@/components/canvas/canvas-toolbar';
import { ConnectionLines } from '@/components/canvas/connection-lines';
import { ConnectionPortsEditor } from '@/components/canvas/connection-ports-editor';
import { IngressDialog } from '@/components/canvas/ingress-dialog';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { TooltipProvider } from '@/components/ui/tooltip';
import { canvasRequest } from '@/lib/canvas-api';
import {
connectorPoint,
resolveApplicationPosition,
resolveIngressPosition,
settleCanvasResources,
type ConnectionEndpoint,
type ConnectorSide,
type DraftConnection,
} from '@/lib/canvas-geometry';
import { runOptimisticUpdate } from '@/lib/optimistic';
import { useApplicationIngress } from '@/lib/use-application-ingress';
import { useCanvasConnections } from '@/lib/use-canvas-connections';
import { useCanvasResourceMerge } from '@/lib/use-canvas-resource-merge';
import { useCanvasViewport, type Viewport } from '@/lib/use-canvas-viewport';
import { usePendingIds } from '@/lib/use-pending-ids';
import type { V5Application, V5CaddyIngress, V5DashboardProps } from '@/types';
const DEFAULT_NGINX_IMAGE = 'docker.io/library/nginx:alpine';
type CanvasPosition = {
canvasX: number;
canvasY: number;
};
type DeleteApplicationResponse = {
message?: string;
can_delete_locally?: boolean;
};
type PendingLocalDelete = {
application: V5Application;
message: string;
};
type PointerState =
| {
type: 'pan';
pointerId: number;
startClientX: number;
startClientY: number;
startViewport: Viewport;
}
| {
type: 'app';
pointerId: number;
applicationId: string;
startClientX: number;
startClientY: number;
startX: number;
startY: number;
}
| {
type: 'ingress';
pointerId: number;
ingressId: string;
startClientX: number;
startClientY: number;
startX: number;
startY: number;
}
| {
type: 'connection';
pointerId: number;
from: ConnectionEndpoint;
};
export default function Dashboard({
flux,
currentTeam = null,
applications: initialApplications = [],
caddyIngresses = [],
resourceConnections: initialResourceConnections = [],
nginxServers = [],
projects = [],
selectedProjectUuid = null,
selectedEnvironmentUuid = null,
selectedApplicationUuid = null,
}: V5DashboardProps) {
const [applications, setApplications] = useState<V5Application[]>(initialApplications);
const [ingresses, setIngresses] = useState<V5CaddyIngress[]>(caddyIngresses);
const [selectedApplicationId, setSelectedApplicationId] = useState<string | null>(null);
const [selectedInspectorApplicationId, setSelectedInspectorApplicationId] = useState<string | null>(null);
const [draftConnection, setDraftConnection] = useState<DraftConnection | null>(null);
const [pointerState, setPointerState] = useState<PointerState | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [selectedNginxServerId, setSelectedNginxServerId] = useState<string>(nginxServers[0]?.id ?? '');
const [nginxImage, setNginxImage] = useState<string>(DEFAULT_NGINX_IMAGE);
const [isRefreshing, setIsRefreshing] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const [pendingLocalDelete, setPendingLocalDelete] = useState<PendingLocalDelete | null>(null);
const deletingApplications = usePendingIds<string>();
const applicationsRef = useRef(applications);
applicationsRef.current = applications;
/**
* Cards that are mid-drag or whose position PATCH has not landed yet keep
* their local canvasX/canvasY when a websocket merge arrives.
*/
const locallyPositionedApplicationIdsRef = useRef<Set<string>>(new Set());
const locallyPositionedIngressIdsRef = useRef<Set<string>>(new Set());
const { canvasRef, viewport, setViewport, zoomCanvas, handleCanvasWheel, centerOnCanvasNodes, canvasPointFromPointer } =
useCanvasViewport();
const {
connections,
selectedConnectionId,
setSelectedConnectionId,
connectionPortInput,
setConnectionPortDraft,
resetConnections,
connectionExists,
persistNewConnection,
deleteConnection,
updateConnectionDirection,
addConnectionPort,
removeConnectionPort,
removeConnectionsForApplication,
} = useCanvasConnections(initialResourceConnections, setNotice);
const applyApplicationUpdate = useCallback((application: V5Application): void => {
setApplications((currentApplications) =>
currentApplications.map((candidate) => (candidate.id === application.id ? application : candidate)),
);
}, []);
const {
ingressModal,
closeIngressModal,
setIngressModalDomains,
setIngressModalInternalPort,
submitApplicationIngress,
toggleApplicationIngress,
savingIngressApplications,
} = useApplicationIngress({ notify: setNotice, onApplicationUpdated: applyApplicationUpdate });
const hasCanvasNodes = applications.length > 0 || ingresses.length > 0;
const statusCounts = useMemo(
() => ({
running: applications.filter((application) => application.effectiveStatus === 'running').length,
failed: applications.filter((application) => application.effectiveStatus === 'failed').length,
unknown: applications.filter((application) => application.effectiveStatus === 'unknown').length,
}),
[applications],
);
const selectedInspectorApplication = useMemo(
() => applications.find((application) => application.id === selectedInspectorApplicationId) ?? null,
[applications, selectedInspectorApplicationId],
);
const selectedConnection = useMemo(
() => connections.find((connection) => connection.id === selectedConnectionId) ?? null,
[connections, selectedConnectionId],
);
useEffect(() => {
const settledResources = settleCanvasResources(initialApplications, caddyIngresses);
setApplications(settledResources.applications);
setIngresses(settledResources.ingresses);
resetConnections(initialResourceConnections);
setSelectedNginxServerId((currentServerId) => currentServerId || nginxServers[0]?.id || '');
const linkedApplicationExists = settledResources.applications.some((application) => application.id === selectedApplicationUuid);
setSelectedApplicationId(linkedApplicationExists ? selectedApplicationUuid : null);
setSelectedInspectorApplicationId(linkedApplicationExists ? selectedApplicationUuid : null);
centerOnCanvasNodes(settledResources.applications, settledResources.ingresses);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
initialApplications,
caddyIngresses,
initialResourceConnections,
nginxServers[0]?.id,
selectedProjectUuid,
selectedEnvironmentUuid,
selectedApplicationUuid,
]);
useCanvasResourceMerge({
teamId: currentTeam?.id ?? null,
selectedProjectUuid,
selectedEnvironmentUuid,
setApplications,
setIngresses,
locallyPositionedApplicationIds: locallyPositionedApplicationIdsRef,
locallyPositionedIngressIds: locallyPositionedIngressIdsRef,
});
const persistApplicationPosition = useCallback(async (application: V5Application, previousPosition: CanvasPosition): Promise<void> => {
await runOptimisticUpdate({
rollback: () =>
setApplications((currentApplications) =>
currentApplications.map((candidate) => (candidate.id === application.id ? { ...candidate, ...previousPosition } : candidate)),
),
request: async () => {
const response = await canvasRequest(`/v5/applications/${application.id}/position`, {
method: 'PATCH',
body: {
canvas_x: application.canvasX,
canvas_y: application.canvasY,
},
});
return response.ok ? { ok: true, payload: undefined } : { ok: false };
},
fallbackErrorMessage: 'Could not save the card position.',
notify: setNotice,
onSettled: () => locallyPositionedApplicationIdsRef.current.delete(application.id),
});
}, []);
const persistCaddyIngressPosition = useCallback(async (ingress: V5CaddyIngress, previousPosition: CanvasPosition): Promise<void> => {
await runOptimisticUpdate({
rollback: () =>
setIngresses((currentIngresses) =>
currentIngresses.map((candidate) => (candidate.id === ingress.id ? { ...candidate, ...previousPosition } : candidate)),
),
request: async () => {
const response = await canvasRequest(`/v5/caddy-ingresses/${ingress.id}/position`, {
method: 'PATCH',
body: {
canvas_x: ingress.canvasX,
canvas_y: ingress.canvasY,
},
});
return response.ok ? { ok: true, payload: undefined } : { ok: false };
},
fallbackErrorMessage: 'Could not save the card position.',
notify: setNotice,
onSettled: () => locallyPositionedIngressIdsRef.current.delete(ingress.id),
});
}, []);
const removeApplication = useCallback(
async (application: V5Application, deleteLocally = false): Promise<void> => {
setNotice(null);
deletingApplications.start(application.id);
try {
const response = await canvasRequest(`/v5/applications/${application.id}`, {
method: 'DELETE',
body: deleteLocally ? { delete_locally: true } : undefined,
});
if (!response.ok) {
const payload = (await response.json().catch(() => ({}))) as DeleteApplicationResponse;
const message = payload.message ?? 'Could not delete application.';
setNotice(message);
if (payload.can_delete_locally && !deleteLocally) {
setPendingLocalDelete({ application, message });
}
return;
}
setApplications((currentApplications) => currentApplications.filter((candidate) => candidate.id !== application.id));
removeConnectionsForApplication(application.id);
} catch (error) {
setNotice(error instanceof Error ? error.message : 'Could not delete application.');
} finally {
deletingApplications.finish(application.id);
}
},
[deletingApplications.start, deletingApplications.finish, removeConnectionsForApplication],
);
const deleteApplication = useCallback(
(application: V5Application): void => {
void removeApplication(application);
},
[removeApplication],
);
const addNginx = useCallback(async (): Promise<void> => {
setIsCreating(true);
setNotice(null);
try {
const response = await canvasRequest('/v5/applications/nginx', {
method: 'POST',
body: {
server_uuid: selectedNginxServerId || null,
image: nginxImage.trim() || DEFAULT_NGINX_IMAGE,
},
});
const payload = (await response.json()) as { application?: V5Application; message?: string };
if (!response.ok || !payload.application) {
setNotice(payload.message ?? 'Could not deploy nginx.');
return;
}
const settledResources = settleCanvasResources([...applicationsRef.current, payload.application], ingresses);
const settledApplication = settledResources.applications.find(
(application) => application.id === payload.application?.id,
);
setApplications(settledResources.applications);
setIngresses(settledResources.ingresses);
centerOnCanvasNodes(settledResources.applications, settledResources.ingresses);
if (
settledApplication &&
(settledApplication.canvasX !== payload.application.canvasX ||
settledApplication.canvasY !== payload.application.canvasY)
) {
locallyPositionedApplicationIdsRef.current.add(settledApplication.id);
void persistApplicationPosition(settledApplication, {
canvasX: payload.application.canvasX,
canvasY: payload.application.canvasY,
});
}
} catch (error) {
setNotice(error instanceof Error ? error.message : 'Could not deploy nginx.');
} finally {
setIsCreating(false);
}
}, [selectedNginxServerId, nginxImage, ingresses, centerOnCanvasNodes, persistApplicationPosition]);
const refreshApplications = useCallback(async (): Promise<void> => {
setIsRefreshing(true);
setNotice(null);
try {
const response = await canvasRequest('/v5/applications/refresh', { method: 'POST' });
const payload = (await response.json()) as {
applications?: V5Application[];
errors?: string[];
message?: string;
};
if (payload.applications) {
const settledResources = settleCanvasResources(payload.applications, ingresses);
setApplications(settledResources.applications);
setIngresses(settledResources.ingresses);
}
if (!response.ok) {
setNotice(payload.message ?? 'Could not refresh application state.');
} else if (payload.errors && payload.errors.length > 0) {
setNotice(payload.errors[0] ?? 'Could not refresh all application state.');
}
} catch (error) {
setNotice(error instanceof Error ? error.message : 'Could not refresh application state.');
} finally {
setIsRefreshing(false);
}
}, [ingresses]);
const startApplicationDrag = useCallback(
(event: PointerEvent<HTMLDivElement>, application: V5Application): void => {
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
setSelectedConnectionId(null);
setSelectedApplicationId(application.id);
locallyPositionedApplicationIdsRef.current.add(application.id);
setPointerState({
type: 'app',
pointerId: event.pointerId,
applicationId: application.id,
startClientX: event.clientX,
startClientY: event.clientY,
startX: application.canvasX,
startY: application.canvasY,
});
},
[setSelectedConnectionId],
);
const startIngressDrag = useCallback((event: PointerEvent<HTMLDivElement>, ingress: V5CaddyIngress): void => {
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
locallyPositionedIngressIdsRef.current.add(ingress.id);
setPointerState({
type: 'ingress',
pointerId: event.pointerId,
ingressId: ingress.id,
startClientX: event.clientX,
startClientY: event.clientY,
startX: ingress.canvasX,
startY: ingress.canvasY,
});
}, []);
const startConnectionDrag = useCallback(
(event: PointerEvent<HTMLButtonElement>, applicationId: string, side: ConnectorSide): void => {
event.stopPropagation();
const from = { applicationId, side };
const fromApplication = applicationsRef.current.find((candidate) => candidate.id === applicationId);
const startPoint = fromApplication ? connectorPoint(fromApplication, side) : canvasPointFromPointer(event);
setDraftConnection({
from,
toX: startPoint.x,
toY: startPoint.y,
});
setPointerState({
type: 'connection',
pointerId: event.pointerId,
from,
});
},
[canvasPointFromPointer],
);
const selectConnection = useCallback(
(event: MouseEvent<SVGLineElement>, connectionId: string): void => {
event.stopPropagation();
setSelectedConnectionId(connectionId);
setSelectedApplicationId(null);
},
[setSelectedConnectionId],
);
const openApplicationInspector = useCallback((event: MouseEvent<HTMLElement>, application: V5Application): void => {
event.stopPropagation();
setSelectedApplicationId(application.id);
setSelectedInspectorApplicationId(application.id);
}, []);
function startPan(event: PointerEvent<HTMLDivElement>): void {
if (event.target !== event.currentTarget) {
return;
}
event.currentTarget.setPointerCapture(event.pointerId);
setPointerState({
type: 'pan',
pointerId: event.pointerId,
startClientX: event.clientX,
startClientY: event.clientY,
startViewport: viewport,
});
}
function clearCanvasSelection(event: MouseEvent<HTMLDivElement>): void {
if (event.target !== event.currentTarget) {
return;
}
setSelectedConnectionId(null);
setSelectedApplicationId(null);
}
function connectionTargetFromPointer(event: PointerEvent<HTMLDivElement>): HTMLElement | null {
const pointerTarget = document.elementFromPoint(event.clientX, event.clientY) as HTMLElement | null;
// Touch browsers may keep pointer-captured mobile drags targeted at the origin connector.
return pointerTarget?.closest<HTMLElement>('[data-application-card]') ?? null;
}
function movePointer(event: PointerEvent<HTMLDivElement>): void {
if (!pointerState || pointerState.pointerId !== event.pointerId) {
return;
}
if (pointerState.type === 'connection') {
const point = canvasPointFromPointer(event);
setDraftConnection({
from: pointerState.from,
toX: point.x,
toY: point.y,
});
return;
}
const deltaX = event.clientX - pointerState.startClientX;
const deltaY = event.clientY - pointerState.startClientY;
if (pointerState.type === 'pan') {
setViewport({
x: pointerState.startViewport.x + deltaX,
y: pointerState.startViewport.y + deltaY,
zoom: pointerState.startViewport.zoom,
});
return;
}
if (pointerState.type === 'app') {
setApplications((currentApplications) =>
currentApplications.map((application) =>
application.id === pointerState.applicationId
? {
...application,
canvasX: Math.round(pointerState.startX + deltaX / viewport.zoom),
canvasY: Math.round(pointerState.startY + deltaY / viewport.zoom),
}
: application,
),
);
return;
}
setIngresses((currentIngresses) =>
currentIngresses.map((ingress) =>
ingress.id === pointerState.ingressId
? {
...ingress,
canvasX: Math.round(pointerState.startX + deltaX / viewport.zoom),
canvasY: Math.round(pointerState.startY + deltaY / viewport.zoom),
}
: ingress,
),
);
}
function stopPointer(event: PointerEvent<HTMLDivElement>): void {
if (!pointerState || pointerState.pointerId !== event.pointerId) {
return;
}
if (pointerState.type === 'connection') {
const target = connectionTargetFromPointer(event);
const targetApplicationId = target?.dataset.applicationId;
if (
targetApplicationId &&
targetApplicationId !== pointerState.from.applicationId &&
!connectionExists(pointerState.from.applicationId, targetApplicationId)
) {
void persistNewConnection(pointerState.from.applicationId, targetApplicationId);
}
setDraftConnection(null);
setPointerState(null);
return;
}
const deltaX = event.clientX - pointerState.startClientX;
const deltaY = event.clientY - pointerState.startClientY;
if (pointerState.type === 'app') {
const application = applications.find((candidate) => candidate.id === pointerState.applicationId);
if (application) {
const updatedApplication = resolveApplicationPosition(
{
...application,
canvasX: Math.round(pointerState.startX + deltaX / viewport.zoom),
canvasY: Math.round(pointerState.startY + deltaY / viewport.zoom),
},
applications,
ingresses,
);
setApplications((currentApplications) =>
currentApplications.map((candidate) => (candidate.id === updatedApplication.id ? updatedApplication : candidate)),
);
void persistApplicationPosition(updatedApplication, {
canvasX: pointerState.startX,
canvasY: pointerState.startY,
});
}
}
if (pointerState.type === 'ingress') {
const ingress = ingresses.find((candidate) => candidate.id === pointerState.ingressId);
if (ingress) {
const updatedIngress = resolveIngressPosition(
{
...ingress,
canvasX: Math.round(pointerState.startX + deltaX / viewport.zoom),
canvasY: Math.round(pointerState.startY + deltaY / viewport.zoom),
},
applications,
ingresses,
);
setIngresses((currentIngresses) =>
currentIngresses.map((candidate) => (candidate.id === updatedIngress.id ? updatedIngress : candidate)),
);
void persistCaddyIngressPosition(updatedIngress, {
canvasX: pointerState.startX,
canvasY: pointerState.startY,
});
}
}
setPointerState(null);
}
const deployNginx = useCallback((): void => {
void addNginx();
}, [addNginx]);
const refreshCanvas = useCallback((): void => {
void refreshApplications();
}, [refreshApplications]);
const centerCanvas = useCallback((): void => {
centerOnCanvasNodes(applications, ingresses);
}, [applications, ingresses, centerOnCanvasNodes]);
const zoomIn = useCallback((): void => zoomCanvas(1), [zoomCanvas]);
const zoomOut = useCallback((): void => zoomCanvas(-1), [zoomCanvas]);
const dismissNotice = useCallback((): void => setNotice(null), []);
const closeInspector = useCallback((): void => setSelectedInspectorApplicationId(null), []);
const submitIngress = useCallback((): void => {
void submitApplicationIngress();
}, [submitApplicationIngress]);
return (
<TooltipProvider>
<Head title="Dashboard" />
<div className="h-dvh overflow-hidden bg-background text-foreground">
<AppNavbar
flux={flux}
projects={projects}
selectedProjectUuid={selectedProjectUuid}
selectedEnvironmentUuid={selectedEnvironmentUuid}
/>
<main className="relative h-full min-h-0 overflow-hidden pt-16">
<CanvasToolbar
nginxServers={nginxServers}
selectedNginxServerId={selectedNginxServerId}
onSelectNginxServer={setSelectedNginxServerId}
nginxImage={nginxImage}
onNginxImageChange={setNginxImage}
isCreating={isCreating}
onDeploy={deployNginx}
onCenter={centerCanvas}
zoom={viewport.zoom}
onZoomIn={zoomIn}
onZoomOut={zoomOut}
isRefreshing={isRefreshing}
onRefresh={refreshCanvas}
applicationsCount={applications.length}
statusCounts={statusCounts}
/>
{notice && <CanvasNotice message={notice} onDismiss={dismissNotice} />}
<div
ref={canvasRef}
className="relative size-full touch-none overflow-hidden bg-[radial-gradient(circle_at_1px_1px,var(--border)_1px,transparent_0)] [background-size:32px_32px]"
onPointerDown={startPan}
onClick={clearCanvasSelection}
onPointerMove={movePointer}
onPointerUp={stopPointer}
onPointerCancel={stopPointer}
onWheel={handleCanvasWheel}
>
{!hasCanvasNodes && (
<section className="pointer-events-none absolute left-1/2 top-1/2 flex w-[min(32rem,calc(100%-2rem))] -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border bg-card/90 px-8 py-16 text-center shadow-sm">
<p className="text-sm font-medium text-foreground">No applications on this canvas yet.</p>
<p className="max-w-md text-sm text-muted-foreground">
Click Deploy to deploy a test container on one of your v5 servers.
</p>
</section>
)}
<div
className="absolute left-0 top-0"
style={{
transform: `translate3d(${viewport.x}px, ${viewport.y}px, 0) scale(${viewport.zoom})`,
transformOrigin: '0 0',
}}
>
{ingresses.map((ingress) => (
<CaddyIngressCard key={`caddy-ingress-${ingress.id}`} ingress={ingress} onDragStart={startIngressDrag} />
))}
<ConnectionLines
connections={connections}
applications={applications}
selectedConnectionId={selectedConnectionId}
draftConnection={draftConnection}
onSelectConnection={selectConnection}
/>
{selectedConnection && (
<ConnectionPortsEditor
connection={selectedConnection}
applications={applications}
portInput={connectionPortInput[selectedConnection.id] ?? ''}
onPortInputChange={setConnectionPortDraft}
onUpdateDirection={updateConnectionDirection}
onAddPort={addConnectionPort}
onRemovePort={removeConnectionPort}
onDelete={deleteConnection}
/>
)}
{applications.map((application) => (
<ApplicationCard
key={application.id}
application={application}
isSelected={selectedApplicationId === application.id}
isDeleting={deletingApplications.pendingIds.has(application.id)}
isIngressSaving={savingIngressApplications.pendingIds.has(application.id)}
onDragStart={startApplicationDrag}
onOpenInspector={openApplicationInspector}
onDelete={deleteApplication}
onToggleIngress={toggleApplicationIngress}
onConnectorPointerDown={startConnectionDrag}
/>
))}
</div>
</div>
</main>
</div>
<ApplicationInspectorSheet
application={selectedInspectorApplication}
isIngressSaving={
selectedInspectorApplication !== null && savingIngressApplications.pendingIds.has(selectedInspectorApplication.id)
}
onToggleIngress={toggleApplicationIngress}
onClose={closeInspector}
/>
<Dialog open={pendingLocalDelete !== null} onOpenChange={(open) => !open && setPendingLocalDelete(null)}>
<DialogContent className="max-w-md" showCloseButton={false}>
<DialogHeader>
<DialogTitle>Delete from Coolify only?</DialogTitle>
<DialogDescription>
Coolify could not reach the server to clean up containers, volumes, networks, or ingress config.
</DialogDescription>
</DialogHeader>
{pendingLocalDelete && (
<div className="flex flex-col gap-3 text-sm text-muted-foreground">
<p className="break-words rounded-md border border-border bg-muted/40 p-3 font-mono text-xs">
{pendingLocalDelete.message}
</p>
<p>
Deleting locally removes {pendingLocalDelete.application.name} from Coolify, but orphaned resources may remain on the unreachable server.
</p>
</div>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setPendingLocalDelete(null)}>
Cancel
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
const application = pendingLocalDelete?.application;
setPendingLocalDelete(null);
if (application) {
void removeApplication(application);
}
}}
>
Retry
</Button>
<Button
type="button"
variant="destructive"
onClick={() => {
const application = pendingLocalDelete?.application;
setPendingLocalDelete(null);
if (application) {
void removeApplication(application, true);
}
}}
>
Delete from Coolify only
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{ingressModal && (
<IngressDialog
modal={ingressModal}
isSaving={savingIngressApplications.pendingIds.has(ingressModal.application.id)}
onDomainsChange={setIngressModalDomains}
onInternalPortChange={setIngressModalInternalPort}
onSubmit={submitIngress}
onClose={closeIngressModal}
/>
)}
</TooltipProvider>
);
}
+147
View File
@@ -0,0 +1,147 @@
import { Head } from '@inertiajs/react';
import { useCallback, useEffect, useState } from 'react';
import { AppNavbar } from '@/components/app-navbar';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { csrfToken } from '@/lib/csrf';
import { useTeamChannel } from '@/lib/use-team-channel';
import type { V5DashboardProps } from '@/types';
type RealtimeTestProps = V5DashboardProps & {
currentTeam: {
id: number;
} | null;
};
type RealtimeTestEvent = {
message: string;
teamId: number;
sentAt: string;
};
function formatLogPayload(payload: unknown): string {
if (typeof payload === 'string') {
return payload;
}
return JSON.stringify(payload, null, 2);
}
export default function RealtimeTest({ currentTeam, flux, projects = [], selectedProjectUuid = null, selectedEnvironmentUuid = null }: RealtimeTestProps) {
const [message, setMessage] = useState('Hello from v5 realtime test');
const [isBroadcasting, setIsBroadcasting] = useState(false);
const [logs, setLogs] = useState<string[]>([]);
const addLog = useCallback((label: string, payload?: unknown): void => {
const timestamp = new Date().toLocaleTimeString();
setLogs((currentLogs) => [
`[${timestamp}] ${label}${payload === undefined ? '' : `\n${formatLogPayload(payload)}`}`,
...currentLogs,
]);
}, []);
useEffect(() => {
if (!currentTeam) {
addLog('No current team was provided to the page.');
}
}, [currentTeam, addLog]);
useTeamChannel(
currentTeam?.id ?? null,
'.v5.realtime.test',
(payload) => {
const event = payload as RealtimeTestEvent;
addLog('Received .v5.realtime.test', event);
},
{ onDebug: addLog, onError: addLog },
);
async function broadcastTestEvent(): Promise<void> {
setIsBroadcasting(true);
addLog('Sending POST /v5/realtime-test');
const response = await fetch('/v5/realtime-test', {
method: 'POST',
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken(),
},
body: JSON.stringify({ message }),
});
const payload = await response.json().catch(() => null);
addLog(`POST /v5/realtime-test responded ${response.status}`, payload);
setIsBroadcasting(false);
}
return (
<>
<Head title="Realtime test" />
<div className="min-h-dvh bg-background text-foreground">
<AppNavbar
flux={flux}
clusters={[]}
projects={projects}
selectedProjectUuid={selectedProjectUuid}
selectedEnvironmentUuid={selectedEnvironmentUuid}
/>
<main className="mx-auto flex max-w-5xl flex-col gap-6 px-4 pt-20 pb-8 lg:px-6">
<section className="rounded-lg border border-border bg-card p-5">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">RealtimeTest</p>
<h1 className="mt-1 text-2xl font-semibold text-foreground">v5 realtime websocket test</h1>
<p className="mt-2 text-sm text-muted-foreground">
Opens a private team channel subscription and broadcasts a manual backend event named{' '}
<code className="rounded bg-muted px-1 py-0.5">v5.realtime.test</code>.
</p>
</section>
<section className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div className="rounded-lg border border-border bg-card p-4">
<h2 className="text-sm font-semibold text-foreground">Runtime state</h2>
<dl className="mt-3 space-y-2 text-sm">
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Team ID</dt>
<dd className="font-medium text-foreground">{currentTeam?.id ?? 'Missing'}</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Echo</dt>
<dd className="font-medium text-foreground">{typeof window !== 'undefined' && window.Echo ? 'Available' : 'Missing'}</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Channel</dt>
<dd className="font-medium text-foreground">{currentTeam ? `private-team.${currentTeam.id}` : 'Missing'}</dd>
</div>
</dl>
</div>
<div className="rounded-lg border border-border bg-card p-4 lg:col-span-2">
<h2 className="text-sm font-semibold text-foreground">Send test event</h2>
<div className="mt-3 flex flex-col gap-3 sm:flex-row">
<Input value={message} onChange={(event) => setMessage(event.target.value)} />
<Button type="button" variant="coolify" disabled={isBroadcasting} onClick={() => void broadcastTestEvent()}>
{isBroadcasting ? 'Broadcasting...' : 'Broadcast event'}
</Button>
</div>
</div>
</section>
<section className="rounded-lg border border-border bg-card p-4">
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold text-foreground">Event log</h2>
<Button type="button" variant="outline" size="sm" onClick={() => setLogs([])}>
Clear
</Button>
</div>
<pre className="mt-3 min-h-64 overflow-auto whitespace-pre-wrap rounded-md bg-background p-4 text-xs text-muted-foreground">
{logs.length === 0 ? 'No logs yet.' : logs.join('\n\n')}
</pre>
</section>
</main>
</div>
</>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { createInertiaApp } from '@inertiajs/react';
import '../../css/v5/app.css';
createInertiaApp({
id: 'v5-app',
pages: {
path: './Pages',
extension: '.tsx',
},
strictMode: true,
progress: {
delay: 10,
color: '#fcd452',
showSpinner: false,
},
});
+243
View File
@@ -0,0 +1,243 @@
import { Link, router, usePage } from '@inertiajs/react';
import { useEffect, useMemo, useState } from 'react';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
import { csrfToken } from '@/lib/csrf';
import { cn } from '@/lib/utils';
import type { SelectItemOption, V5DashboardProps, V5Project } from '@/types';
async function persistSelection(projectUuid: string, environmentUuid: string): Promise<boolean> {
try {
const response = await fetch('/v5/selection', {
method: 'POST',
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken(),
},
body: JSON.stringify({
project_uuid: projectUuid,
environment_uuid: environmentUuid,
}),
});
if (!response.ok) {
console.error(`Could not persist the project/environment selection (HTTP ${response.status}).`);
}
return response.ok;
} catch (error) {
console.error('Could not persist the project/environment selection.', error);
return false;
}
}
function refreshCurrentPageSelection(): void {
router.reload({
only: [
'applications',
'caddyIngresses',
'resourceConnections',
'nginxServers',
'selectedProjectUuid',
'selectedEnvironmentUuid',
],
});
}
type AppNavbarProps = V5DashboardProps;
export function AppNavbar({
projects = [],
selectedProjectUuid = null,
selectedEnvironmentUuid = null,
}: AppNavbarProps) {
const { url } = usePage();
const firstProject = projects[0] ?? null;
const [projectUuid, setProjectUuid] = useState<string>(selectedProjectUuid ?? firstProject?.uuid ?? '');
const selectedProject = useMemo<V5Project | null>(
() => projects.find((project) => project.uuid === projectUuid) ?? firstProject,
[firstProject, projectUuid, projects],
);
const firstEnvironment = selectedProject?.environments?.[0] ?? null;
const [environmentUuid, setEnvironmentUuid] = useState<string>(selectedEnvironmentUuid ?? firstEnvironment?.uuid ?? '');
const selectedEnvironment = useMemo(
() => selectedProject?.environments?.find((environment) => environment.uuid === environmentUuid) ?? firstEnvironment,
[environmentUuid, firstEnvironment, selectedProject],
);
useEffect(() => {
setProjectUuid(selectedProjectUuid ?? firstProject?.uuid ?? '');
}, [firstProject?.uuid, selectedProjectUuid]);
useEffect(() => {
setEnvironmentUuid(selectedEnvironmentUuid ?? firstEnvironment?.uuid ?? '');
}, [firstEnvironment?.uuid, selectedEnvironmentUuid]);
function selectProject(nextProjectUuid: string | null): void {
if (nextProjectUuid === null) {
return;
}
const nextProject = projects.find((project) => project.uuid === nextProjectUuid);
const nextEnvironmentUuid = nextProject?.environments?.[0]?.uuid ?? '';
const previousProjectUuid = projectUuid;
const previousEnvironmentUuid = environmentUuid;
setProjectUuid(nextProjectUuid);
setEnvironmentUuid(nextEnvironmentUuid);
void persistSelection(nextProjectUuid, nextEnvironmentUuid).then((persisted) => {
if (persisted) {
refreshCurrentPageSelection();
return;
}
setProjectUuid(previousProjectUuid);
setEnvironmentUuid(previousEnvironmentUuid);
});
}
function selectEnvironment(nextEnvironmentUuid: string | null): void {
if (nextEnvironmentUuid === null) {
return;
}
const previousEnvironmentUuid = environmentUuid;
setEnvironmentUuid(nextEnvironmentUuid);
void persistSelection(projectUuid, nextEnvironmentUuid).then((persisted) => {
if (persisted) {
refreshCurrentPageSelection();
return;
}
setEnvironmentUuid(previousEnvironmentUuid);
});
}
const projectItems: SelectItemOption[] = projects.map((project) => ({
label: project.name,
value: project.uuid,
}));
const environmentItems: SelectItemOption[] = (selectedProject?.environments ?? []).map((environment) => ({
label: environment.name,
value: environment.uuid,
}));
const isClustersPage = url.startsWith('/v5/clusters');
return (
<header className="fixed inset-x-0 top-0 z-40 border-b border-border bg-background">
<nav className="relative flex h-16 items-center gap-3 px-4 sm:px-6" aria-label="Main navigation">
<Link
href="/v5"
className="flex shrink-0 items-center rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
aria-label="Coolify dashboard"
>
<img src="/coolify-logo.svg" alt="Coolify" className="size-6" />
</Link>
<div className="absolute left-1/2 flex min-w-0 -translate-x-1/2 items-center justify-center gap-1 md:static md:flex-1 md:translate-x-0 md:justify-start md:gap-2">
<Select
items={projectItems}
value={selectedProject?.uuid ?? ''}
onValueChange={selectProject}
disabled={projects.length === 0}
>
<SelectTrigger aria-label="Select a project" variant="ghost" className="max-w-[38vw] md:max-w-[10rem]">
<SelectValue placeholder="Select a project" />
</SelectTrigger>
<SelectContent position="popper" align="start" sideOffset={4}>
<SelectGroup>
{projects.map((project) => (
<SelectItem key={project.uuid} value={project.uuid}>
{project.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<span className="text-muted-foreground">/</span>
<Select
items={environmentItems}
value={selectedEnvironment?.uuid ?? ''}
onValueChange={selectEnvironment}
disabled={!selectedProject || (selectedProject.environments ?? []).length === 0}
>
<SelectTrigger aria-label="Select an environment" variant="ghost" className="max-w-[30vw] md:max-w-[10rem]">
<SelectValue placeholder="Select an environment" />
</SelectTrigger>
<SelectContent position="popper" align="start" sideOffset={4}>
<SelectGroup>
{(selectedProject?.environments ?? []).map((environment) => (
<SelectItem key={environment.uuid} value={environment.uuid}>
{environment.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="ml-auto flex items-center gap-3">
<Link
href="/v5/clusters"
className="hidden rounded-md px-3 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground md:inline-flex"
>
Clusters
</Link>
<Sheet>
<SheetTrigger
className="inline-flex rounded-md p-2 text-warning transition-colors hover:bg-muted hover:text-warning md:hidden"
aria-label="Open mobile menu"
>
<svg xmlns="http://www.w3.org/2000/svg" className="size-6" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</SheetTrigger>
<SheetContent side="right" className="w-72 max-w-[85vw] bg-background">
<SheetHeader>
<SheetTitle>Coolify</SheetTitle>
<SheetDescription className="sr-only">Move between Coolify v5 pages.</SheetDescription>
</SheetHeader>
<nav className="flex flex-col gap-1 px-4" aria-label="Mobile navigation">
<SheetClose
render={<Link href="/v5" />}
className={cn(
'rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-warning',
!isClustersPage && 'bg-accent text-accent-foreground',
)}
>
Dashboard
</SheetClose>
<SheetClose
render={<Link href="/v5/clusters" />}
className={cn(
'rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-warning',
isClustersPage && 'bg-accent text-accent-foreground',
)}
>
Clusters
</SheetClose>
</nav>
</SheetContent>
</Sheet>
</div>
</nav>
</header>
);
}
@@ -0,0 +1,129 @@
import { memo, type MouseEvent, type PointerEvent } from 'react';
import { ApplicationIngressButton } from '@/components/canvas/application-ingress-button';
import { statusBadgeClass } from '@/components/canvas/status-badge';
import { CONNECTOR_SIDES, type ConnectorSide } from '@/lib/canvas-geometry';
import { cn } from '@/lib/utils';
import type { V5Application } from '@/types';
type ApplicationCardProps = {
application: V5Application;
isSelected: boolean;
isDeleting: boolean;
isIngressSaving: boolean;
onDragStart: (event: PointerEvent<HTMLDivElement>, application: V5Application) => void;
onOpenInspector: (event: MouseEvent<HTMLElement>, application: V5Application) => void;
onDelete: (application: V5Application) => void;
onToggleIngress: (application: V5Application) => void;
onConnectorPointerDown: (event: PointerEvent<HTMLButtonElement>, applicationId: string, side: ConnectorSide) => void;
};
export const ApplicationCard = memo(function ApplicationCard({
application,
isSelected,
isDeleting,
isIngressSaving,
onDragStart,
onOpenInspector,
onDelete,
onToggleIngress,
onConnectorPointerDown,
}: ApplicationCardProps) {
return (
<div
data-application-card="application-card"
data-application-id={application.id}
className="group/application absolute h-40 w-80 select-none overflow-visible rounded-xl border border-border bg-card p-4 shadow-xl transition-shadow hover:shadow-2xl"
style={{
transform: `translate3d(${application.canvasX}px, ${application.canvasY}px, 0)`,
}}
onPointerDown={(event) => onDragStart(event, application)}
onDoubleClick={(event) => onOpenInspector(event, application)}
>
{CONNECTOR_SIDES.map((side) => (
<button
key={side}
type="button"
aria-label={`${application.name} ${side} connector`}
data-application-connector="application-connector"
data-application-id={application.id}
data-connector-side={side}
onPointerDown={(event) => onConnectorPointerDown(event, application.id, side)}
className={cn(
'application-connector group/connector absolute z-10 flex size-8 items-center justify-center rounded-full opacity-0 transition group-hover/application:opacity-100 md:size-3',
isSelected && 'opacity-100',
side === 'top' && 'left-1/2 top-0 -translate-x-1/2 -translate-y-1/2',
side === 'right' && 'right-0 top-1/2 -translate-y-1/2 translate-x-1/2',
side === 'bottom' && 'bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2',
side === 'left' && 'left-0 top-1/2 -translate-x-1/2 -translate-y-1/2',
)}
>
<span className="size-3 rounded-full border-2 border-card bg-warning shadow ring-2 ring-background transition group-hover/connector:scale-125 group-hover/connector:bg-warning/90" />
</button>
))}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-foreground">{application.name}</div>
<div className="mt-1 truncate text-xs text-muted-foreground">{application.image}</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span
className={cn(
'rounded-full px-2 py-1 text-[0.625rem] font-semibold uppercase tracking-wide',
statusBadgeClass(application.effectiveStatus),
)}
title={application.effectiveStatusMessage ?? undefined}
>
{application.effectiveStatus}
</span>
<button
type="button"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => onOpenInspector(event, application)}
className="rounded-md border border-border px-2 py-1 text-[0.625rem] font-semibold uppercase tracking-wide text-foreground transition hover:bg-muted"
>
Configure
</button>
<button
type="button"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onDelete(application);
}}
disabled={isDeleting}
className="rounded-md border border-destructive/40 px-2 py-1 text-[0.625rem] font-semibold uppercase tracking-wide text-destructive transition hover:bg-destructive/10 disabled:cursor-not-allowed disabled:opacity-60"
>
{isDeleting ? 'Deleting…' : 'Delete'}
</button>
</div>
</div>
<dl className="mt-4 grid gap-2 text-xs">
<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-3">
<dt className="shrink-0 text-muted-foreground">Server</dt>
<dd className="truncate text-right font-medium text-foreground">
{application.serverName ?? 'Unknown'}
{!application.isServerReachable && <span className="ml-2 text-destructive">(unreachable)</span>}
</dd>
</div>
<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-3">
<dt className="shrink-0 text-muted-foreground">Container</dt>
<dd className="truncate text-right font-mono text-[0.6875rem] text-foreground">{application.containerName}</dd>
</div>
<div className="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-3">
<dt className="shrink-0 text-muted-foreground">Ingress</dt>
<dd className="flex items-center justify-end gap-2 text-right">
<span className="truncate text-muted-foreground">
{application.ingressEnabled
? `${application.domains.length} domain${application.domains.length === 1 ? '' : 's'} → ${application.internalPort ?? 'no port'}`
: 'Private'}
</span>
<ApplicationIngressButton application={application} isSaving={isIngressSaving} onToggle={onToggleIngress} />
</dd>
</div>
</dl>
</div>
);
});
@@ -0,0 +1,39 @@
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import type { V5Application } from '@/types';
type ApplicationIngressButtonProps = {
application: V5Application;
isSaving: boolean;
onToggle: (application: V5Application) => void;
};
export function ApplicationIngressButton({ application, isSaving, onToggle }: ApplicationIngressButtonProps) {
const isDisabled = !application.ingressEnabled && !application.serverIngressEnabled;
const button = (
<button
type="button"
onPointerDown={(event) => event.stopPropagation()}
disabled={isDisabled || isSaving}
onClick={(event) => {
event.stopPropagation();
onToggle(application);
}}
className="rounded-sm border border-border px-2 py-1 text-[0.625rem] font-semibold uppercase tracking-wide text-foreground transition hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
>
{isSaving ? 'Saving...' : application.ingressEnabled ? 'Disable' : 'Enable'}
</button>
);
if (!isDisabled) {
return button;
}
return (
<Tooltip>
<TooltipTrigger render={<span className="inline-flex" />}>{button}</TooltipTrigger>
<TooltipContent side="top">
<p>You need to enable ingress in server settings first.</p>
</TooltipContent>
</Tooltip>
);
}
@@ -0,0 +1,276 @@
import { ApplicationIngressButton } from '@/components/canvas/application-ingress-button';
import { Button } from '@/components/ui/button';
import { Field, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import { apiRequest } from '@/lib/api';
import type { V5Application } from '@/types';
import { useCallback, useEffect, useState } from 'react';
type ApplicationInspectorSheetProps = {
application: V5Application | null;
isIngressSaving: boolean;
onToggleIngress: (application: V5Application) => void;
onClose: () => void;
};
type ApplicationLogsResponse = {
status: string;
statusMessage: string | null;
containerId: string | null;
logs: string | null;
logsError: string | null;
};
function ApplicationLogsTab({ application }: { application: V5Application }) {
const [isLoading, setIsLoading] = useState(false);
const [logs, setLogs] = useState<string | null>(null);
const [logsError, setLogsError] = useState<string | null>(null);
const [hasLoaded, setHasLoaded] = useState(false);
const fetchLogs = useCallback(async () => {
setIsLoading(true);
setLogsError(null);
try {
const response = await apiRequest(`/v5/applications/${application.id}/logs`, { method: 'GET' });
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const data = (await response.json()) as ApplicationLogsResponse;
setLogs(data.logs);
setLogsError(data.logsError);
} catch {
setLogs(null);
setLogsError('Could not reach the server to fetch container logs. Try again.');
} finally {
setIsLoading(false);
setHasLoaded(true);
}
}, [application.id]);
// Lazily fetch when the Logs tab first mounts for this app, and reset/refetch
// whenever a different application is inspected.
useEffect(() => {
setLogs(null);
setLogsError(null);
setHasLoaded(false);
void fetchLogs();
}, [fetchLogs]);
const statusMessage = application.effectiveStatusMessage ?? application.statusMessage;
return (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field>
<FieldLabel>Deploy status</FieldLabel>
<Input value={application.effectiveStatus} readOnly />
</Field>
<Field>
<FieldLabel>Container</FieldLabel>
<Input value={application.runtimeContainerId ?? 'Not created yet'} readOnly />
</Field>
</div>
<Field>
<FieldLabel>Status message</FieldLabel>
<Textarea value={statusMessage ?? 'No status message yet.'} readOnly className="min-h-16" />
</Field>
<Field>
<div className="flex items-center justify-between">
<FieldLabel>Container logs</FieldLabel>
<Button variant="outline" size="sm" onClick={() => void fetchLogs()} disabled={isLoading}>
{isLoading ? 'Refreshing…' : 'Refresh'}
</Button>
</div>
<Textarea
value={
isLoading && !hasLoaded
? 'Loading container logs…'
: logsError
? logsError
: logs
? logs
: hasLoaded
? 'No container logs yet. The container has not been created. See the status message above for the deploy result.'
: ''
}
readOnly
className="min-h-80 font-mono text-xs"
/>
</Field>
</div>
);
}
export function ApplicationInspectorSheet({ application, isIngressSaving, onToggleIngress, onClose }: ApplicationInspectorSheetProps) {
return (
<Sheet
open={application !== null}
onOpenChange={(open) => {
if (!open) {
onClose();
}
}}
>
<SheetContent side="right" className="w-full overflow-hidden bg-background sm:rounded-l-xl sm:border data-[side=right]:sm:!inset-y-4 data-[side=right]:sm:!h-auto data-[side=right]:sm:!w-[45vw] data-[side=right]:sm:!max-w-[45vw]" showCloseButton blurOverlay={false}>
{application && (
<>
<SheetHeader className="p-6 pb-4">
<SheetTitle>App configuration</SheetTitle>
<SheetDescription>
Double-click an application card to open configuration. Review runtime, networking, and advanced settings for{' '}
{application.name}.
</SheetDescription>
</SheetHeader>
<div className="flex flex-1 flex-col gap-6 px-6 pb-6">
<Tabs defaultValue="overview" className="gap-4">
<TabsList className="w-full justify-start" variant="line">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="networking">Networking</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
<TabsTrigger value="advanced">Advanced</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field>
<FieldLabel>Name</FieldLabel>
<Input value={application.name} readOnly />
</Field>
<Field>
<FieldLabel>Status</FieldLabel>
<Input value={application.effectiveStatus} readOnly />
</Field>
{application.effectiveStatus !== application.status && (
<Field>
<FieldLabel>Last known container status</FieldLabel>
<Input value={application.status} readOnly />
</Field>
)}
<Field>
<FieldLabel>Image</FieldLabel>
<Input value={application.image} readOnly />
</Field>
<Field>
<FieldLabel>Server</FieldLabel>
<Input
value={
application.isServerReachable
? (application.serverName ?? 'Unknown')
: `${application.serverName ?? 'Unknown'} (unreachable)`
}
readOnly
/>
</Field>
<Field>
<FieldLabel>Container</FieldLabel>
<Input value={application.containerName} readOnly />
</Field>
<Field>
<FieldLabel>Runtime container ID</FieldLabel>
<Input value={application.runtimeContainerId ?? 'Not available'} readOnly />
</Field>
</div>
<Field>
<FieldLabel>Status message</FieldLabel>
<Textarea
value={application.effectiveStatusMessage ?? 'No status message yet.'}
readOnly
className="min-h-20"
/>
</Field>
</TabsContent>
<TabsContent value="networking" className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field>
<FieldLabel>Mesh namespace</FieldLabel>
<Input value={application.meshNamespace} readOnly />
</Field>
<Field>
<FieldLabel>Mesh FQDN</FieldLabel>
<Input value={application.meshFqdn} readOnly />
</Field>
<Field>
<FieldLabel>Internal port</FieldLabel>
<Input value={application.internalPort?.toString() ?? 'Not configured'} readOnly />
</Field>
<Field>
<FieldLabel>Public ingress</FieldLabel>
<Input value={application.ingressEnabled ? 'Enabled' : 'Private'} readOnly />
</Field>
</div>
<Field>
<FieldLabel>Domains</FieldLabel>
<Textarea
value={
application.domains.length > 0
? application.domains.join('\n')
: 'No public domains configured.'
}
readOnly
/>
</Field>
<div className="flex flex-wrap items-center gap-2">
<ApplicationIngressButton application={application} isSaving={isIngressSaving} onToggle={onToggleIngress} />
<span className="text-xs text-muted-foreground">
Use this action to publish or private-route the app through the server ingress.
</span>
</div>
</TabsContent>
<TabsContent value="logs">
<ApplicationLogsTab key={application.id} application={application} />
</TabsContent>
<TabsContent value="advanced" className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field>
<FieldLabel>Application ID</FieldLabel>
<Input value={application.id} readOnly />
</Field>
<Field>
<FieldLabel>Canvas position</FieldLabel>
<Input value={`${application.canvasX}, ${application.canvasY}`} readOnly />
</Field>
</div>
<Field>
<FieldLabel>Raw app config</FieldLabel>
<Textarea
value={JSON.stringify(application, null, 2)}
readOnly
className="min-h-80 font-mono text-xs"
/>
</Field>
</TabsContent>
</Tabs>
</div>
</>
)}
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,49 @@
import { memo, type PointerEvent } from 'react';
import { statusBadgeClass } from '@/components/canvas/status-badge';
import { cn } from '@/lib/utils';
import type { V5CaddyIngress } from '@/types';
type CaddyIngressCardProps = {
ingress: V5CaddyIngress;
onDragStart: (event: PointerEvent<HTMLDivElement>, ingress: V5CaddyIngress) => void;
};
export const CaddyIngressCard = memo(function CaddyIngressCard({ ingress, onDragStart }: CaddyIngressCardProps) {
return (
<div
className="absolute w-80 select-none overflow-hidden rounded-xl border border-warning/40 bg-card p-4 shadow-xl transition-shadow hover:shadow-2xl"
style={{
transform: `translate3d(${ingress.canvasX}px, ${ingress.canvasY}px, 0)`,
}}
onPointerDown={(event) => onDragStart(event, ingress)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-foreground">Caddy ingress</div>
<div className="mt-1 truncate text-xs text-muted-foreground">{ingress.name}</div>
</div>
<span
className={cn(
'shrink-0 rounded-full px-2 py-1 text-[0.625rem] font-semibold uppercase tracking-wide',
statusBadgeClass(ingress.status),
)}
title={ingress.statusMessage ?? undefined}
>
{ingress.status}
</span>
</div>
<dl className="mt-4 grid gap-2 text-xs">
<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-3">
<dt className="shrink-0 text-muted-foreground">Server</dt>
<dd className="truncate text-right font-medium text-foreground">{ingress.name}</dd>
</div>
<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-3">
<dt className="shrink-0 text-muted-foreground">Host</dt>
<dd className="truncate text-right font-mono text-[0.6875rem] text-foreground">{ingress.host}</dd>
</div>
</dl>
</div>
);
});
@@ -0,0 +1,38 @@
type CanvasNoticeProps = {
message: string;
description?: string;
onDismiss: () => void;
variant?: 'danger' | 'success' | 'info';
};
const noticeClasses = {
danger: 'border-destructive/40 text-destructive',
success: 'border-emerald-500/40 text-emerald-400',
info: 'border-blue-500/40 text-blue-400',
};
export function CanvasNotice({ message, description, onDismiss, variant = 'danger' }: CanvasNoticeProps) {
return (
<div
className={`fixed right-4 top-20 z-50 flex max-w-sm items-start gap-3 rounded-lg border bg-card p-3 text-sm shadow-lg ${noticeClasses[variant]}`}
>
<div className="min-w-0">
<p className="font-medium">{message}</p>
{description ? (
<p className="mt-1 max-h-32 overflow-auto whitespace-pre-wrap break-words text-xs text-muted-foreground">
{description}
</p>
) : null}
</div>
<button
type="button"
aria-label="Dismiss notice"
onClick={onDismiss}
className="-m-1 rounded p-1 opacity-80 transition hover:bg-background/20 hover:opacity-100"
>
<span aria-hidden="true">×</span>
<span className="sr-only">Dismiss notice</span>
</button>
</div>
);
}
@@ -0,0 +1,139 @@
import { memo } from 'react';
import { MAX_CANVAS_ZOOM, MIN_CANVAS_ZOOM } from '@/lib/use-canvas-viewport';
import type { V5NginxServer } from '@/types';
export type CanvasStatusCounts = {
running: number;
failed: number;
unknown: number;
};
type CanvasToolbarProps = {
nginxServers: V5NginxServer[];
selectedNginxServerId: string;
onSelectNginxServer: (serverId: string) => void;
nginxImage: string;
onNginxImageChange: (image: string) => void;
isCreating: boolean;
onDeploy: () => void;
onCenter: () => void;
zoom: number;
onZoomIn: () => void;
onZoomOut: () => void;
isRefreshing: boolean;
onRefresh: () => void;
applicationsCount: number;
statusCounts: CanvasStatusCounts;
};
export const CanvasToolbar = memo(function CanvasToolbar({
nginxServers,
selectedNginxServerId,
onSelectNginxServer,
nginxImage,
onNginxImageChange,
isCreating,
onDeploy,
onCenter,
zoom,
onZoomIn,
onZoomOut,
isRefreshing,
onRefresh,
applicationsCount,
statusCounts,
}: CanvasToolbarProps) {
return (
<div className="absolute left-4 top-20 z-30 flex max-w-[calc(100%-2rem)] flex-wrap items-center gap-2 rounded-xl border border-border bg-background/95 p-2 shadow-lg backdrop-blur">
<select
aria-label="Select nginx server"
value={selectedNginxServerId}
onChange={(event) => onSelectNginxServer(event.target.value)}
disabled={isCreating || nginxServers.length === 0}
className="rounded-lg border border-border bg-background px-3 py-2 text-sm font-medium text-foreground transition disabled:cursor-not-allowed disabled:opacity-60"
>
{nginxServers.length === 0 ? (
<option value="">No servers available</option>
) : (
nginxServers.map((server) => (
<option key={server.id} value={server.id}>
{server.name} ({server.host})
</option>
))
)}
</select>
<input
type="text"
aria-label="Nginx image"
value={nginxImage}
onChange={(event) => onNginxImageChange(event.target.value)}
disabled={isCreating}
className="w-72 rounded-lg border border-border bg-background px-3 py-2 text-sm font-medium text-foreground transition disabled:cursor-not-allowed disabled:opacity-60"
/>
<button
type="button"
onClick={onDeploy}
disabled={isCreating || nginxServers.length === 0}
className="rounded-lg bg-warning px-3 py-2 text-sm font-semibold text-black transition hover:bg-warning/90 disabled:cursor-not-allowed disabled:opacity-60"
>
{isCreating ? 'Deploying…' : 'Deploy'}
</button>
<button
type="button"
onClick={onCenter}
className="rounded-lg border border-border px-3 py-2 text-sm font-medium text-foreground transition hover:bg-muted"
>
Center
</button>
<div className="flex items-center overflow-hidden rounded-lg border border-border">
<button
type="button"
aria-label="Zoom out"
onClick={onZoomOut}
className="px-3 py-2 text-sm font-semibold text-foreground transition hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
disabled={zoom <= MIN_CANVAS_ZOOM}
>
−
</button>
<span className="min-w-14 border-x border-border px-2 py-2 text-center text-xs font-medium text-muted-foreground">
{Math.round(zoom * 100)}%
</span>
<button
type="button"
aria-label="Zoom in"
onClick={onZoomIn}
className="px-3 py-2 text-sm font-semibold text-foreground transition hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
disabled={zoom >= MAX_CANVAS_ZOOM}
>
+
</button>
</div>
<button
type="button"
onClick={onRefresh}
disabled={isRefreshing}
className="rounded-lg border border-border px-3 py-2 text-sm font-medium text-foreground transition hover:bg-muted disabled:cursor-not-allowed disabled:opacity-60"
>
{isRefreshing ? 'Refreshing…' : 'Refresh state'}
</button>
<div className="hidden items-center gap-2 px-2 text-xs text-muted-foreground sm:flex">
<span>{applicationsCount} apps</span>
<span>•</span>
<span>{statusCounts.running} running</span>
{statusCounts.failed > 0 && (
<>
<span>•</span>
<span className="text-destructive">{statusCounts.failed} failed</span>
</>
)}
{statusCounts.unknown > 0 && (
<>
<span>•</span>
<span>{statusCounts.unknown} unknown</span>
</>
)}
</div>
</div>
);
});
@@ -0,0 +1,111 @@
import { memo, useMemo, type MouseEvent } from 'react';
import { connectorPoint, shortestConnectionPoints, type DraftConnection } from '@/lib/canvas-geometry';
import { cn } from '@/lib/utils';
import type { CanvasConnection } from '@/lib/use-canvas-connections';
import type { V5Application } from '@/types';
type ConnectionLineProps = {
connection: CanvasConnection;
fromApplication: V5Application;
toApplication: V5Application;
isSelected: boolean;
onSelect: (event: MouseEvent<SVGLineElement>, connectionId: string) => void;
};
const ConnectionLine = memo(function ConnectionLine({ connection, fromApplication, toApplication, isSelected, onSelect }: ConnectionLineProps) {
const points = useMemo(() => shortestConnectionPoints(fromApplication, toApplication), [fromApplication, toApplication]);
return (
<g>
<line
x1={points.from.x}
y1={points.from.y}
x2={points.to.x}
y2={points.to.y}
aria-label="Select connection"
className="pointer-events-auto cursor-pointer"
stroke="transparent"
strokeWidth={12}
strokeLinecap="round"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => onSelect(event, connection.id)}
/>
<line
x1={points.from.x}
y1={points.from.y}
x2={points.to.x}
y2={points.to.y}
className={cn('pointer-events-none', isSelected ? 'stroke-destructive' : 'stroke-warning')}
strokeWidth={isSelected ? 4 : 2}
strokeDasharray="6 6"
strokeLinecap="round"
markerEnd={isSelected ? 'url(#dashboard-connection-arrow)' : undefined}
/>
</g>
);
});
type ConnectionLinesProps = {
connections: CanvasConnection[];
applications: V5Application[];
selectedConnectionId: string | null;
draftConnection: DraftConnection | null;
onSelectConnection: (event: MouseEvent<SVGLineElement>, connectionId: string) => void;
};
export function ConnectionLines({ connections, applications, selectedConnectionId, draftConnection, onSelectConnection }: ConnectionLinesProps) {
const applicationsById = useMemo(() => new Map(applications.map((application) => [application.id, application])), [applications]);
const draftFromApplication = draftConnection ? applicationsById.get(draftConnection.from.applicationId) : undefined;
const draftFrom = draftConnection && draftFromApplication ? connectorPoint(draftFromApplication, draftConnection.from.side) : null;
return (
<svg className="pointer-events-none absolute inset-0 overflow-visible">
<defs>
<marker
id="dashboard-connection-arrow"
viewBox="0 0 10 10"
refX="9"
refY="5"
markerWidth="16"
markerHeight="16"
orient="auto"
markerUnits="userSpaceOnUse"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill="var(--destructive)" />
</marker>
</defs>
{connections.map((connection) => {
const fromApplication = applicationsById.get(connection.fromApplicationId);
const toApplication = applicationsById.get(connection.toApplicationId);
if (!fromApplication || !toApplication) {
return null;
}
return (
<ConnectionLine
key={connection.id}
connection={connection}
fromApplication={fromApplication}
toApplication={toApplication}
isSelected={selectedConnectionId === connection.id}
onSelect={onSelectConnection}
/>
);
})}
{draftConnection && draftFrom && (
<line
x1={draftFrom.x}
y1={draftFrom.y}
x2={draftConnection.toX}
y2={draftConnection.toY}
className="stroke-warning/70"
strokeWidth={2}
strokeDasharray="6 6"
strokeLinecap="round"
/>
)}
</svg>
);
}
@@ -0,0 +1,146 @@
import { useMemo } from 'react';
import { shortestConnectionPoints } from '@/lib/canvas-geometry';
import { activeConnectionPorts, type CanvasConnection } from '@/lib/use-canvas-connections';
import { cn } from '@/lib/utils';
import type { V5Application } from '@/types';
type ConnectionPortsEditorProps = {
connection: CanvasConnection;
applications: V5Application[];
portInput: string;
onPortInputChange: (connectionId: string, value: string) => void;
onUpdateDirection: (connectionId: string, fromApplicationId: string, toApplicationId: string) => void;
onAddPort: (connectionId: string) => void;
onRemovePort: (connectionId: string, port: string) => void;
onDelete: (connectionId: string) => void;
};
export function ConnectionPortsEditor({
connection,
applications,
portInput,
onPortInputChange,
onUpdateDirection,
onAddPort,
onRemovePort,
onDelete,
}: ConnectionPortsEditorProps) {
const fromApplication = applications.find((candidate) => candidate.id === connection.fromApplicationId);
const toApplication = applications.find((candidate) => candidate.id === connection.toApplicationId);
const points = useMemo(
() => (fromApplication && toApplication ? shortestConnectionPoints(fromApplication, toApplication) : null),
[fromApplication, toApplication],
);
if (!points) {
return null;
}
function applicationDirectionLabel(applicationId: string): string {
const application = applications.find((candidate) => candidate.id === applicationId);
if (!application) {
return 'Unknown app';
}
return `${application.name} (${application.id.slice(0, 8)})`;
}
const activePorts = activeConnectionPorts(connection);
const firstApplicationId = connection.applicationIds[0];
const secondApplicationId = connection.applicationIds[1];
const isForwardDirection =
connection.fromApplicationId === firstApplicationId && connection.toApplicationId === secondApplicationId;
return (
<div
className="absolute z-20 flex w-72 -translate-x-1/2 -translate-y-1/2 flex-col gap-3 rounded-md border border-border bg-card p-3 shadow-lg"
style={{
left: (points.from.x + points.to.x) / 2,
top: (points.from.y + points.to.y) / 2,
}}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<div className="space-y-2">
<div className="text-[0.625rem] font-semibold uppercase tracking-wide text-muted-foreground">Firewall</div>
<div className="grid gap-1">
<button
type="button"
onClick={() => onUpdateDirection(connection.id, firstApplicationId, secondApplicationId)}
className={cn(
'rounded-sm border px-2 py-1 text-left text-xs transition',
isForwardDirection
? 'border-warning/40 bg-warning/10 text-foreground hover:bg-warning/20'
: 'border-border text-muted-foreground hover:bg-muted hover:text-foreground',
)}
>
{applicationDirectionLabel(firstApplicationId)} → {applicationDirectionLabel(secondApplicationId)}
</button>
<button
type="button"
onClick={() => onUpdateDirection(connection.id, secondApplicationId, firstApplicationId)}
className={cn(
'rounded-sm border px-2 py-1 text-left text-xs transition',
!isForwardDirection
? 'border-warning/40 bg-warning/10 text-foreground hover:bg-warning/20'
: 'border-border text-muted-foreground hover:bg-muted hover:text-foreground',
)}
>
{applicationDirectionLabel(secondApplicationId)} → {applicationDirectionLabel(firstApplicationId)}
</button>
</div>
</div>
<div className="space-y-2">
<div className="text-[0.625rem] font-semibold uppercase tracking-wide text-muted-foreground">Allowed ports</div>
<div className="flex flex-wrap gap-1">
{activePorts.length === 0 && <span className="text-xs text-muted-foreground">No ports yet.</span>}
{activePorts.map((port) => (
<button
key={port}
type="button"
onClick={() => onRemovePort(connection.id, port)}
className="rounded-full border border-border bg-muted px-2 py-1 text-[0.625rem] font-medium text-foreground transition hover:border-destructive/40 hover:text-destructive"
>
{port} ×
</button>
))}
</div>
<div className="flex gap-1">
<input
type="number"
min={1}
max={65535}
placeholder="Port"
value={portInput}
onChange={(event) => onPortInputChange(connection.id, event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
onAddPort(connection.id);
}
}}
className="min-w-0 flex-1 rounded-sm border border-border bg-background px-2 py-1 text-xs text-foreground outline-none transition focus:border-warning"
/>
<button
type="button"
onClick={() => onAddPort(connection.id)}
className="rounded-sm border border-border px-2 py-1 text-xs font-medium text-foreground transition hover:bg-muted"
>
Add
</button>
</div>
</div>
<button
type="button"
aria-label="Delete connection"
onClick={() => onDelete(connection.id)}
className="rounded-sm border border-destructive/40 px-2 py-1 text-[0.625rem] font-semibold uppercase tracking-wide text-destructive transition hover:bg-destructive/10"
>
Delete
</button>
</div>
);
}
@@ -0,0 +1,82 @@
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Field, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import type { IngressModalState } from '@/lib/use-application-ingress';
type IngressDialogProps = {
modal: IngressModalState;
isSaving: boolean;
onDomainsChange: (domains: string) => void;
onInternalPortChange: (internalPort: string) => void;
onSubmit: () => void;
onClose: () => void;
};
export function IngressDialog({ modal, isSaving, onDomainsChange, onInternalPortChange, onSubmit, onClose }: IngressDialogProps) {
return (
<Dialog
open
onOpenChange={(open) => {
if (!open && !isSaving) {
onClose();
}
}}
>
<DialogContent className="max-w-lg" showCloseButton>
<DialogHeader>
<DialogTitle>Enable app ingress</DialogTitle>
<DialogDescription>
Route public domains to {modal.application.name} through the server ingress.
</DialogDescription>
</DialogHeader>
<form
className="mt-6 flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
onSubmit();
}}
>
<Field>
<FieldLabel>Domains</FieldLabel>
<Input
type="text"
value={modal.domains}
onChange={(event) => onDomainsChange(event.target.value)}
placeholder="example.com, www.example.com"
/>
<span className="text-xs text-muted-foreground">
Use hostnames only, separated by commas. No scheme, path, wildcard, or port.
</span>
</Field>
<Field>
<FieldLabel>Internal port</FieldLabel>
<Input
type="number"
min="1"
max="65535"
value={modal.internalPort}
onChange={(event) => onInternalPortChange(event.target.value)}
placeholder="3000"
/>
</Field>
{modal.error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<p className="font-medium">Ingress update failed</p>
<p className="mt-1 text-destructive/90">{modal.error}</p>
</div>
)}
<div className="flex justify-end">
<Button type="submit" variant="coolify" disabled={isSaving}>
{isSaving ? 'Saving...' : 'Enable ingress'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,19 @@
export function statusBadgeClass(status: string): string {
if (status === 'running') {
return 'bg-emerald-500/15 text-emerald-400';
}
if (['creating', 'starting'].includes(status)) {
return 'bg-warning/15 text-warning';
}
if (status === 'unknown') {
return 'bg-muted text-muted-foreground';
}
if (['failed', 'exited', 'unreachable'].includes(status)) {
return 'bg-destructive/15 text-destructive';
}
return 'bg-muted text-muted-foreground';
}
+49
View File
@@ -0,0 +1,49 @@
import { Button as ButtonPrimitive } from '@base-ui/react/button';
import { cva, type VariantProps } from 'class-variance-authority';
import type * as React from 'react';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-none border border-transparent bg-clip-padding text-xs font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/80',
coolify:
'border-coollabs bg-coollabs-50 text-coollabs-200 hover:bg-coollabs hover:text-white focus-visible:border-coollabs-100 focus-visible:bg-coollabs focus-visible:text-white dark:border-coollabs-100 dark:bg-coollabs/20 dark:text-white dark:hover:bg-coollabs-100 dark:hover:text-white dark:focus-visible:border-coollabs-100 dark:focus-visible:bg-coollabs-100',
outline:
'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost: 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
destructive:
'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-ring dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:border-ring',
delete: 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-ring dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:border-ring',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
xs: "h-6 gap-1 rounded-none px-2 text-xs has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-none px-2.5 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
icon: 'size-8',
'icon-xs': "size-6 rounded-none [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-7 rounded-none',
'icon-lg': 'size-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
type ButtonProps = React.ComponentProps<typeof ButtonPrimitive> & VariantProps<typeof buttonVariants>;
function Button({ className, variant = 'default', size = 'default', ...props }: ButtonProps) {
return <ButtonPrimitive data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />;
}
export { Button, buttonVariants };
+93
View File
@@ -0,0 +1,93 @@
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
import { XIcon } from '@phosphor-icons/react';
import type * as React from 'react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Backdrop>) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn('fixed inset-0 bg-background/80 backdrop-blur-sm', className)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Popup> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
'fixed left-1/2 top-1/2 z-50 max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-lg border border-border bg-card p-6 shadow-lg outline-none',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
aria-label="Close dialog"
render={<Button type="button" variant="ghost" size="icon-sm" className="absolute top-3 right-3" />}
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="dialog-header" className={cn('flex flex-col gap-1.5', className)} {...props} />;
}
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="dialog-footer" className={cn('mt-6 flex justify-end gap-2', className)} {...props} />;
}
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn('text-lg font-semibold text-foreground', className)}
{...props}
/>
);
}
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
);
}
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle };
@@ -0,0 +1,266 @@
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
import { CaretRightIcon, CheckIcon } from "@phosphor-icons/react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-none bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-2 text-xs text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-2 rounded-none px-2 py-2 text-xs outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-none px-2 py-2 text-xs outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<CaretRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-[96px] rounded-none bg-popover text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-none py-2 pr-8 pl-2 text-xs outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-none py-2 pr-8 pl-2 text-xs outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+35
View File
@@ -0,0 +1,35 @@
import type * as React from 'react';
import { cn } from '@/lib/utils';
type FieldProps = React.ComponentProps<'label'>;
function Field({ className, ...props }: FieldProps) {
return <label data-slot="field" className={cn('flex flex-col gap-1 text-sm', className)} {...props} />;
}
type FieldLabelProps = React.ComponentProps<'span'>;
function FieldLabel({ className, ...props }: FieldLabelProps) {
return <span data-slot="field-label" className={cn('font-medium text-foreground', className)} {...props} />;
}
type FieldErrorProps = React.ComponentProps<'span'> & {
message?: string;
};
function FieldError({ className, message, ...props }: FieldErrorProps) {
return (
<span
aria-hidden={message ? undefined : true}
aria-live="polite"
data-slot="field-error"
className={cn('min-h-4 text-xs leading-4 text-destructive', className)}
{...props}
>
{message ?? ''}
</span>
);
}
export { Field, FieldError, FieldLabel };
+18
View File
@@ -0,0 +1,18 @@
import type * as React from 'react';
import { cn } from '@/lib/utils';
function Input({ className, ...props }: React.ComponentProps<'input'>) {
return (
<input
data-slot="input"
className={cn(
'rounded-md border border-border bg-background px-3 py-2 text-sm outline-none transition focus:border-ring focus:ring-0 aria-invalid:border-destructive aria-invalid:ring-0 dark:aria-invalid:border-destructive/50',
className,
)}
{...props}
/>
);
}
export { Input };
+178
View File
@@ -0,0 +1,178 @@
import { Select as SelectPrimitive } from '@base-ui/react/select';
import { CaretDownIcon, CaretUpIcon, CheckIcon } from '@phosphor-icons/react';
import type * as React from 'react';
import { cn } from '@/lib/utils';
const Select = SelectPrimitive.Root;
type SelectGroupProps = React.ComponentProps<typeof SelectPrimitive.Group>;
function SelectGroup({ className, ...props }: SelectGroupProps) {
return <SelectPrimitive.Group data-slot="select-group" className={cn('scroll-my-1', className)} {...props} />;
}
type SelectValueProps = React.ComponentProps<typeof SelectPrimitive.Value>;
function SelectValue({ className, ...props }: SelectValueProps) {
return <SelectPrimitive.Value data-slot="select-value" className={cn('flex flex-1 text-left', className)} {...props} />;
}
type SelectTriggerProps = React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: 'default' | 'sm';
variant?: 'default' | 'ghost';
};
function SelectTrigger({ className, size = 'default', variant = 'default', children, ...props }: SelectTriggerProps) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
data-variant={variant}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-none whitespace-nowrap transition-colors outline-none select-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-none *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:aria-invalid:border-destructive/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variant === 'default' &&
'border border-input bg-transparent py-2 pr-2 pl-2.5 text-xs focus-visible:border-ring dark:bg-input/30 dark:hover:bg-input/50',
variant === 'ghost' &&
'h-auto border border-transparent bg-transparent px-0 py-0 text-sm font-medium text-foreground hover:bg-transparent focus-visible:border-ring dark:bg-transparent dark:hover:bg-transparent',
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon render={<CaretDownIcon className="pointer-events-none size-4 text-muted-foreground" />} />
</SelectPrimitive.Trigger>
);
}
type SelectPositionerProps = React.ComponentProps<typeof SelectPrimitive.Positioner>;
type SelectPopupProps = React.ComponentProps<typeof SelectPrimitive.Popup>;
type SelectContentProps = SelectPopupProps & {
position?: 'popper' | 'item-aligned';
side?: SelectPositionerProps['side'];
sideOffset?: SelectPositionerProps['sideOffset'];
align?: SelectPositionerProps['align'];
alignOffset?: SelectPositionerProps['alignOffset'];
alignItemWithTrigger?: SelectPositionerProps['alignItemWithTrigger'];
};
function SelectContent({
className,
children,
position,
side = 'bottom',
sideOffset = 4,
align = 'center',
alignOffset = 0,
alignItemWithTrigger = position === 'popper' ? false : true,
...props
}: SelectContentProps) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn(
'relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-none bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className,
)}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
);
}
type SelectLabelProps = React.ComponentProps<typeof SelectPrimitive.GroupLabel>;
function SelectLabel({ className, ...props }: SelectLabelProps) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn('px-2 py-2 text-xs text-muted-foreground', className)}
{...props}
/>
);
}
type SelectItemProps = React.ComponentProps<typeof SelectPrimitive.Item>;
function SelectItem({ className, children, ...props }: SelectItemProps) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-none py-2 pr-8 pl-2 text-xs outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">{children}</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
);
}
type SelectSeparatorProps = React.ComponentProps<typeof SelectPrimitive.Separator>;
function SelectSeparator({ className, ...props }: SelectSeparatorProps) {
return <SelectPrimitive.Separator data-slot="select-separator" className={cn('pointer-events-none -mx-1 h-px bg-border', className)} {...props} />;
}
type SelectScrollUpButtonProps = React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>;
function SelectScrollUpButton({ className, ...props }: SelectScrollUpButtonProps) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn('top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*=\'size-\'])]:size-4', className)}
{...props}
>
<CaretUpIcon />
</SelectPrimitive.ScrollUpArrow>
);
}
type SelectScrollDownButtonProps = React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>;
function SelectScrollDownButton({ className, ...props }: SelectScrollDownButtonProps) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn('bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*=\'size-\'])]:size-4', className)}
{...props}
>
<CaretDownIcon />
</SelectPrimitive.ScrollDownArrow>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
@@ -0,0 +1,24 @@
'use client';
import { Separator as SeparatorPrimitive } from '@base-ui/react/separator';
import type * as React from 'react';
import { cn } from '@/lib/utils';
type SeparatorProps = React.ComponentProps<typeof SeparatorPrimitive>;
function Separator({ className, orientation = 'horizontal', ...props }: SeparatorProps) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
'shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch',
className,
)}
{...props}
/>
);
}
export { Separator };
+96
View File
@@ -0,0 +1,96 @@
import { Dialog as SheetPrimitive } from '@base-ui/react/dialog';
import { XIcon } from '@phosphor-icons/react';
import type * as React from 'react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
className,
blur = true,
...props
}: SheetPrimitive.Backdrop.Props & {
blur?: boolean;
}) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
'fixed inset-0 z-50 bg-black/10 text-xs/relaxed transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0',
blur && 'supports-backdrop-filter:backdrop-blur-xs',
className,
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = 'right',
showCloseButton = true,
blurOverlay = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: 'top' | 'right' | 'bottom' | 'left';
showCloseButton?: boolean;
blurOverlay?: boolean;
}) {
return (
<SheetPortal>
<SheetOverlay blur={blurOverlay} />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
'fixed z-50 flex flex-col bg-popover bg-clip-padding text-xs/relaxed text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close data-slot="sheet-close" render={<Button variant="ghost" className="absolute top-3 right-3" size="icon-sm" />}>
<XIcon />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="sheet-header" className={cn('flex flex-col gap-0.5 p-4', className)} {...props} />;
}
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="sheet-footer" className={cn('mt-auto flex flex-col gap-2 p-4', className)} {...props} />;
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return <SheetPrimitive.Title data-slot="sheet-title" className={cn('font-heading text-sm font-medium text-foreground', className)} {...props} />;
}
function SheetDescription({ className, ...props }: SheetPrimitive.Description.Props) {
return <SheetPrimitive.Description data-slot="sheet-description" className={cn('text-xs/relaxed text-muted-foreground', className)} {...props} />;
}
export { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger };
+63
View File
@@ -0,0 +1,63 @@
import { Tabs as TabsPrimitive } from '@base-ui/react/tabs';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
function Tabs({ className, orientation = 'horizontal', ...props }: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn('group/tabs flex gap-2 data-horizontal:flex-col', className)}
{...props}
/>
);
}
const tabsListVariants = cva(
'group/tabs-list inline-flex w-fit items-center justify-center rounded-none p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none',
{
variants: {
variant: {
default: 'bg-muted',
line: 'gap-1 bg-transparent',
},
},
defaultVariants: {
variant: 'default',
},
},
);
function TabsList({ className, variant = 'default', ...props }: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
);
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-none border border-transparent px-1.5 py-0.5 text-xs font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start group-data-vertical/tabs:py-[calc(--spacing(1.25))] hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
'group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent',
'data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground',
'after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100',
className,
)}
{...props}
/>
);
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return <TabsPrimitive.Panel data-slot="tabs-content" className={cn('flex-1 text-xs/relaxed outline-none', className)} {...props} />;
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
@@ -0,0 +1,18 @@
import type * as React from 'react';
import { cn } from '@/lib/utils';
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot="textarea"
className={cn(
'min-h-24 rounded-md border border-border bg-background px-3 py-2 text-sm outline-none transition focus:border-ring focus:ring-0 aria-invalid:border-destructive aria-invalid:ring-0 dark:aria-invalid:border-destructive/50',
className,
)}
{...props}
/>
);
}
export { Textarea };
+64
View File
@@ -0,0 +1,64 @@
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-none border border-border bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-md has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-none data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-none bg-popover fill-popover data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+7
View File
@@ -0,0 +1,7 @@
/**
* General-purpose JSON request helper for v5 pages: same-origin credentials,
* CSRF header, and a 30 second timeout on every request. The implementation
* currently lives in canvas-api.ts; import from this module in non-canvas
* code so the canvas-specific name can be retired later.
*/
export { canvasRequest as apiRequest } from '@/lib/canvas-api';
+24
View File
@@ -0,0 +1,24 @@
import { csrfToken } from '@/lib/csrf';
type CanvasRequestOptions = {
method: 'GET' | 'POST' | 'PATCH' | 'DELETE';
body?: unknown;
};
/**
* Shared JSON fetch for the canvas endpoints: same-origin credentials,
* CSRF header, and a 30 second timeout on every request.
*/
export function canvasRequest(url: string, { method, body }: CanvasRequestOptions): Promise<Response> {
return fetch(url, {
method,
credentials: 'same-origin',
headers: {
Accept: 'application/json',
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
'X-CSRF-TOKEN': csrfToken(),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(30_000),
});
}
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import { resolveCanvasNodeLayout, resolveCanvasNodePosition, type CanvasNodeBounds } from '@/lib/canvas-collision';
const GAP = 16;
function node(id: string, x: number, y: number, width = 320, height = 160): CanvasNodeBounds {
return { id, x, y, width, height };
}
function nodesOverlap(first: CanvasNodeBounds, second: CanvasNodeBounds, gap: number): boolean {
return (
first.x < second.x + second.width + gap &&
first.x + first.width + gap > second.x &&
first.y < second.y + second.height + gap &&
first.y + first.height + gap > second.y
);
}
describe('resolveCanvasNodePosition', () => {
it('keeps the position when there is no collision', () => {
const position = resolveCanvasNodePosition(node('a', 0, 0), [node('b', 1000, 1000)], GAP);
expect(position).toEqual({ x: 0, y: 0 });
});
it('ignores the node itself when checking collisions', () => {
const subject = node('a', 0, 0);
const position = resolveCanvasNodePosition(subject, [subject], GAP);
expect(position).toEqual({ x: 0, y: 0 });
});
it('moves an overlapping node to a gap-respecting position', () => {
const settled = node('a', 0, 0);
const position = resolveCanvasNodePosition(node('b', 10, 10), [settled], GAP);
expect(nodesOverlap({ ...node('b', 10, 10), ...position }, settled, GAP)).toBe(false);
});
it('picks the candidate closest to the requested position', () => {
const settled = node('a', 0, 0);
// Dropped near the settled node's top-left corner: the slot above is nearest.
const position = resolveCanvasNodePosition(node('b', 40, 0), [settled], GAP);
expect(position).toEqual({ x: 40, y: -(160 + GAP) });
});
});
describe('resolveCanvasNodeLayout', () => {
it('settles all nodes without overlaps', () => {
const settled = resolveCanvasNodeLayout(
[node('a', 0, 0), node('b', 0, 0), node('c', 0, 0), node('d', 0, 0)],
GAP,
);
expect(settled).toHaveLength(4);
for (const first of settled) {
for (const second of settled) {
if (first.id !== second.id) {
expect(nodesOverlap(first, second, GAP)).toBe(false);
}
}
}
});
it('preserves node order and ids', () => {
const settled = resolveCanvasNodeLayout([node('a', 0, 0), node('b', 0, 0)], GAP);
expect(settled.map((settledNode) => settledNode.id)).toEqual(['a', 'b']);
});
});
+80
View File
@@ -0,0 +1,80 @@
export type CanvasNodeBounds = {
id: string;
x: number;
y: number;
width: number;
height: number;
};
export type CanvasNodePosition = {
x: number;
y: number;
};
export function resolveCanvasNodeLayout(nodes: CanvasNodeBounds[], gap: number): CanvasNodeBounds[] {
return nodes.reduce<CanvasNodeBounds[]>((settledNodes, node) => {
const position = resolveCanvasNodePosition(node, settledNodes, gap);
return [...settledNodes, { ...node, ...position }];
}, []);
}
export function resolveCanvasNodePosition(
node: CanvasNodeBounds,
nodes: CanvasNodeBounds[],
gap: number,
): CanvasNodePosition {
const otherNodes = nodes.filter((otherNode) => otherNode.id !== node.id);
let position = { x: node.x, y: node.y };
for (let attempt = 0; attempt < 50; attempt += 1) {
const collision = otherNodes.find((otherNode) => canvasNodesOverlap({ ...node, ...position }, otherNode, gap));
if (!collision) {
return position;
}
position = closestCanvasNodePosition(node, collision, gap, otherNodes, position);
}
return position;
}
function closestCanvasNodePosition(
node: CanvasNodeBounds,
collision: CanvasNodeBounds,
gap: number,
otherNodes: CanvasNodeBounds[],
targetPosition: CanvasNodePosition,
): CanvasNodePosition {
const candidates = [
{ x: targetPosition.x, y: collision.y - node.height - gap },
{ x: collision.x + collision.width + gap, y: targetPosition.y },
{ x: targetPosition.x, y: collision.y + collision.height + gap },
{ x: collision.x - node.width - gap, y: targetPosition.y },
].sort((firstCandidate, secondCandidate) => {
const firstDistance = canvasDistance(firstCandidate, targetPosition);
const secondDistance = canvasDistance(secondCandidate, targetPosition);
return firstDistance - secondDistance;
});
return (
candidates.find((candidate) =>
otherNodes.every((otherNode) => !canvasNodesOverlap({ ...node, ...candidate }, otherNode, gap)),
) ?? candidates[0]
);
}
function canvasNodesOverlap(node: CanvasNodeBounds, otherNode: CanvasNodeBounds, gap: number): boolean {
return (
node.x < otherNode.x + otherNode.width + gap &&
node.x + node.width + gap > otherNode.x &&
node.y < otherNode.y + otherNode.height + gap &&
node.y + node.height + gap > otherNode.y
);
}
function canvasDistance(firstPosition: CanvasNodePosition, secondPosition: CanvasNodePosition): number {
return Math.hypot(firstPosition.x - secondPosition.x, firstPosition.y - secondPosition.y);
}
@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest';
import {
APPLICATION_CARD_HEIGHT,
APPLICATION_CARD_WIDTH,
CANVAS_CARD_GAP,
connectorPoint,
resolveApplicationPosition,
settleCanvasResources,
shortestConnectionPoints,
} from '@/lib/canvas-geometry';
import type { V5Application, V5CaddyIngress } from '@/types';
function application(id: string, canvasX: number, canvasY: number): V5Application {
return { id, canvasX, canvasY } as V5Application;
}
function ingress(id: string, canvasX: number, canvasY: number): V5CaddyIngress {
return { id, canvasX, canvasY } as V5CaddyIngress;
}
describe('connectorPoint', () => {
const node = { canvasX: 100, canvasY: 200 };
it('returns the midpoint of each card edge', () => {
expect(connectorPoint(node, 'top')).toEqual({ x: 100 + APPLICATION_CARD_WIDTH / 2, y: 200 });
expect(connectorPoint(node, 'right')).toEqual({ x: 100 + APPLICATION_CARD_WIDTH, y: 200 + APPLICATION_CARD_HEIGHT / 2 });
expect(connectorPoint(node, 'bottom')).toEqual({ x: 100 + APPLICATION_CARD_WIDTH / 2, y: 200 + APPLICATION_CARD_HEIGHT });
expect(connectorPoint(node, 'left')).toEqual({ x: 100, y: 200 + APPLICATION_CARD_HEIGHT / 2 });
});
});
describe('shortestConnectionPoints', () => {
it('connects facing edges of horizontally separated cards', () => {
const left = { canvasX: 0, canvasY: 0 };
const right = { canvasX: 1000, canvasY: 0 };
const { from, to } = shortestConnectionPoints(left, right);
expect(from).toEqual(connectorPoint(left, 'right'));
expect(to).toEqual(connectorPoint(right, 'left'));
});
it('connects facing edges of vertically separated cards', () => {
const top = { canvasX: 0, canvasY: 0 };
const bottom = { canvasX: 0, canvasY: 1000 };
const { from, to } = shortestConnectionPoints(top, bottom);
expect(from).toEqual(connectorPoint(top, 'bottom'));
expect(to).toEqual(connectorPoint(bottom, 'top'));
});
});
describe('settleCanvasResources', () => {
it('keeps non-overlapping resources in place', () => {
const apps = [application('app-1', 0, 0), application('app-2', 1000, 0)];
const settled = settleCanvasResources(apps, []);
expect(settled.applications[0]).toMatchObject({ canvasX: 0, canvasY: 0 });
expect(settled.applications[1]).toMatchObject({ canvasX: 1000, canvasY: 0 });
});
it('separates overlapping applications and ingresses', () => {
const settled = settleCanvasResources([application('app-1', 0, 0)], [ingress('ingress-1', 0, 0)]);
const app = settled.applications[0];
const caddy = settled.ingresses[0];
const horizontalGap = Math.abs(app.canvasX - caddy.canvasX);
const verticalGap = Math.abs(app.canvasY - caddy.canvasY);
expect(
horizontalGap >= APPLICATION_CARD_WIDTH + CANVAS_CARD_GAP || verticalGap >= APPLICATION_CARD_HEIGHT + CANVAS_CARD_GAP,
).toBe(true);
});
});
describe('resolveApplicationPosition', () => {
it('moves a dragged application off an occupied slot', () => {
const occupied = application('app-1', 0, 0);
const dragged = application('app-2', 8, 8);
const resolved = resolveApplicationPosition(dragged, [occupied, dragged], []);
expect(resolved.canvasX !== 8 || resolved.canvasY !== 8).toBe(true);
});
it('keeps a dragged application on a free slot', () => {
const occupied = application('app-1', 0, 0);
const dragged = application('app-2', 1000, 1000);
const resolved = resolveApplicationPosition(dragged, [occupied, dragged], []);
expect(resolved).toMatchObject({ canvasX: 1000, canvasY: 1000 });
});
});
+132
View File
@@ -0,0 +1,132 @@
import { resolveCanvasNodeLayout, resolveCanvasNodePosition, type CanvasNodeBounds } from '@/lib/canvas-collision';
import type { V5Application, V5CaddyIngress } from '@/types';
export const APPLICATION_CARD_WIDTH = 320;
export const APPLICATION_CARD_HEIGHT = 160;
export const CANVAS_CARD_GAP = 16;
export type ConnectorSide = 'top' | 'right' | 'bottom' | 'left';
export const CONNECTOR_SIDES: ConnectorSide[] = ['top', 'right', 'bottom', 'left'];
export type CanvasPoint = {
x: number;
y: number;
};
export type CanvasCardNode = {
canvasX: number;
canvasY: number;
};
export type ConnectionEndpoint = {
applicationId: string;
side: ConnectorSide;
};
export type DraftConnection = {
from: ConnectionEndpoint;
toX: number;
toY: number;
};
export function connectorPoint(node: CanvasCardNode, side: ConnectorSide): CanvasPoint {
switch (side) {
case 'top':
return { x: node.canvasX + APPLICATION_CARD_WIDTH / 2, y: node.canvasY };
case 'right':
return { x: node.canvasX + APPLICATION_CARD_WIDTH, y: node.canvasY + APPLICATION_CARD_HEIGHT / 2 };
case 'bottom':
return { x: node.canvasX + APPLICATION_CARD_WIDTH / 2, y: node.canvasY + APPLICATION_CARD_HEIGHT };
case 'left':
return { x: node.canvasX, y: node.canvasY + APPLICATION_CARD_HEIGHT / 2 };
}
}
export function shortestConnectionPoints(fromNode: CanvasCardNode, toNode: CanvasCardNode): { from: CanvasPoint; to: CanvasPoint } {
let shortest = {
from: connectorPoint(fromNode, 'top'),
to: connectorPoint(toNode, 'top'),
distance: Number.POSITIVE_INFINITY,
};
for (const fromSide of CONNECTOR_SIDES) {
const from = connectorPoint(fromNode, fromSide);
for (const toSide of CONNECTOR_SIDES) {
const to = connectorPoint(toNode, toSide);
const distance = Math.hypot(from.x - to.x, from.y - to.y);
if (distance < shortest.distance) {
shortest = { from, to, distance };
}
}
}
return { from: shortest.from, to: shortest.to };
}
function applicationBounds(application: V5Application): CanvasNodeBounds {
return {
id: `application-${application.id}`,
x: application.canvasX,
y: application.canvasY,
width: APPLICATION_CARD_WIDTH,
height: APPLICATION_CARD_HEIGHT,
};
}
function ingressBounds(ingress: V5CaddyIngress): CanvasNodeBounds {
return {
id: `ingress-${ingress.id}`,
x: ingress.canvasX,
y: ingress.canvasY,
width: APPLICATION_CARD_WIDTH,
height: APPLICATION_CARD_HEIGHT,
};
}
export function canvasCollisionNodes(applications: V5Application[], ingresses: V5CaddyIngress[]): CanvasNodeBounds[] {
return [...applications.map(applicationBounds), ...ingresses.map(ingressBounds)];
}
export function settleCanvasResources(
nextApplications: V5Application[],
nextIngresses: V5CaddyIngress[],
): { applications: V5Application[]; ingresses: V5CaddyIngress[] } {
const settledNodes = resolveCanvasNodeLayout(canvasCollisionNodes(nextApplications, nextIngresses), CANVAS_CARD_GAP);
const positionsById = new Map(settledNodes.map((node) => [node.id, node]));
return {
applications: nextApplications.map((application) => {
const position = positionsById.get(`application-${application.id}`);
return position ? { ...application, canvasX: position.x, canvasY: position.y } : application;
}),
ingresses: nextIngresses.map((ingress) => {
const position = positionsById.get(`ingress-${ingress.id}`);
return position ? { ...ingress, canvasX: position.x, canvasY: position.y } : ingress;
}),
};
}
export function resolveApplicationPosition(
application: V5Application,
applications: V5Application[],
ingresses: V5CaddyIngress[],
): V5Application {
const position = resolveCanvasNodePosition(applicationBounds(application), canvasCollisionNodes(applications, ingresses), CANVAS_CARD_GAP);
return { ...application, canvasX: position.x, canvasY: position.y };
}
export function resolveIngressPosition(
ingress: V5CaddyIngress,
applications: V5Application[],
ingresses: V5CaddyIngress[],
): V5CaddyIngress {
const position = resolveCanvasNodePosition(ingressBounds(ingress), canvasCollisionNodes(applications, ingresses), CANVAS_CARD_GAP);
return { ...ingress, canvasX: position.x, canvasY: position.y };
}
+3
View File
@@ -0,0 +1,3 @@
export function csrfToken(): string {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ?? '';
}
+108
View File
@@ -0,0 +1,108 @@
import { describe, expect, it, vi } from 'vitest';
import { runOptimisticUpdate } from '@/lib/optimistic';
describe('runOptimisticUpdate', () => {
it('applies, persists, and reconciles on success', async () => {
const calls: string[] = [];
const notify = vi.fn();
const succeeded = await runOptimisticUpdate<string>({
apply: () => calls.push('apply'),
rollback: () => calls.push('rollback'),
request: async () => {
calls.push('request');
return { ok: true, payload: 'server-state' };
},
fallbackErrorMessage: 'fallback',
notify,
onSuccess: (payload) => calls.push(`success:${payload}`),
onSettled: () => calls.push('settled'),
});
expect(succeeded).toBe(true);
expect(calls).toEqual(['apply', 'request', 'success:server-state', 'settled']);
expect(notify).not.toHaveBeenCalled();
});
it('rolls back and notifies with the server error message on failure', async () => {
const rollback = vi.fn();
const notify = vi.fn();
const succeeded = await runOptimisticUpdate({
rollback,
request: async () => ({ ok: false, errorMessage: 'Ports must be integers.' }),
fallbackErrorMessage: 'fallback',
notify,
});
expect(succeeded).toBe(false);
expect(rollback).toHaveBeenCalledOnce();
expect(notify).toHaveBeenCalledWith('Ports must be integers.');
});
it('falls back to the generic message when the failure carries none', async () => {
const notify = vi.fn();
await runOptimisticUpdate({
request: async () => ({ ok: false }),
fallbackErrorMessage: 'Could not save.',
notify,
});
expect(notify).toHaveBeenCalledWith('Could not save.');
});
it('rolls back and notifies with the thrown error message', async () => {
const rollback = vi.fn();
const notify = vi.fn();
const onSettled = vi.fn();
const succeeded = await runOptimisticUpdate({
rollback,
request: async () => {
throw new Error('Network down.');
},
fallbackErrorMessage: 'fallback',
notify,
onSettled,
});
expect(succeeded).toBe(false);
expect(rollback).toHaveBeenCalledOnce();
expect(notify).toHaveBeenCalledWith('Network down.');
expect(onSettled).toHaveBeenCalledOnce();
});
it('uses the fallback message for non-Error throws', async () => {
const notify = vi.fn();
await runOptimisticUpdate({
request: async () => {
throw 'string failure';
},
fallbackErrorMessage: 'Could not save.',
notify,
});
expect(notify).toHaveBeenCalledWith('Could not save.');
});
it('runs onSettled even when onSuccess throws', async () => {
const onSettled = vi.fn();
await expect(
runOptimisticUpdate({
request: async () => ({ ok: true, payload: undefined }),
fallbackErrorMessage: 'fallback',
notify: vi.fn(),
onSuccess: () => {
throw new Error('reconcile failed');
},
onSettled,
}),
).resolves.toBe(false);
expect(onSettled).toHaveBeenCalledOnce();
});
});
+57
View File
@@ -0,0 +1,57 @@
export type OptimisticRequestResult<TPayload> =
| { ok: true; payload: TPayload }
| { ok: false; errorMessage?: string };
export type OptimisticUpdateOptions<TPayload> = {
/** Apply the optimistic state change before the request is sent. */
apply?: () => void;
/** Restore the previous state after a failed request. */
rollback?: () => void;
/** Perform the persistence request; return ok=false (or throw) on failure. */
request: () => Promise<OptimisticRequestResult<TPayload>>;
/** Notice used when the failure carries no specific error message. */
fallbackErrorMessage: string;
notify: (message: string | null) => void;
/** Reconcile local state with the server payload after success. */
onSuccess?: (payload: TPayload) => void;
/** Runs after success or failure, e.g. to clear pending markers. */
onSettled?: () => void;
};
/**
* Unified optimistic-update flow: apply the local change, persist it, and on
* any failure roll the local state back and surface a notice.
*/
export async function runOptimisticUpdate<TPayload = void>({
apply,
rollback,
request,
fallbackErrorMessage,
notify,
onSuccess,
onSettled,
}: OptimisticUpdateOptions<TPayload>): Promise<boolean> {
apply?.();
try {
const result = await request();
if (!result.ok) {
rollback?.();
notify(result.errorMessage ?? fallbackErrorMessage);
return false;
}
onSuccess?.(result.payload);
return true;
} catch (error) {
rollback?.();
notify(error instanceof Error ? error.message : fallbackErrorMessage);
return false;
} finally {
onSettled?.();
}
}
@@ -0,0 +1,201 @@
// @vitest-environment jsdom
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { isValidDomain, useApplicationIngress } from '@/lib/use-application-ingress';
import type { V5Application } from '@/types';
vi.mock('@/lib/canvas-api', () => ({
canvasRequest: vi.fn(),
}));
import { canvasRequest } from '@/lib/canvas-api';
const canvasRequestMock = vi.mocked(canvasRequest);
function application(overrides: Partial<V5Application> = {}): V5Application {
return {
id: 'app-1',
name: 'nginx-test',
serverIngressEnabled: true,
ingressEnabled: false,
internalPort: null,
domains: [],
...overrides,
} as V5Application;
}
function jsonResponse(payload: unknown, ok = true): Response {
return { ok, json: async () => payload } as Response;
}
function renderIngress(notify = vi.fn(), onApplicationUpdated = vi.fn()) {
const rendered = renderHook(() => useApplicationIngress({ notify, onApplicationUpdated }));
return { ...rendered, notify, onApplicationUpdated };
}
beforeEach(() => {
canvasRequestMock.mockReset();
});
describe('isValidDomain', () => {
it.each(['example.com', 'sub.example.com', 'a.io', 'my-app.example.co.uk'])('accepts %s', (domain) => {
expect(isValidDomain(domain)).toBe(true);
});
it.each(['', '.example.com', 'example.com.', '-bad.example.com', 'bad-.example.com', 'exa mple.com', 'UPPER.example.com'])(
'rejects %j',
(domain) => {
expect(isValidDomain(domain)).toBe(false);
},
);
});
describe('toggleApplicationIngress', () => {
it('refuses to open the modal when server ingress is disabled', () => {
const { result, notify } = renderIngress();
act(() => result.current.toggleApplicationIngress(application({ serverIngressEnabled: false })));
expect(notify).toHaveBeenCalledWith('Enable ingress on the server before enabling app ingress.');
expect(result.current.ingressModal).toBeNull();
expect(canvasRequestMock).not.toHaveBeenCalled();
});
it('opens the modal prefilled from the application', () => {
const { result } = renderIngress();
act(() =>
result.current.toggleApplicationIngress(application({ domains: ['a.example.com', 'b.example.com'], internalPort: 8080 })),
);
expect(result.current.ingressModal).toMatchObject({
domains: 'a.example.com, b.example.com',
internalPort: '8080',
error: null,
});
expect(canvasRequestMock).not.toHaveBeenCalled();
});
it('disables ingress immediately for an enabled application', async () => {
canvasRequestMock.mockResolvedValue(jsonResponse({ application: application() }));
const enabledApplication = application({ ingressEnabled: true, domains: ['app.example.com'], internalPort: 8080 });
const { result, onApplicationUpdated } = renderIngress();
await act(() => result.current.toggleApplicationIngress(enabledApplication));
expect(canvasRequestMock).toHaveBeenCalledWith('/v5/applications/app-1/ingress', {
method: 'PATCH',
body: { ingress_enabled: false, internal_port: 8080, domains: ['app.example.com'] },
});
expect(onApplicationUpdated).toHaveBeenCalledOnce();
});
});
describe('submitApplicationIngress', () => {
function openModal(result: { current: ReturnType<typeof useApplicationIngress> }, app = application()): void {
act(() => result.current.toggleApplicationIngress(app));
}
it('requires at least one domain', async () => {
const { result } = renderIngress();
openModal(result);
await act(() => result.current.submitApplicationIngress());
expect(result.current.ingressModal?.error).toBe('Add at least one valid domain.');
expect(canvasRequestMock).not.toHaveBeenCalled();
});
it('rejects invalid domains by name', async () => {
const { result } = renderIngress();
openModal(result);
act(() => result.current.setIngressModalDomains('good.example.com, bad_domain'));
act(() => result.current.setIngressModalInternalPort('8080'));
await act(() => result.current.submitApplicationIngress());
expect(result.current.ingressModal?.error).toBe('bad_domain is not a valid domain.');
expect(canvasRequestMock).not.toHaveBeenCalled();
});
it.each(['', '0', '65536', 'abc'])('rejects invalid internal port %j', async (port) => {
const { result } = renderIngress();
openModal(result);
act(() => result.current.setIngressModalDomains('app.example.com'));
act(() => result.current.setIngressModalInternalPort(port));
await act(() => result.current.submitApplicationIngress());
expect(result.current.ingressModal?.error).toBe('Choose a valid internal port between 1 and 65535.');
expect(canvasRequestMock).not.toHaveBeenCalled();
});
it('persists deduplicated lowercase domains and closes the modal', async () => {
const updatedApplication = application({ ingressEnabled: true });
canvasRequestMock.mockResolvedValue(jsonResponse({ application: updatedApplication }));
const { result, onApplicationUpdated } = renderIngress();
openModal(result);
act(() => result.current.setIngressModalDomains('App.Example.com, app.example.com, other.example.com,'));
act(() => result.current.setIngressModalInternalPort('8080'));
await act(() => result.current.submitApplicationIngress());
expect(canvasRequestMock).toHaveBeenCalledWith('/v5/applications/app-1/ingress', {
method: 'PATCH',
body: { ingress_enabled: true, internal_port: 8080, domains: ['app.example.com', 'other.example.com'] },
});
expect(onApplicationUpdated).toHaveBeenCalledWith(updatedApplication);
expect(result.current.ingressModal).toBeNull();
});
it('keeps the modal open and shows the server error on failure', async () => {
canvasRequestMock.mockResolvedValue(jsonResponse({ message: 'Domain already in use.' }, false));
const { result, onApplicationUpdated } = renderIngress();
openModal(result);
act(() => result.current.setIngressModalDomains('app.example.com'));
act(() => result.current.setIngressModalInternalPort('8080'));
await act(() => result.current.submitApplicationIngress());
expect(result.current.ingressModal?.error).toBe('Domain already in use.');
expect(onApplicationUpdated).not.toHaveBeenCalled();
});
it('reports network failures inside the modal', async () => {
canvasRequestMock.mockRejectedValue(new Error('Network down.'));
const { result } = renderIngress();
openModal(result);
act(() => result.current.setIngressModalDomains('app.example.com'));
act(() => result.current.setIngressModalInternalPort('8080'));
await act(() => result.current.submitApplicationIngress());
expect(result.current.ingressModal?.error).toBe('Network down.');
});
});
describe('modal editing', () => {
it('clears the error when inputs change', async () => {
const { result } = renderIngress();
act(() => result.current.toggleApplicationIngress(application()));
await act(() => result.current.submitApplicationIngress());
expect(result.current.ingressModal?.error).not.toBeNull();
act(() => result.current.setIngressModalDomains('app.example.com'));
expect(result.current.ingressModal?.error).toBeNull();
});
it('closes the modal on demand', () => {
const { result } = renderIngress();
act(() => result.current.toggleApplicationIngress(application()));
act(() => result.current.closeIngressModal());
expect(result.current.ingressModal).toBeNull();
});
});
@@ -0,0 +1,169 @@
import { useCallback, useRef, useState } from 'react';
import { canvasRequest } from '@/lib/canvas-api';
import { usePendingIds } from '@/lib/use-pending-ids';
import type { CanvasNotify } from '@/lib/use-canvas-connections';
import type { V5Application } from '@/types';
export type IngressModalState = {
application: V5Application;
domains: string;
internalPort: string;
error: string | null;
};
export function isValidDomain(domain: string): boolean {
if (domain.length < 1 || domain.length > 253 || domain.startsWith('.') || domain.endsWith('.')) {
return false;
}
return domain.split('.').every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label));
}
/**
* Owns the application ingress modal and the enable/disable persistence flow,
* tracking per-application saving state through usePendingIds.
*/
export function useApplicationIngress(options: {
notify: CanvasNotify;
onApplicationUpdated: (application: V5Application) => void;
}) {
const { notify, onApplicationUpdated } = options;
const [ingressModal, setIngressModal] = useState<IngressModalState | null>(null);
const savingIngressApplications = usePendingIds<string>();
const ingressModalRef = useRef<IngressModalState | null>(null);
ingressModalRef.current = ingressModal;
const saveApplicationIngress = useCallback(
async (application: V5Application, enabled: boolean, domains: string[], internalPort: number | null): Promise<void> => {
notify(null);
savingIngressApplications.start(application.id);
const reportError = (message: string): void => {
if (ingressModalRef.current) {
setIngressModal((currentModal) => (currentModal ? { ...currentModal, error: message } : currentModal));
} else {
notify(message);
}
};
try {
const response = await canvasRequest(`/v5/applications/${application.id}/ingress`, {
method: 'PATCH',
body: {
ingress_enabled: enabled,
internal_port: internalPort,
domains,
},
});
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as { message?: string } | null;
reportError(payload?.message ?? 'Could not update application ingress.');
return;
}
const payload = (await response.json()) as { application: V5Application };
onApplicationUpdated(payload.application);
setIngressModal(null);
} catch (error) {
reportError(error instanceof Error ? error.message : 'Could not update application ingress.');
} finally {
savingIngressApplications.finish(application.id);
}
},
[notify, onApplicationUpdated, savingIngressApplications.start, savingIngressApplications.finish],
);
const openApplicationIngressModal = useCallback(
(application: V5Application): void => {
notify(null);
if (!application.serverIngressEnabled) {
notify('Enable ingress on the server before enabling app ingress.');
return;
}
setIngressModal({
application,
domains: application.domains.join(', '),
internalPort: application.internalPort ? String(application.internalPort) : '',
error: null,
});
},
[notify],
);
const toggleApplicationIngress = useCallback(
(application: V5Application): void => {
if (application.ingressEnabled) {
void saveApplicationIngress(application, false, application.domains, application.internalPort);
} else {
openApplicationIngressModal(application);
}
},
[saveApplicationIngress, openApplicationIngressModal],
);
const submitApplicationIngress = useCallback(async (): Promise<void> => {
const currentModal = ingressModalRef.current;
if (!currentModal) {
return;
}
const domains = currentModal.domains
.split(',')
.map((domain) => domain.trim().toLowerCase())
.filter(Boolean);
const internalPort = Number(currentModal.internalPort);
const invalidDomain = domains.find((domain) => !isValidDomain(domain));
if (domains.length === 0) {
setIngressModal({ ...currentModal, error: 'Add at least one valid domain.' });
return;
}
if (invalidDomain) {
setIngressModal({ ...currentModal, error: `${invalidDomain} is not a valid domain.` });
return;
}
if (!Number.isInteger(internalPort) || internalPort < 1 || internalPort > 65535) {
setIngressModal({ ...currentModal, error: 'Choose a valid internal port between 1 and 65535.' });
return;
}
await saveApplicationIngress(currentModal.application, true, [...new Set(domains)], internalPort);
}, [saveApplicationIngress]);
const closeIngressModal = useCallback((): void => {
setIngressModal(null);
}, []);
const setIngressModalDomains = useCallback((domains: string): void => {
setIngressModal((currentModal) => (currentModal ? { ...currentModal, domains, error: null } : currentModal));
}, []);
const setIngressModalInternalPort = useCallback((internalPort: string): void => {
setIngressModal((currentModal) => (currentModal ? { ...currentModal, internalPort, error: null } : currentModal));
}, []);
return {
ingressModal,
closeIngressModal,
setIngressModalDomains,
setIngressModalInternalPort,
submitApplicationIngress,
toggleApplicationIngress,
savingIngressApplications,
};
}
+19
View File
@@ -0,0 +1,19 @@
import { useTeamChannel } from '@/lib/use-team-channel';
import type { V5Application, V5CaddyIngress } from '@/types';
export type V5CanvasResourceUpdatedEvent = {
application: V5Application | null;
applications?: V5Application[];
caddyIngress: V5CaddyIngress | null;
};
/**
* Subscribes the canvas to the private team channel and forwards
* `.v5.canvas.resource.updated` payloads to the latest onEvent callback
* without resubscribing when the callback identity changes.
*/
export function useCanvasResourceChannel(teamId: number | null, onEvent: (event: V5CanvasResourceUpdatedEvent) => void): void {
useTeamChannel(teamId, '.v5.canvas.resource.updated', (payload) => {
onEvent(payload as V5CanvasResourceUpdatedEvent);
});
}
@@ -0,0 +1,270 @@
// @vitest-environment jsdom
import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
activeConnectionPorts,
connectionDirectionKey,
pruneConnectionPortsByDirection,
useCanvasConnections,
} from '@/lib/use-canvas-connections';
import type { V5ResourceConnection } from '@/types';
vi.mock('@/lib/canvas-api', () => ({
canvasRequest: vi.fn(),
}));
import { canvasRequest } from '@/lib/canvas-api';
const canvasRequestMock = vi.mocked(canvasRequest);
function connection(overrides: Partial<V5ResourceConnection> = {}): V5ResourceConnection {
return {
id: 'connection-1',
applicationIds: ['app-a', 'app-b'],
fromApplicationId: 'app-a',
toApplicationId: 'app-b',
portsByDirection: { 'app-a->app-b': ['80'] },
...overrides,
};
}
function jsonResponse(payload: unknown, ok = true): Response {
return { ok, json: async () => payload } as Response;
}
beforeEach(() => {
canvasRequestMock.mockReset();
});
describe('connection helpers', () => {
it('builds direction keys and reads active ports', () => {
expect(connectionDirectionKey('app-a', 'app-b')).toBe('app-a->app-b');
expect(activeConnectionPorts(connection())).toEqual(['80']);
expect(activeConnectionPorts(connection({ portsByDirection: {} }))).toEqual([]);
});
it('prunes ports to the active direction only', () => {
const pruned = pruneConnectionPortsByDirection(
connection({ portsByDirection: { 'app-a->app-b': ['80'], 'app-b->app-a': ['443'] } }),
);
expect(pruned).toEqual({ 'app-a->app-b': ['80'] });
});
});
describe('connectionExists', () => {
it('matches connections in both directions', () => {
const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn()));
expect(result.current.connectionExists('app-a', 'app-b')).toBe(true);
expect(result.current.connectionExists('app-b', 'app-a')).toBe(true);
expect(result.current.connectionExists('app-a', 'app-c')).toBe(false);
});
});
describe('persistNewConnection', () => {
it('adds and selects the persisted connection on success', async () => {
const persisted = connection({ id: 'connection-2', fromApplicationId: 'app-a', toApplicationId: 'app-c' });
canvasRequestMock.mockResolvedValue(jsonResponse({ connection: persisted }));
const notify = vi.fn();
const { result } = renderHook(() => useCanvasConnections([connection()], notify));
await act(() => result.current.persistNewConnection('app-a', 'app-c'));
expect(canvasRequestMock).toHaveBeenCalledWith('/v5/resource-connections', {
method: 'POST',
body: {
resource_one: { type: 'application', uuid: 'app-a' },
resource_two: { type: 'application', uuid: 'app-c' },
},
});
expect(result.current.connections.map((candidate) => candidate.id)).toEqual(['connection-1', 'connection-2']);
expect(result.current.selectedConnectionId).toBe('connection-2');
expect(notify).toHaveBeenCalledWith(null);
expect(notify).not.toHaveBeenCalledWith(expect.stringContaining('Could not'));
});
it('notifies with message and detail on failure', async () => {
canvasRequestMock.mockResolvedValue(jsonResponse({ message: 'Connection rejected.', detail: 'Applications overlap.' }, false));
const notify = vi.fn();
const { result } = renderHook(() => useCanvasConnections([], notify));
await act(() => result.current.persistNewConnection('app-a', 'app-b'));
expect(notify).toHaveBeenLastCalledWith('Connection rejected. Applications overlap.');
expect(result.current.connections).toEqual([]);
});
});
describe('addConnectionPort', () => {
it.each(['', '0', '65536', '8.5', 'http'])('ignores invalid port draft %j', (draft) => {
const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn()));
act(() => result.current.setConnectionPortDraft('connection-1', draft));
act(() => result.current.addConnectionPort('connection-1'));
expect(canvasRequestMock).not.toHaveBeenCalled();
});
it('ignores a port already present for the active direction', () => {
const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn()));
act(() => result.current.setConnectionPortDraft('connection-1', '80'));
act(() => result.current.addConnectionPort('connection-1'));
expect(canvasRequestMock).not.toHaveBeenCalled();
});
it('optimistically adds the port, persists it, and clears the draft', async () => {
const persisted = connection({ portsByDirection: { 'app-a->app-b': ['80', '443'] } });
canvasRequestMock.mockResolvedValue(jsonResponse({ connection: persisted }));
const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn()));
act(() => result.current.setConnectionPortDraft('connection-1', ' 443 '));
act(() => result.current.addConnectionPort('connection-1'));
expect(activeConnectionPorts(result.current.connections[0])).toEqual(['80', '443']);
expect(result.current.connectionPortInput['connection-1']).toBe('');
await waitFor(() => {
expect(canvasRequestMock).toHaveBeenCalledWith('/v5/resource-connections/connection-1', {
method: 'PATCH',
body: { ports_by_direction: { 'app-a->app-b': [80, 443] } },
});
});
});
it('rolls the port back when persistence fails', async () => {
canvasRequestMock.mockResolvedValue(jsonResponse({ message: 'Could not save allowed ports.' }, false));
const notify = vi.fn();
const { result } = renderHook(() => useCanvasConnections([connection()], notify));
act(() => result.current.setConnectionPortDraft('connection-1', '443'));
act(() => result.current.addConnectionPort('connection-1'));
await waitFor(() => {
expect(activeConnectionPorts(result.current.connections[0])).toEqual(['80']);
});
expect(notify).toHaveBeenCalledWith('Could not save allowed ports.');
});
});
describe('removeConnectionPort', () => {
it('persists the connection without the removed port', async () => {
const persisted = connection({ portsByDirection: { 'app-a->app-b': [] } });
canvasRequestMock.mockResolvedValue(jsonResponse({ connection: persisted }));
const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn()));
act(() => result.current.removeConnectionPort('connection-1', '80'));
expect(activeConnectionPorts(result.current.connections[0])).toEqual([]);
await waitFor(() => {
expect(canvasRequestMock).toHaveBeenCalledWith('/v5/resource-connections/connection-1', {
method: 'PATCH',
body: { ports_by_direction: { 'app-a->app-b': [] } },
});
});
});
});
describe('deleteConnection', () => {
it('optimistically removes the connection and keeps it removed on success', async () => {
canvasRequestMock.mockResolvedValue(jsonResponse({}));
const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn()));
act(() => result.current.setSelectedConnectionId('connection-1'));
act(() => result.current.deleteConnection('connection-1'));
expect(result.current.connections).toEqual([]);
expect(result.current.selectedConnectionId).toBeNull();
await waitFor(() => {
expect(canvasRequestMock).toHaveBeenCalledWith('/v5/resource-connections/connection-1', { method: 'DELETE' });
});
expect(result.current.connections).toEqual([]);
});
it('restores the connection at its original index when deletion fails', async () => {
canvasRequestMock.mockResolvedValue(jsonResponse({}, false));
const first = connection({ id: 'connection-1' });
const second = connection({ id: 'connection-2' });
const notify = vi.fn();
const { result } = renderHook(() => useCanvasConnections([first, second], notify));
act(() => result.current.deleteConnection('connection-1'));
expect(result.current.connections.map((candidate) => candidate.id)).toEqual(['connection-2']);
await waitFor(() => {
expect(result.current.connections.map((candidate) => candidate.id)).toEqual(['connection-1', 'connection-2']);
});
expect(notify).toHaveBeenCalledWith('Could not delete resource connection.');
});
it('does nothing for an unknown connection id', () => {
const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn()));
act(() => result.current.deleteConnection('missing'));
expect(canvasRequestMock).not.toHaveBeenCalled();
expect(result.current.connections).toHaveLength(1);
});
});
describe('updateConnectionDirection', () => {
it('swaps the active direction locally without a request', () => {
const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn()));
act(() => result.current.updateConnectionDirection('connection-1', 'app-b', 'app-a'));
expect(result.current.connections[0]).toMatchObject({ fromApplicationId: 'app-b', toApplicationId: 'app-a' });
expect(canvasRequestMock).not.toHaveBeenCalled();
});
});
describe('removeConnectionsForApplication', () => {
it('drops every connection touching the application', () => {
const related = connection({ id: 'connection-1' });
const unrelated = connection({
id: 'connection-2',
applicationIds: ['app-c', 'app-d'],
fromApplicationId: 'app-c',
toApplicationId: 'app-d',
});
const { result } = renderHook(() => useCanvasConnections([related, unrelated], vi.fn()));
act(() => result.current.removeConnectionsForApplication('app-a'));
expect(result.current.connections.map((candidate) => candidate.id)).toEqual(['connection-2']);
});
});
describe('keyboard deletion', () => {
it('deletes the selected connection on Backspace', async () => {
canvasRequestMock.mockResolvedValue(jsonResponse({}));
const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn()));
act(() => result.current.setSelectedConnectionId('connection-1'));
act(() => {
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }));
});
expect(result.current.connections).toEqual([]);
});
it('ignores Backspace while typing in an input', () => {
const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn()));
const input = document.createElement('input');
document.body.appendChild(input);
act(() => result.current.setSelectedConnectionId('connection-1'));
act(() => {
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }));
});
expect(result.current.connections).toHaveLength(1);
input.remove();
});
});
@@ -0,0 +1,323 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { canvasRequest } from '@/lib/canvas-api';
import { runOptimisticUpdate, type OptimisticRequestResult } from '@/lib/optimistic';
import type { V5ResourceConnection } from '@/types';
export type CanvasConnection = V5ResourceConnection;
export type CanvasNotify = (message: string | null) => void;
export function connectionDirectionKey(fromApplicationId: string, toApplicationId: string): string {
return `${fromApplicationId}->${toApplicationId}`;
}
export function activeConnectionPorts(connection: CanvasConnection): string[] {
return connection.portsByDirection[connectionDirectionKey(connection.fromApplicationId, connection.toApplicationId)] ?? [];
}
export function pruneConnectionPortsByDirection(
connection: CanvasConnection,
fromApplicationId = connection.fromApplicationId,
toApplicationId = connection.toApplicationId,
): Record<string, string[]> {
const directionKey = connectionDirectionKey(fromApplicationId, toApplicationId);
return {
[directionKey]: connection.portsByDirection[directionKey] ?? [],
};
}
function connectionPortsPayload(connection: CanvasConnection): Record<string, number[]> {
return Object.fromEntries(
Object.entries(connection.portsByDirection).map(([direction, ports]) => [
direction,
ports.map((port) => Number(port)).filter((port) => Number.isInteger(port)),
]),
);
}
function preserveActiveDirection(connection: CanvasConnection, activeConnection: CanvasConnection): CanvasConnection {
return {
...connection,
fromApplicationId: activeConnection.fromApplicationId,
toApplicationId: activeConnection.toApplicationId,
};
}
function normalizeConnection(connection: V5ResourceConnection): CanvasConnection {
return {
...connection,
applicationIds: [...connection.applicationIds],
portsByDirection: connection.portsByDirection ?? {},
};
}
function responseErrorMessage(payload: { message?: string; detail?: string }, fallback: string): string {
if (payload.message && payload.detail) {
return `${payload.message} ${payload.detail}`;
}
return payload.message ?? fallback;
}
async function parseConnectionResponse(response: Response, fallback: string): Promise<OptimisticRequestResult<CanvasConnection>> {
const payload = (await response.json()) as { connection?: V5ResourceConnection; message?: string; detail?: string };
if (!response.ok || !payload.connection) {
return { ok: false, errorMessage: responseErrorMessage(payload, fallback) };
}
return { ok: true, payload: normalizeConnection(payload.connection) };
}
/**
* Owns the canvas resource connections: selection, per-direction allowed
* ports, and the optimistic persistence flows for creating, updating, and
* deleting connections.
*/
export function useCanvasConnections(initialConnections: V5ResourceConnection[], notify: CanvasNotify) {
const [connections, setConnections] = useState<CanvasConnection[]>(initialConnections);
const [selectedConnectionId, setSelectedConnectionId] = useState<string | null>(null);
const [connectionPortInput, setConnectionPortInput] = useState<Record<string, string>>({});
const connectionsRef = useRef(connections);
connectionsRef.current = connections;
const resetConnections = useCallback((nextConnections: V5ResourceConnection[]): void => {
setConnections(nextConnections);
setSelectedConnectionId(null);
setConnectionPortInput({});
}, []);
const connectionExists = useCallback(
(fromApplicationId: string, toApplicationId: string): boolean =>
connectionsRef.current.some(
(connection) =>
(connection.fromApplicationId === fromApplicationId && connection.toApplicationId === toApplicationId) ||
(connection.fromApplicationId === toApplicationId && connection.toApplicationId === fromApplicationId),
),
[],
);
const replaceConnection = useCallback((nextConnection: CanvasConnection): void => {
setConnections((currentConnections) =>
currentConnections.map((currentConnection) =>
currentConnection.id === nextConnection.id ? nextConnection : currentConnection,
),
);
}, []);
const persistNewConnection = useCallback(
async (fromApplicationId: string, toApplicationId: string): Promise<void> => {
notify(null);
await runOptimisticUpdate<CanvasConnection>({
request: async () => {
const response = await canvasRequest('/v5/resource-connections', {
method: 'POST',
body: {
resource_one: { type: 'application', uuid: fromApplicationId },
resource_two: { type: 'application', uuid: toApplicationId },
},
});
return parseConnectionResponse(response, 'Could not save resource connection.');
},
fallbackErrorMessage: 'Could not save resource connection.',
notify,
onSuccess: (nextConnection) => {
setConnections((currentConnections) => {
const withoutDuplicate = currentConnections.filter((connection) => connection.id !== nextConnection.id);
return [...withoutDuplicate, nextConnection];
});
setSelectedConnectionId(nextConnection.id);
},
});
},
[notify],
);
const persistConnectionPorts = useCallback(
async (updatedConnection: CanvasConnection, previousConnection: CanvasConnection): Promise<void> => {
await runOptimisticUpdate<CanvasConnection>({
apply: () => replaceConnection(updatedConnection),
rollback: () => replaceConnection(previousConnection),
request: async () => {
const response = await canvasRequest(`/v5/resource-connections/${updatedConnection.id}`, {
method: 'PATCH',
body: { ports_by_direction: connectionPortsPayload(updatedConnection) },
});
return parseConnectionResponse(response, 'Could not save allowed ports.');
},
fallbackErrorMessage: 'Could not save allowed ports.',
notify,
onSuccess: (nextConnection) => replaceConnection(preserveActiveDirection(nextConnection, updatedConnection)),
});
},
[notify, replaceConnection],
);
const deleteConnection = useCallback(
(connectionId: string): void => {
const connectionIndex = connectionsRef.current.findIndex((candidate) => candidate.id === connectionId);
const connection = connectionsRef.current[connectionIndex];
if (!connection) {
return;
}
setSelectedConnectionId(null);
void runOptimisticUpdate({
apply: () => setConnections((currentConnections) => currentConnections.filter((candidate) => candidate.id !== connectionId)),
rollback: () =>
setConnections((currentConnections) => {
if (currentConnections.some((candidate) => candidate.id === connectionId)) {
return currentConnections;
}
const restoredConnections = [...currentConnections];
restoredConnections.splice(Math.min(connectionIndex, restoredConnections.length), 0, connection);
return restoredConnections;
}),
request: async () => {
const response = await canvasRequest(`/v5/resource-connections/${connectionId}`, { method: 'DELETE' });
return response.ok ? { ok: true, payload: undefined } : { ok: false };
},
fallbackErrorMessage: 'Could not delete resource connection.',
notify,
});
},
[notify],
);
const updateConnectionDirection = useCallback(
(connectionId: string, fromApplicationId: string, toApplicationId: string): void => {
const connection = connectionsRef.current.find((candidate) => candidate.id === connectionId);
if (!connection) {
return;
}
const updatedConnection = {
...connection,
fromApplicationId,
toApplicationId,
};
replaceConnection(updatedConnection);
},
[replaceConnection],
);
const setConnectionPortDraft = useCallback((connectionId: string, value: string): void => {
setConnectionPortInput((currentInputs) => ({ ...currentInputs, [connectionId]: value }));
}, []);
const addConnectionPort = useCallback(
(connectionId: string): void => {
const port = connectionPortInput[connectionId]?.trim();
const portNumber = Number(port);
if (!port || !Number.isInteger(portNumber) || portNumber < 1 || portNumber > 65535) {
return;
}
const connection = connectionsRef.current.find((candidate) => candidate.id === connectionId);
if (!connection) {
return;
}
const directionKey = connectionDirectionKey(connection.fromApplicationId, connection.toApplicationId);
const directionPorts = connection.portsByDirection[directionKey] ?? [];
if (directionPorts.includes(port)) {
return;
}
const updatedConnection = {
...connection,
portsByDirection: {
...connection.portsByDirection,
[directionKey]: [...directionPorts, port],
},
};
void persistConnectionPorts(updatedConnection, connection);
setConnectionPortInput((currentInputs) => ({ ...currentInputs, [connectionId]: '' }));
},
[connectionPortInput, persistConnectionPorts],
);
const removeConnectionPort = useCallback(
(connectionId: string, port: string): void => {
const connection = connectionsRef.current.find((candidate) => candidate.id === connectionId);
if (!connection) {
return;
}
const directionKey = connectionDirectionKey(connection.fromApplicationId, connection.toApplicationId);
const updatedConnection = {
...connection,
portsByDirection: {
...connection.portsByDirection,
[directionKey]: activeConnectionPorts(connection).filter((allowedPort) => allowedPort !== port),
},
};
void persistConnectionPorts(updatedConnection, connection);
},
[persistConnectionPorts],
);
const removeConnectionsForApplication = useCallback((applicationId: string): void => {
setConnections((currentConnections) =>
currentConnections.filter(
(connection) => connection.fromApplicationId !== applicationId && connection.toApplicationId !== applicationId,
),
);
}, []);
useEffect(() => {
function deleteSelectedConnection(event: KeyboardEvent): void {
const target = event.target as HTMLElement | null;
if (target?.closest('input, textarea, [contenteditable="true"]')) {
return;
}
if (!['Backspace', 'Delete'].includes(event.key) || !selectedConnectionId) {
return;
}
deleteConnection(selectedConnectionId);
}
window.addEventListener('keydown', deleteSelectedConnection);
return () => window.removeEventListener('keydown', deleteSelectedConnection);
}, [selectedConnectionId, deleteConnection]);
return {
connections,
selectedConnectionId,
setSelectedConnectionId,
connectionPortInput,
setConnectionPortDraft,
resetConnections,
connectionExists,
persistNewConnection,
deleteConnection,
updateConnectionDirection,
addConnectionPort,
removeConnectionPort,
removeConnectionsForApplication,
};
}
@@ -0,0 +1,178 @@
// @vitest-environment jsdom
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useCanvasResourceMerge } from '@/lib/use-canvas-resource-merge';
import type { V5CanvasResourceUpdatedEvent } from '@/lib/use-canvas-channel';
import type { V5Application, V5CaddyIngress } from '@/types';
const channelHandlers: Array<(event: V5CanvasResourceUpdatedEvent) => void> = [];
vi.mock('@/lib/use-canvas-channel', () => ({
useCanvasResourceChannel: (_teamId: number | null, onEvent: (event: V5CanvasResourceUpdatedEvent) => void) => {
channelHandlers.push(onEvent);
},
}));
function application(overrides: Partial<V5Application> = {}): V5Application {
return {
id: 'app-1',
name: 'nginx-test',
status: 'running',
projectUuid: 'project-1',
environmentUuid: 'env-1',
canvasX: 0,
canvasY: 0,
...overrides,
} as V5Application;
}
function ingress(overrides: Partial<V5CaddyIngress> = {}): V5CaddyIngress {
return {
id: 'ingress-1',
name: 'caddy',
status: 'running',
canvasX: 0,
canvasY: 0,
...overrides,
} as V5CaddyIngress;
}
type HarnessOptions = {
initialApplications?: V5Application[];
initialIngresses?: V5CaddyIngress[];
selectedProjectUuid?: string | null;
selectedEnvironmentUuid?: string | null;
locallyPositionedApplicationIds?: Set<string>;
locallyPositionedIngressIds?: Set<string>;
};
function renderMergeHarness({
initialApplications = [],
initialIngresses = [],
selectedProjectUuid = 'project-1',
selectedEnvironmentUuid = 'env-1',
locallyPositionedApplicationIds = new Set<string>(),
locallyPositionedIngressIds = new Set<string>(),
}: HarnessOptions = {}) {
let applications = initialApplications;
let ingresses = initialIngresses;
const setApplications = (update: V5Application[] | ((current: V5Application[]) => V5Application[])): void => {
applications = typeof update === 'function' ? update(applications) : update;
};
const setIngresses = (update: V5CaddyIngress[] | ((current: V5CaddyIngress[]) => V5CaddyIngress[])): void => {
ingresses = typeof update === 'function' ? update(ingresses) : update;
};
renderHook(() =>
useCanvasResourceMerge({
teamId: 1,
selectedProjectUuid,
selectedEnvironmentUuid,
setApplications,
setIngresses,
locallyPositionedApplicationIds: { current: locallyPositionedApplicationIds },
locallyPositionedIngressIds: { current: locallyPositionedIngressIds },
}),
);
const emit = (event: Partial<V5CanvasResourceUpdatedEvent>): void => {
act(() => {
channelHandlers.at(-1)?.({ application: null, caddyIngress: null, ...event });
});
};
return {
emit,
applications: () => applications,
ingresses: () => ingresses,
};
}
beforeEach(() => {
channelHandlers.length = 0;
});
describe('useCanvasResourceMerge', () => {
it('updates an existing application in place', () => {
const harness = renderMergeHarness({ initialApplications: [application()] });
harness.emit({ application: application({ status: 'failed', canvasX: 100 }) });
expect(harness.applications()).toEqual([expect.objectContaining({ id: 'app-1', status: 'failed', canvasX: 100 })]);
});
it('keeps local canvas position for cards mid-drag', () => {
const harness = renderMergeHarness({
initialApplications: [application({ canvasX: 500, canvasY: 300 })],
locallyPositionedApplicationIds: new Set(['app-1']),
});
harness.emit({ application: application({ status: 'failed', canvasX: 0, canvasY: 0 }) });
expect(harness.applications()).toEqual([
expect.objectContaining({ id: 'app-1', status: 'failed', canvasX: 500, canvasY: 300 }),
]);
});
it('appends unknown applications belonging to the selected project and environment', () => {
const harness = renderMergeHarness({ initialApplications: [application()] });
harness.emit({ application: application({ id: 'app-2', projectUuid: 'project-1', environmentUuid: 'env-1' }) });
expect(harness.applications().map((candidate) => candidate.id)).toEqual(['app-1', 'app-2']);
});
it('ignores unknown applications from other projects or environments', () => {
const harness = renderMergeHarness({ initialApplications: [application()] });
harness.emit({ application: application({ id: 'app-2', projectUuid: 'project-9' }) });
harness.emit({ application: application({ id: 'app-3', environmentUuid: 'env-9' }) });
expect(harness.applications().map((candidate) => candidate.id)).toEqual(['app-1']);
});
it('never appends when no project or environment is selected', () => {
const harness = renderMergeHarness({ selectedProjectUuid: null, selectedEnvironmentUuid: null });
harness.emit({ application: application() });
expect(harness.applications()).toEqual([]);
});
it('merges bulk application payloads', () => {
const harness = renderMergeHarness({ initialApplications: [application()] });
harness.emit({
applications: [application({ status: 'failed' }), application({ id: 'app-2' })],
});
expect(harness.applications()).toEqual([
expect.objectContaining({ id: 'app-1', status: 'failed' }),
expect.objectContaining({ id: 'app-2' }),
]);
});
it('updates known ingresses in place but never appends unknown ones', () => {
const harness = renderMergeHarness({ initialIngresses: [ingress()] });
harness.emit({ caddyIngress: ingress({ status: 'stopped' }) });
harness.emit({ caddyIngress: ingress({ id: 'ingress-9' }) });
expect(harness.ingresses()).toEqual([expect.objectContaining({ id: 'ingress-1', status: 'stopped' })]);
});
it('keeps local ingress position for cards mid-drag', () => {
const harness = renderMergeHarness({
initialIngresses: [ingress({ canvasX: 250, canvasY: 100 })],
locallyPositionedIngressIds: new Set(['ingress-1']),
});
harness.emit({ caddyIngress: ingress({ status: 'stopped', canvasX: 0, canvasY: 0 }) });
expect(harness.ingresses()).toEqual([
expect.objectContaining({ id: 'ingress-1', status: 'stopped', canvasX: 250, canvasY: 100 }),
]);
});
});
@@ -0,0 +1,98 @@
import { useCallback, type Dispatch, type RefObject, type SetStateAction } from 'react';
import { useCanvasResourceChannel, type V5CanvasResourceUpdatedEvent } from '@/lib/use-canvas-channel';
import type { V5Application, V5CaddyIngress } from '@/types';
type UseCanvasResourceMergeOptions = {
teamId: number | null;
selectedProjectUuid: string | null;
selectedEnvironmentUuid: string | null;
setApplications: Dispatch<SetStateAction<V5Application[]>>;
setIngresses: Dispatch<SetStateAction<V5CaddyIngress[]>>;
/** Cards mid-drag or with an unsaved position keep their local canvasX/canvasY. */
locallyPositionedApplicationIds: RefObject<Set<string>>;
locallyPositionedIngressIds: RefObject<Set<string>>;
};
/**
* Merges `.v5.canvas.resource.updated` broadcasts into the canvas state.
*
* The team channel broadcasts every canvas resource of the team, so incoming
* applications are only appended when they belong to the selected
* project+environment; existing entries are updated in place regardless.
*/
export function useCanvasResourceMerge({
teamId,
selectedProjectUuid,
selectedEnvironmentUuid,
setApplications,
setIngresses,
locallyPositionedApplicationIds,
locallyPositionedIngressIds,
}: UseCanvasResourceMergeOptions): void {
const belongsToCurrentCanvas = useCallback(
(application: V5Application): boolean =>
selectedProjectUuid !== null &&
selectedEnvironmentUuid !== null &&
application.projectUuid === selectedProjectUuid &&
application.environmentUuid === selectedEnvironmentUuid,
[selectedProjectUuid, selectedEnvironmentUuid],
);
const mergeIncomingApplications = useCallback(
(incomingApplications: V5Application[]): void => {
setApplications((currentApplications) => {
const updatedApplications = currentApplications.map((application) => {
const incoming = incomingApplications.find((candidate) => candidate.id === application.id);
if (!incoming) {
return application;
}
return locallyPositionedApplicationIds.current.has(incoming.id)
? { ...incoming, canvasX: application.canvasX, canvasY: application.canvasY }
: incoming;
});
const appendedApplications = incomingApplications.filter(
(candidate) =>
!currentApplications.some((application) => application.id === candidate.id) &&
belongsToCurrentCanvas(candidate),
);
return [...updatedApplications, ...appendedApplications];
});
},
[belongsToCurrentCanvas, setApplications, locallyPositionedApplicationIds],
);
const handleCanvasResourceEvent = useCallback(
(event: V5CanvasResourceUpdatedEvent): void => {
if (event.application) {
mergeIncomingApplications([event.application]);
}
if (event.applications && event.applications.length > 0) {
mergeIncomingApplications(event.applications);
}
const incomingIngress = event.caddyIngress;
if (incomingIngress) {
setIngresses((currentIngresses) =>
currentIngresses.map((ingress) => {
if (ingress.id !== incomingIngress.id) {
return ingress;
}
return locallyPositionedIngressIds.current.has(incomingIngress.id)
? { ...incomingIngress, canvasX: ingress.canvasX, canvasY: ingress.canvasY }
: incomingIngress;
}),
);
}
},
[mergeIncomingApplications, setIngresses, locallyPositionedIngressIds],
);
useCanvasResourceChannel(teamId, handleCanvasResourceEvent);
}
@@ -0,0 +1,172 @@
// @vitest-environment jsdom
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import type { PointerEvent, WheelEvent } from 'react';
import { APPLICATION_CARD_HEIGHT, APPLICATION_CARD_WIDTH } from '@/lib/canvas-geometry';
import { MAX_CANVAS_ZOOM, MIN_CANVAS_ZOOM, PINCH_CANVAS_ZOOM_STEP, useCanvasViewport } from '@/lib/use-canvas-viewport';
const CANVAS_WIDTH = 800;
const CANVAS_HEIGHT = 600;
function canvasElement(): HTMLDivElement {
const element = document.createElement('div');
element.getBoundingClientRect = () =>
({ left: 0, top: 0, width: CANVAS_WIDTH, height: CANVAS_HEIGHT, right: CANVAS_WIDTH, bottom: CANVAS_HEIGHT, x: 0, y: 0 }) as DOMRect;
return element;
}
function renderViewport() {
const rendered = renderHook(() => useCanvasViewport());
rendered.result.current.canvasRef.current = canvasElement();
return rendered;
}
describe('zoomCanvas', () => {
it('zooms in by one step around the canvas center', () => {
const { result } = renderViewport();
act(() => result.current.zoomCanvas(1));
expect(result.current.viewport.zoom).toBeCloseTo(1.1);
// The canvas point under the viewport center must stay put.
expect((CANVAS_WIDTH / 2 - result.current.viewport.x) / result.current.viewport.zoom).toBeCloseTo(CANVAS_WIDTH / 2);
expect((CANVAS_HEIGHT / 2 - result.current.viewport.y) / result.current.viewport.zoom).toBeCloseTo(CANVAS_HEIGHT / 2);
});
it('keeps an explicit zoom origin stable', () => {
const { result } = renderViewport();
const origin = { x: 100, y: 50 };
act(() => result.current.zoomCanvas(1, 0.5, origin));
const { x, y, zoom } = result.current.viewport;
expect(zoom).toBeCloseTo(1.5);
expect((origin.x - x) / zoom).toBeCloseTo(origin.x);
expect((origin.y - y) / zoom).toBeCloseTo(origin.y);
});
it('clamps zoom to the maximum', () => {
const { result } = renderViewport();
act(() => result.current.zoomCanvas(1, 10));
expect(result.current.viewport.zoom).toBe(MAX_CANVAS_ZOOM);
});
it('clamps zoom to the minimum', () => {
const { result } = renderViewport();
act(() => result.current.zoomCanvas(-1, 10));
expect(result.current.viewport.zoom).toBe(MIN_CANVAS_ZOOM);
});
it('does nothing when the canvas element is not mounted', () => {
const { result } = renderHook(() => useCanvasViewport());
act(() => result.current.zoomCanvas(1));
expect(result.current.viewport).toEqual({ x: 0, y: 0, zoom: 1 });
});
});
describe('handleCanvasWheel', () => {
function wheelEvent(overrides: Partial<{ ctrlKey: boolean; deltaY: number; clientX: number; clientY: number }>) {
return {
ctrlKey: true,
deltaY: -1,
clientX: 0,
clientY: 0,
currentTarget: canvasElement(),
preventDefault: () => {},
...overrides,
} as unknown as WheelEvent<HTMLDivElement>;
}
it('pinch-zooms in on ctrl+wheel up', () => {
const { result } = renderViewport();
act(() => result.current.handleCanvasWheel(wheelEvent({ deltaY: -1 })));
expect(result.current.viewport.zoom).toBeCloseTo(1 + PINCH_CANVAS_ZOOM_STEP);
});
it('pinch-zooms out on ctrl+wheel down', () => {
const { result } = renderViewport();
act(() => result.current.handleCanvasWheel(wheelEvent({ deltaY: 1 })));
expect(result.current.viewport.zoom).toBeCloseTo(1 - PINCH_CANVAS_ZOOM_STEP);
});
it('ignores plain scrolling without ctrl', () => {
const { result } = renderViewport();
act(() => result.current.handleCanvasWheel(wheelEvent({ ctrlKey: false })));
expect(result.current.viewport.zoom).toBe(1);
});
});
describe('centerOnCanvasNodes', () => {
it('centers a single card in the canvas', () => {
const { result } = renderViewport();
act(() => result.current.centerOnCanvasNodes([{ canvasX: 0, canvasY: 0 }], []));
expect(result.current.viewport).toEqual({
x: CANVAS_WIDTH / 2 - APPLICATION_CARD_WIDTH / 2,
y: CANVAS_HEIGHT / 2 - APPLICATION_CARD_HEIGHT / 2,
zoom: 1,
});
});
it('centers on the midpoint of multiple nodes', () => {
const { result } = renderViewport();
act(() => result.current.centerOnCanvasNodes([{ canvasX: 0, canvasY: 0 }], [{ canvasX: 400, canvasY: 200 }]));
expect(result.current.viewport).toEqual({
x: CANVAS_WIDTH / 2 - (200 + APPLICATION_CARD_WIDTH / 2),
y: CANVAS_HEIGHT / 2 - (100 + APPLICATION_CARD_HEIGHT / 2),
zoom: 1,
});
});
it('resets the pan but keeps the zoom when there are no nodes', () => {
const { result } = renderViewport();
act(() => result.current.zoomCanvas(1));
act(() => result.current.centerOnCanvasNodes([], []));
expect(result.current.viewport).toEqual({ x: 0, y: 0, zoom: result.current.viewport.zoom });
expect(result.current.viewport.x).toBe(0);
expect(result.current.viewport.y).toBe(0);
});
});
describe('canvasPointFromPointer', () => {
function pointerEvent(clientX: number, clientY: number): PointerEvent {
return { clientX, clientY } as PointerEvent;
}
it('maps client coordinates through pan and zoom', () => {
const { result } = renderViewport();
act(() => result.current.setViewport({ x: 100, y: 50, zoom: 2 }));
expect(result.current.canvasPointFromPointer(pointerEvent(300, 250))).toEqual({ x: 100, y: 100 });
});
it('returns the origin when the canvas element is not mounted', () => {
const { result } = renderHook(() => useCanvasViewport());
expect(result.current.canvasPointFromPointer(pointerEvent(300, 250))).toEqual({ x: 0, y: 0 });
});
});
+125
View File
@@ -0,0 +1,125 @@
import { useCallback, useRef, useState, type PointerEvent, type WheelEvent } from 'react';
import { APPLICATION_CARD_HEIGHT, APPLICATION_CARD_WIDTH, type CanvasCardNode } from '@/lib/canvas-geometry';
export type Viewport = {
x: number;
y: number;
zoom: number;
};
export const MIN_CANVAS_ZOOM = 0.5;
export const MAX_CANVAS_ZOOM = 2;
export const CANVAS_ZOOM_STEP = 0.1;
export const PINCH_CANVAS_ZOOM_STEP = 0.03;
function clampCanvasZoom(zoom: number): number {
return Math.min(MAX_CANVAS_ZOOM, Math.max(MIN_CANVAS_ZOOM, zoom));
}
export function useCanvasViewport() {
const canvasRef = useRef<HTMLDivElement | null>(null);
const [viewport, setViewport] = useState<Viewport>({ x: 0, y: 0, zoom: 1 });
const viewportRef = useRef(viewport);
viewportRef.current = viewport;
const zoomCanvas = useCallback((direction: 1 | -1, step = CANVAS_ZOOM_STEP, origin?: { x: number; y: number }): void => {
const rect = canvasRef.current?.getBoundingClientRect();
setViewport((currentViewport) => {
const nextZoom = clampCanvasZoom(currentViewport.zoom + direction * step);
if (!rect || nextZoom === currentViewport.zoom) {
return currentViewport;
}
const originX = origin?.x ?? rect.width / 2;
const originY = origin?.y ?? rect.height / 2;
const canvasX = (originX - currentViewport.x) / currentViewport.zoom;
const canvasY = (originY - currentViewport.y) / currentViewport.zoom;
return {
x: originX - canvasX * nextZoom,
y: originY - canvasY * nextZoom,
zoom: nextZoom,
};
});
}, []);
const handleCanvasWheel = useCallback(
(event: WheelEvent<HTMLDivElement>): void => {
if (!event.ctrlKey) {
return;
}
const rect = event.currentTarget.getBoundingClientRect();
event.preventDefault();
zoomCanvas(event.deltaY < 0 ? 1 : -1, PINCH_CANVAS_ZOOM_STEP, {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
});
},
[zoomCanvas],
);
const centerOnCanvasNodes = useCallback((applications: CanvasCardNode[], ingresses: CanvasCardNode[]): void => {
const canvas = canvasRef.current;
const nodes = [...applications, ...ingresses];
if (!canvas || nodes.length === 0) {
setViewport((currentViewport) => ({ x: 0, y: 0, zoom: currentViewport.zoom }));
return;
}
const bounds = nodes.reduce(
(currentBounds, node) => ({
minX: Math.min(currentBounds.minX, node.canvasX),
maxX: Math.max(currentBounds.maxX, node.canvasX),
minY: Math.min(currentBounds.minY, node.canvasY),
maxY: Math.max(currentBounds.maxY, node.canvasY),
}),
{
minX: nodes[0]?.canvasX ?? 0,
maxX: nodes[0]?.canvasX ?? 0,
minY: nodes[0]?.canvasY ?? 0,
maxY: nodes[0]?.canvasY ?? 0,
},
);
const centerX = (bounds.minX + bounds.maxX) / 2;
const centerY = (bounds.minY + bounds.maxY) / 2;
const rect = canvas.getBoundingClientRect();
setViewport((currentViewport) => ({
x: rect.width / 2 - (centerX + APPLICATION_CARD_WIDTH / 2) * currentViewport.zoom,
y: rect.height / 2 - (centerY + APPLICATION_CARD_HEIGHT / 2) * currentViewport.zoom,
zoom: currentViewport.zoom,
}));
}, []);
const canvasPointFromPointer = useCallback((event: PointerEvent): { x: number; y: number } => {
const rect = canvasRef.current?.getBoundingClientRect();
const currentViewport = viewportRef.current;
if (!rect) {
return { x: 0, y: 0 };
}
return {
x: (event.clientX - rect.left - currentViewport.x) / currentViewport.zoom,
y: (event.clientY - rect.top - currentViewport.y) / currentViewport.zoom,
};
}, []);
return {
canvasRef,
viewport,
setViewport,
zoomCanvas,
handleCanvasWheel,
centerOnCanvasNodes,
canvasPointFromPointer,
};
}
+28
View File
@@ -0,0 +1,28 @@
import { useCallback, useState } from 'react';
export function usePendingIds<T extends string | number>() {
const [pendingIds, setPendingIds] = useState<Set<T>>(() => new Set());
const start = useCallback((id: T): void => {
setPendingIds((currentIds) => new Set(currentIds).add(id));
}, []);
const finish = useCallback((id: T): void => {
setPendingIds((currentIds) => {
const nextIds = new Set(currentIds);
nextIds.delete(id);
return nextIds;
});
}, []);
const has = useCallback((id: T): boolean => pendingIds.has(id), [pendingIds]);
return {
pendingIds,
has,
hasAny: pendingIds.size > 0,
start,
finish,
};
}
+116
View File
@@ -0,0 +1,116 @@
import { useEffect, useRef } from 'react';
type EchoChannel = {
listen: (event: string, callback: (payload: unknown) => void) => EchoChannel;
subscribed?: (callback: () => void) => EchoChannel;
error?: (callback: (error: unknown) => void) => EchoChannel;
};
type EchoClient = {
private: (channel: string) => EchoChannel;
leave?: (channel: string) => void;
leaveChannel?: (channel: string) => void;
};
declare global {
interface Window {
Echo?: EchoClient;
}
}
export type TeamChannelLogger = (message: string, payload?: unknown) => void;
export type UseTeamChannelOptions = {
/** Receives subscription lifecycle messages; defaults to console.debug. */
onDebug?: TeamChannelLogger;
/** Receives subscription failures; defaults to console.error. */
onError?: TeamChannelLogger;
};
/**
* Subscribes to the private `team.{teamId}` channel and forwards `eventName`
* payloads to the latest onEvent callback without resubscribing when the
* callback identity changes. Polls for window.Echo (500ms, up to 20 attempts)
* because the Echo script can finish loading after React mounts.
*/
export function useTeamChannel(
teamId: number | null,
eventName: string,
onEvent: (payload: unknown) => void,
options: UseTeamChannelOptions = {},
): void {
const onEventRef = useRef(onEvent);
const onDebugRef = useRef(options.onDebug);
const onErrorRef = useRef(options.onError);
onEventRef.current = onEvent;
onDebugRef.current = options.onDebug;
onErrorRef.current = options.onError;
useEffect(() => {
if (teamId === null) {
return;
}
const debug: TeamChannelLogger = (message, payload) => {
if (onDebugRef.current) {
onDebugRef.current(message, payload);
return;
}
console.debug(message);
};
const fail: TeamChannelLogger = (message, payload) => {
if (onErrorRef.current) {
onErrorRef.current(message, payload);
return;
}
console.error(message, payload);
};
let isCancelled = false;
let attempts = 0;
const channelName = `team.${teamId}`;
const interval = window.setInterval(() => {
attempts += 1;
if (!window.Echo) {
if (attempts === 1) {
debug(`Waiting for window.Echo before subscribing to ${eventName} updates`);
}
if (attempts >= 20) {
window.clearInterval(interval);
fail('window.Echo was not available after 10 seconds.');
}
return;
}
window.clearInterval(interval);
if (isCancelled) {
return;
}
debug(`Subscribing to private-${channelName} for ${eventName} updates`);
const channel = window.Echo.private(channelName);
channel.subscribed?.(() => debug(`Subscribed to private-${channelName} for ${eventName} updates`));
channel.error?.((error) => fail(`Subscription error on private-${channelName}`, error));
channel.listen(eventName, (payload) => {
onEventRef.current(payload);
});
}, 500);
return () => {
isCancelled = true;
window.clearInterval(interval);
window.Echo?.leave?.(channelName) ?? window.Echo?.leaveChannel?.(`private-${channelName}`);
};
}, [teamId, eventName]);
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
+154
View File
@@ -0,0 +1,154 @@
export type FluxStatus = {
available?: boolean;
label?: string | null;
message?: string | null;
socket?: string | null;
};
export type V5Server = {
id: string;
name: string;
host: string;
status: string;
capabilities: string[];
builderEnabled: boolean;
builderCapacity: number;
builderCpuQuota: string;
ingressEnabled: boolean;
ingressType: string | null;
uuid: string | null;
nodeAddress: string | null;
wireguardListenPortOverride: number | null;
wireguardEndpointOverride: string | null;
wireguardManagementIp: string | null;
wireguardPublicKey: string | null;
/** Namespace → subnet map; serialized as an empty JSON array when no subnets are recorded. */
containerSubnets: Record<string, string> | [];
privateKeyName: string | null;
lastBootstrappedAt: string | null;
lastBootstrapAction: string | null;
lastBootstrapStatus: string | null;
lastBootstrapOutput: string | null;
lastBootstrapRanAt: string | null;
lastStatusOutput: string | null;
lastStatusCheckedAt: string | null;
};
export type V5Cluster = {
id: string;
name: string;
description: string | null;
wireguardInterface: string;
wireguardManagementPool: string;
wireguardListenPort: number;
containerNetworkPool: string;
containerNetworkPrefix: number;
namespaces: string[];
defaultDenyContainers: boolean;
cooldVersion: string;
corrosionVersion: string;
corrosionGossipPort: number;
corrosionApiPort: number;
builderEnabled: boolean;
builderCapacity: number;
builderCpuQuota: string;
builderMemoryMax: string;
builderTimeoutSecs: number;
lastCliAction: string | null;
lastCliStatus: string | null;
lastCliSummary: string | null;
lastCliRanAt: string | null;
serversCount: number;
servers: V5Server[];
};
export type V5Environment = {
uuid: string;
name: string;
};
export type V5Project = {
uuid: string;
name: string;
environments: V5Environment[];
};
export type V5PrivateKey = {
id: string;
name: string;
};
export type V5NginxServer = {
id: string;
name: string;
host: string;
status: string;
};
export type V5Application = {
id: string;
name: string;
image: string;
containerName: string;
status: 'creating' | 'running' | 'failed' | string;
statusMessage: string | null;
effectiveStatus: 'creating' | 'running' | 'failed' | 'unknown' | string;
effectiveStatusMessage: string | null;
runtimeContainerId: string | null;
serverName: string | null;
serverStatus: string | null;
serverStatusMessage: string | null;
isServerReachable: boolean;
serverIngressEnabled: boolean;
meshNamespace: string;
ingressEnabled: boolean;
internalPort: number | null;
domains: string[];
meshFqdn: string;
projectUuid: string | null;
environmentUuid: string | null;
canvasX: number;
canvasY: number;
};
export type V5CaddyIngress = {
id: string;
name: string;
host: string;
type: string;
status: string;
statusMessage: string | null;
canvasX: number;
canvasY: number;
};
export type V5ResourceConnection = {
id: string;
applicationIds: string[];
fromApplicationId: string;
toApplicationId: string;
portsByDirection: Record<string, string[]>;
};
export type V5DashboardProps = {
flux: FluxStatus | null;
currentTeam?: {
id: number;
} | null;
applications?: V5Application[];
caddyIngresses?: V5CaddyIngress[];
resourceConnections?: V5ResourceConnection[];
nginxServers?: V5NginxServer[];
clusters?: V5Cluster[];
privateKeys?: V5PrivateKey[];
projects?: V5Project[];
selectedProjectUuid?: string | null;
selectedEnvironmentUuid?: string | null;
selectedApplicationUuid?: string | null;
};
export type SelectItemOption = {
label: string;
value: string;
};
+1
View File
@@ -0,0 +1 @@
declare module '*.css';
+26 -44
View File
@@ -1,51 +1,33 @@
<x-layout-simple>
<section class="bg-gray-50 dark:bg-base">
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
<div class="w-full max-w-md space-y-8">
<div class="text-center space-y-2">
<h1 class="!text-5xl font-extrabold tracking-tight text-gray-900 dark:text-white">
Coolify
</h1>
<p class="text-lg dark:text-neutral-400">
Confirm Your Password
</p>
</div>
<x-auth.shell title="Coolify" description="Confirm your password to continue to this secure area.">
<div class="flex flex-col gap-4">
@if (session('status'))
<x-auth.alert type="success">{{ session('status') }}</x-auth.alert>
@endif
<div class="space-y-6">
@if (session('status'))
<div class="p-4 bg-success/10 border border-success rounded-lg">
<p class="text-sm text-success">{{ session('status') }}</p>
</div>
@endif
@if ($errors->any())
<div class="p-4 bg-error/10 border border-error rounded-lg">
@foreach ($errors->all() as $error)
<p class="text-sm text-error">{{ $error }}</p>
@endforeach
</div>
@endif
<div class="p-4 bg-neutral-50 dark:bg-coolgray-200 rounded-lg border border-neutral-200 dark:border-coolgray-400">
<div class="flex gap-3">
<svg class="size-5 flex-shrink-0 mt-0.5 text-coollabs dark:text-warning" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="text-sm dark:text-neutral-400">
This is a secure area. Please confirm your password before continuing.
</p>
</div>
@if ($errors->any())
<x-auth.alert type="error">
<div class="flex flex-col gap-1">
@foreach ($errors->all() as $error)
<p>{{ $error }}</p>
@endforeach
</div>
</x-auth.alert>
@endif
<form action="/user/confirm-password" method="POST" class="flex flex-col gap-4">
@csrf
<x-forms.input required type="password" name="password" label="{{ __('input.password') }}" />
<x-forms.button class="w-full justify-center py-3 box-boarding" type="submit" isHighlighted>
{{ __('auth.confirm_password') }}
</x-forms.button>
</form>
</div>
<div class="auth-guidance">
<x-reicon name="info-circle" class="mt-0.5 size-4 shrink-0" />
<p>This is a secure area. Please confirm your password before continuing.</p>
</div>
<form action="/user/confirm-password" method="POST" class="flex flex-col gap-4">
@csrf
<x-forms.input required type="password" name="password" autocomplete="current-password" autofocus
label="{{ __('input.password') }}" />
<x-forms.button class="w-full justify-center" type="submit" isHighlighted>
{{ __('auth.confirm_password') }}
</x-forms.button>
</form>
</div>
</section>
</x-auth.shell>
</x-layout-simple>
+40 -83
View File
@@ -1,88 +1,45 @@
<x-layout-simple>
<section class="bg-gray-50 dark:bg-base">
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
<div class="w-full max-w-md space-y-8">
<div class="text-center space-y-2">
<h1 class="!text-5xl font-extrabold tracking-tight text-gray-900 dark:text-white">
Coolify
</h1>
<p class="text-lg dark:text-neutral-400">
{{ __('auth.forgot_password_heading') }}
</p>
</div>
<x-auth.shell title="Coolify"
description="Enter your account email and we’ll send you a secure reset link.">
<div class="flex flex-col gap-4">
@if (session('status'))
<x-auth.alert type="success">{{ session('status') }}</x-auth.alert>
@endif
<div class="space-y-6">
@if (session('status'))
<div class="mb-6 p-4 bg-success/10 border border-success rounded-lg">
<div class="flex gap-3">
<svg class="size-5 text-success flex-shrink-0 mt-0.5" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd" />
</svg>
<p class="text-sm text-success">{{ session('status') }}</p>
</div>
</div>
@endif
@if ($errors->any())
<div class="mb-6 p-4 bg-error/10 border border-error rounded-lg">
@foreach ($errors->all() as $error)
<p class="text-sm text-error">{{ $error }}</p>
@endforeach
</div>
@endif
@if (is_transactional_emails_enabled())
<form action="/forgot-password" method="POST" class="flex flex-col gap-4">
@csrf
<x-forms.input required type="email" name="email" label="{{ __('input.email') }}" />
<x-forms.button class="w-full justify-center py-3 box-boarding" type="submit" isHighlighted>
{{ __('auth.forgot_password_send_email') }}
</x-forms.button>
</form>
@else
<div class="p-4 bg-warning/10 border border-warning rounded-lg mb-6">
<div class="flex gap-3">
<svg class="size-5 text-warning flex-shrink-0 mt-0.5" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
clip-rule="evenodd" />
</svg>
<div>
<p class="font-bold text-warning mb-2">Email Not Configured</p>
<p class="text-sm dark:text-white text-black mb-2">
Transactional emails are not active on this instance.
</p>
<p class="text-sm dark:text-white text-black">
See how to set it in our <a class="font-bold underline hover:text-coollabs"
target="_blank" href="{{ config('constants.urls.docs') }}">documentation</a>, or
learn how to manually reset your password.
</p>
</div>
</div>
</div>
@endif
<div class="relative my-6">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-neutral-300 dark:border-coolgray-400"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 dark:bg-base text-neutral-500 dark:text-neutral-400">
Remember your password?
</span>
</div>
@if ($errors->any())
<x-auth.alert type="error">
<div class="flex flex-col gap-1">
@foreach ($errors->all() as $error)
<p>{{ $error }}</p>
@endforeach
</div>
</x-auth.alert>
@endif
<a href="/login"
class="block w-full text-center py-3 px-4 rounded-lg border border-neutral-300 dark:border-coolgray-400 font-medium hover:border-coollabs dark:hover:border-warning transition-colors">
Back to Login
</a>
</div>
</div>
@if (is_transactional_emails_enabled())
<form action="/forgot-password" method="POST" class="flex flex-col gap-4">
@csrf
<x-forms.input required type="email" name="email" autocomplete="email" autofocus
label="{{ __('input.email') }}" />
<x-forms.button class="w-full justify-center" type="submit" isHighlighted>
{{ __('auth.forgot_password_send_email') }}
</x-forms.button>
</form>
@else
<x-auth.alert type="warning">
<p class="font-medium">Transactional email is not configured</p>
<p class="mt-0.5 text-black/70 dark:text-white/70">
Configure email delivery or follow the
<a class="font-medium underline underline-offset-2" target="_blank" rel="noopener noreferrer"
href="{{ config('constants.urls.docs') }}">manual reset guide</a>.
</p>
</x-auth.alert>
@endif
</div>
</section>
</x-layout-simple>
<x-slot:footer>
<span>Remember your password?</span>
<a href="/login" class="auth-text-link">Back to login</a>
</x-slot:footer>
</x-auth.shell>
</x-layout-simple>
+92 -91
View File
@@ -1,102 +1,103 @@
<x-layout-simple>
<section class="bg-gray-50 dark:bg-base">
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
<div class="w-full max-w-md space-y-8">
<div class="text-center space-y-2">
<h1 class="!text-5xl font-extrabold tracking-tight text-gray-900 dark:text-white">
Coolify
</h1>
</div>
<x-auth.shell title="Coolify" description="Sign in to manage your applications and infrastructure.">
<div class="flex flex-col gap-4">
@if (session('status'))
<x-auth.alert type="success">{{ session('status') }}</x-auth.alert>
@endif
<div class="space-y-6">
@if (session('status'))
<div class="mb-6 p-4 bg-success/10 border border-success rounded-lg">
<p class="text-sm text-success">{{ session('status') }}</p>
</div>
@endif
@if (session('error'))
<x-auth.alert type="error">{{ session('error') }}</x-auth.alert>
@endif
@if (session('error'))
<div class="mb-6 p-4 bg-error/10 border border-error rounded-lg">
<p class="text-sm text-error">{{ session('error') }}</p>
</div>
@endif
@if ($errors->any())
<x-auth.alert type="error">
<div class="flex flex-col gap-1">
@foreach ($errors->all() as $error)
<p>{{ $error }}</p>
@endforeach
</div>
</x-auth.alert>
@endif
@if ($errors->any())
<div class="mb-6 p-4 bg-error/10 border border-error rounded-lg">
@foreach ($errors->all() as $error)
<p class="text-sm text-error">{{ $error }}</p>
@endforeach
</div>
@endif
<form action="/login" method="POST" class="flex flex-col gap-4">
@csrf
@env('local')
<x-forms.input value="test@example.com" type="email" autocomplete="email" name="email" required
autofocus label="{{ __('input.email') }}" />
<x-forms.input value="password" type="password" autocomplete="current-password" name="password"
required label="{{ __('input.password') }}" />
@else
<x-forms.input type="email" name="email" autocomplete="email" required autofocus
label="{{ __('input.email') }}" />
<x-forms.input type="password" name="password" autocomplete="current-password" required
label="{{ __('input.password') }}" />
@endenv
<form action="/login" method="POST" class="flex flex-col gap-4">
@csrf
@env('local')
<x-forms.input value="test@example.com" type="email" autocomplete="email" name="email" required
label="{{ __('input.email') }}" />
<x-forms.input value="password" type="password" autocomplete="current-password" name="password"
required label="{{ __('input.password') }}" />
@else
<x-forms.input type="email" name="email" autocomplete="email" required
label="{{ __('input.email') }}" />
<x-forms.input type="password" name="password" autocomplete="current-password" required
label="{{ __('input.password') }}" />
@endenv
<div class="flex items-center justify-between">
<a href="/forgot-password"
class="text-sm dark:text-neutral-400 hover:text-coollabs dark:hover:text-warning hover:underline transition-colors">
{{ __('auth.forgot_password_link') }}
</a>
</div>
<x-forms.button class="w-full justify-center py-3 box-boarding" type="submit" isHighlighted>
{{ __('auth.login') }}
</x-forms.button>
</form>
@if ($is_registration_enabled)
<div class="relative my-6">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-neutral-300 dark:border-coolgray-400"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-gray-50 dark:bg-base text-neutral-500 dark:text-neutral-400 ">
Don't have an account?
</span>
</div>
</div>
<a href="/register"
class="block w-full text-center py-3 px-4 rounded-lg border border-neutral-300 dark:border-coolgray-400 font-medium hover:border-coollabs dark:hover:border-warning transition-colors">
{{ __('auth.register_now') }}
<div class="flex justify-end">
@if (is_transactional_emails_enabled())
<a href="/forgot-password" class="auth-text-link">
{{ __('auth.forgot_password_link') }}
</a>
@else
<div class="mt-6 text-center text-sm text-neutral-500 dark:text-neutral-400">
{{ __('auth.registration_disabled') }}
</div>
@endif
@if ($enabled_oauth_providers->isNotEmpty())
<div class="relative my-6">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-neutral-300 dark:border-coolgray-400"></div>
<span class="relative inline-flex"
x-data="{ visible: false, _t: null }"
@mouseenter="_t = setTimeout(() => {
visible = true;
$nextTick(() => requestAnimationFrame(() => {
const tip = $refs.tip;
if (!tip) return;
const r = $el.getBoundingClientRect();
const t = tip.getBoundingClientRect();
let top = r.top - t.height - 6;
let left = r.left;
if (top < 4) top = r.bottom + 6;
if (left + t.width > innerWidth - 8) left = innerWidth - 8 - t.width;
if (left < 4) left = 4;
tip.style.top = top + 'px';
tip.style.left = left + 'px';
}));
}, 300)"
@mouseleave="clearTimeout(_t); visible = false"
@focusin="visible = true"
@focusout="visible = false">
<span tabindex="0" role="link" aria-disabled="true"
class="auth-text-link cursor-not-allowed opacity-50"
aria-describedby="forgot-password-disabled-tooltip">
{{ __('auth.forgot_password_link') }}
</span>
<div id="forgot-password-disabled-tooltip" x-ref="tip" x-show="visible" x-cloak
class="auth-tooltip max-w-xs whitespace-normal">
{{ __('auth.forgot_password_disabled_tooltip') }}
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-gray-50 dark:bg-base text-neutral-500 dark:text-neutral-400">or
continue with</span>
</div>
</div>
<div class="flex flex-col gap-3">
@foreach ($enabled_oauth_providers as $provider_setting)
<x-forms.button class="w-full justify-center" type="button"
onclick="document.location.href='/auth/{{ $provider_setting->provider }}/redirect'">
{{ __("auth.login.$provider_setting->provider") }}
</x-forms.button>
@endforeach
</div>
</span>
@endif
</div>
</div>
<x-forms.button class="w-full justify-center" type="submit" isHighlighted>
{{ __('auth.login') }}
</x-forms.button>
</form>
@if ($enabled_oauth_providers->isNotEmpty())
<div class="auth-divider"><span>Or continue with</span></div>
<div class="grid gap-2 sm:grid-cols-2">
@foreach ($enabled_oauth_providers as $provider_setting)
<x-forms.button class="w-full justify-center" type="button"
onclick="document.location.href='/auth/{{ $provider_setting->provider }}/redirect'">
{{ __("auth.login.$provider_setting->provider") }}
</x-forms.button>
@endforeach
</div>
@endif
</div>
</section>
</x-layout-simple>
<x-slot:footer>
@if ($is_registration_enabled)
<span>New to Coolify?</span>
<a href="/register" class="auth-text-link">{{ __('auth.register_now') }}</a>
@else
<span>{{ __('auth.registration_disabled') }}</span>
@endif
</x-slot:footer>
</x-auth.shell>
</x-layout-simple>
+48 -83
View File
@@ -11,89 +11,54 @@ $email = getOldOrLocal('email', 'test3@example.com');
?>
<x-layout-simple>
<section class="bg-gray-50 dark:bg-base">
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
<div class="w-full max-w-md space-y-8">
<div class="text-center space-y-2">
<h1 class="!text-5xl font-extrabold tracking-tight text-gray-900 dark:text-white">
Coolify
</h1>
<p class="text-lg dark:text-neutral-400">
Create your account
</p>
<x-auth.shell title="Coolify"
:description="$isFirstUser ? 'Create the root account for this instance.' : 'Create your account to get started.'">
<div class="flex flex-col gap-4">
@if ($isFirstUser)
<x-auth.alert type="warning">
<p class="font-medium">Full instance access</p>
<p class="mt-0.5 text-black/70 dark:text-white/70">This first account becomes the root user.</p>
</x-auth.alert>
@endif
@if ($errors->any())
<x-auth.alert type="error">
<div class="flex flex-col gap-1">
@foreach ($errors->all() as $error)
<p>{{ $error }}</p>
@endforeach
</div>
</x-auth.alert>
@endif
<form action="/register" method="POST" class="flex flex-col gap-4">
@csrf
<x-forms.input id="name" required type="text" name="name" value="{{ $name }}" autofocus
autocomplete="name" label="{{ __('input.name') }}" />
<x-forms.input id="email" required type="email" name="email" value="{{ $email }}"
autocomplete="email" label="{{ __('input.email') }}" />
<x-forms.input id="password" required type="password" name="password" autocomplete="new-password"
label="{{ __('input.password') }}" />
<x-forms.input id="password_confirmation" required type="password" name="password_confirmation"
autocomplete="new-password" label="{{ __('input.password.again') }}" />
<div class="auth-guidance">
<x-reicon name="info-circle" class="mt-0.5 size-4 shrink-0" />
<p>Use at least 8 characters with uppercase, lowercase, number, and symbol.</p>
</div>
<div class="space-y-6">
@if ($isFirstUser)
<div class="mb-6 p-4 bg-warning/10 border border-warning rounded-lg">
<div class="flex gap-3">
<svg class="size-5 text-warning flex-shrink-0 mt-0.5" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd" />
</svg>
<div>
<p class="font-bold text-warning">Root User Setup</p>
<p class="text-sm dark:text-white text-black">This user will be the root user with full
admin access.</p>
</div>
</div>
</div>
@endif
@if ($errors->any())
<div class="mb-6 p-4 bg-error/10 border border-error rounded-lg">
@foreach ($errors->all() as $error)
<p class="text-sm text-error">{{ $error }}</p>
@endforeach
</div>
@endif
<form action="/register" method="POST" class="flex flex-col gap-4">
@csrf
<x-forms.input id="name" required type="text" name="name" value="{{ $name }}"
label="{{ __('input.name') }}" />
<x-forms.input id="email" required type="email" name="email" value="{{ $email }}"
label="{{ __('input.email') }}" />
<x-forms.input id="password" required type="password" name="password"
label="{{ __('input.password') }}" />
<x-forms.input id="password_confirmation" required type="password" name="password_confirmation"
label="{{ __('input.password.again') }}" />
<div
class="p-4 bg-neutral-50 dark:bg-coolgray-200 rounded-lg border border-neutral-200 dark:border-coolgray-400">
<p class="text-xs dark:text-neutral-400">
Your password should be min 8 characters long and contain at least one uppercase letter,
one lowercase letter, one number, and one symbol.
</p>
</div>
<x-forms.button class="w-full justify-center py-3 box-boarding mt-2" type="submit"
isHighlighted>
Create Account
</x-forms.button>
</form>
@if (!$isFirstUser)
<div class="relative my-6">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-neutral-300 dark:border-coolgray-400"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-gray-50 dark:bg-base text-neutral-500 dark:text-neutral-400">
Already have an account?
</span>
</div>
</div>
<a href="{{ route('login') }}"
class="block w-full text-center py-3 px-4 rounded-lg border border-neutral-300 dark:border-coolgray-400 font-medium hover:border-coollabs dark:hover:border-warning transition-colors">
{{ __('auth.already_registered') }}
</a>
@endif
</div>
</div>
<x-forms.button class="w-full justify-center" type="submit" isHighlighted>
Create account
</x-forms.button>
</form>
</div>
</section>
</x-layout-simple>
@if (! $isFirstUser)
<x-slot:footer>
<span>Already have an account?</span>
<a href="{{ route('login') }}"
class="auth-text-link underline">{{ __('auth.already_registered') }}</a>
</x-slot:footer>
@endif
</x-auth.shell>
</x-layout-simple>
+40 -82
View File
@@ -1,86 +1,44 @@
<x-layout-simple>
<section class="bg-gray-50 dark:bg-base">
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
<div class="w-full max-w-md space-y-8">
<div class="text-center space-y-2">
<h1 class="!text-5xl font-extrabold tracking-tight text-gray-900 dark:text-white">
Coolify
</h1>
<p class="text-lg dark:text-neutral-400">
{{ __('auth.reset_password') }}
</p>
<x-auth.shell title="{{ __('auth.reset_password') }}"
description="Choose a strong new password for your Coolify account.">
<div class="flex flex-col gap-4">
@if (session('status'))
<x-auth.alert type="success">{{ session('status') }}</x-auth.alert>
@endif
@if ($errors->any())
<x-auth.alert type="error">
<div class="flex flex-col gap-1">
@foreach ($errors->all() as $error)
<p>{{ $error }}</p>
@endforeach
</div>
</x-auth.alert>
@endif
<form action="/reset-password" method="POST" class="flex flex-col gap-4">
@csrf
<input hidden id="token" name="token" value="{{ request()->route('token') }}">
<input hidden value="{{ request()->query('email') }}" type="email" name="email" />
<x-forms.input required type="password" id="password" name="password" autocomplete="new-password"
autofocus label="{{ __('input.password') }}" />
<x-forms.input required type="password" id="password_confirmation" name="password_confirmation"
autocomplete="new-password" label="{{ __('input.password.again') }}" />
<div class="auth-guidance">
<x-reicon name="info-circle" class="mt-0.5 size-4 shrink-0" />
<p>Use at least 8 characters with uppercase, lowercase, number, and symbol.</p>
</div>
<div class="space-y-6">
@if (session('status'))
<div class="mb-6 p-4 bg-success/10 border border-success rounded-lg">
<div class="flex gap-3">
<svg class="size-5 text-success flex-shrink-0 mt-0.5" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd" />
</svg>
<p class="text-sm text-success">{{ session('status') }}</p>
</div>
</div>
@endif
@if ($errors->any())
<div class="mb-6 p-4 bg-error/10 border border-error rounded-lg">
@foreach ($errors->all() as $error)
<p class="text-sm text-error">{{ $error }}</p>
@endforeach
</div>
@endif
<div class="mb-6">
<p class="text-sm dark:text-neutral-400">
Enter your new password below. Make sure it's strong and secure.
</p>
</div>
<form action="/reset-password" method="POST" class="flex flex-col gap-4">
@csrf
<input hidden id="token" name="token" value="{{ request()->route('token') }}">
<input hidden value="{{ request()->query('email') }}" type="email" name="email"
label="{{ __('input.email') }}" />
<x-forms.input required type="password" id="password" name="password"
label="{{ __('input.password') }}" />
<x-forms.input required type="password" id="password_confirmation" name="password_confirmation"
label="{{ __('input.password.again') }}" />
<div
class="p-4 bg-neutral-50 dark:bg-coolgray-200 rounded-lg border border-neutral-200 dark:border-coolgray-400">
<p class="text-xs dark:text-neutral-400">
Your password should be min 8 characters long and contain at least one uppercase letter,
one lowercase letter, one number, and one symbol.
</p>
</div>
<x-forms.button class="w-full justify-center py-3 box-boarding mt-2" type="submit"
isHighlighted>
{{ __('auth.reset_password') }}
</x-forms.button>
</form>
<div class="relative my-6">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-neutral-300 dark:border-coolgray-400"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-gray-50 dark:bg-base text-neutral-500 dark:text-neutral-400">
Remember your password?
</span>
</div>
</div>
<a href="/login"
class="block w-full text-center py-3 px-4 rounded-lg border border-neutral-300 dark:border-coolgray-400 font-medium hover:border-coollabs dark:hover:border-warning transition-colors">
Back to Login
</a>
</div>
</div>
<x-forms.button class="w-full justify-center" type="submit" isHighlighted>
{{ __('auth.reset_password') }}
</x-forms.button>
</form>
</div>
</section>
</x-layout-simple>
<x-slot:footer>
<span>Remember your password?</span>
<a href="/login" class="auth-text-link">Back to login</a>
</x-slot:footer>
</x-auth.shell>
</x-layout-simple>
@@ -1,137 +1,96 @@
<x-layout-simple>
<section class="bg-gray-50 dark:bg-base" x-data="{
showRecovery: false,
digits: ['', '', '', '', '', ''],
code: '',
focusNext(event) {
const nextInput = event.target.nextElementSibling;
if (nextInput && nextInput.tagName === 'INPUT') {
nextInput.focus();
}
},
focusPrevious(event) {
if (event.key === 'Backspace' && !event.target.value) {
const prevInput = event.target.previousElementSibling;
if (prevInput && prevInput.tagName === 'INPUT') {
prevInput.focus();
<x-auth.shell title="Coolify" description="Verify your identity to finish signing in.">
<div class="flex flex-col gap-4" x-data="{
showRecovery: false,
digits: ['', '', '', '', '', ''],
code: '',
focusNext(event) {
const nextInput = event.target.nextElementSibling;
if (nextInput?.tagName === 'INPUT') nextInput.focus();
},
focusPrevious(event) {
if (event.key !== 'Backspace' || event.target.value) return;
const previousInput = event.target.previousElementSibling;
if (previousInput?.tagName === 'INPUT') previousInput.focus();
},
updateCode() {
this.code = this.digits.join('');
if (this.code.length === 6) {
this.$nextTick(() => this.$refs.challengeForm.requestSubmit());
}
}
},
updateCode() {
this.code = this.digits.join('');
if (this.digits.every(d => d !== '') && this.digits.length === 6) {
this.$nextTick(() => {
const form = document.querySelector('form[action=\'/two-factor-challenge\']');
if (form) form.submit();
});
}
},
pasteCode(event) {
event.preventDefault();
const paste = (event.clipboardData || window.clipboardData).getData('text');
const pasteDigits = paste.replace(/\D/g, '').slice(0, 6).split('');
const container = event.target.closest('.flex');
const inputs = container.querySelectorAll('input[type=text]');
pasteDigits.forEach((digit, index) => {
if (index < 6 && inputs[index]) {
this.digits[index] = digit;
}
});
this.updateCode();
if (pasteDigits.length > 0 && inputs.length > 0) {
const lastIndex = Math.min(pasteDigits.length - 1, 5);
inputs[lastIndex].focus();
}
}
}">
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
<div class="w-full max-w-md space-y-8">
<div class="text-center space-y-2">
<h1 class="!text-5xl font-extrabold tracking-tight text-gray-900 dark:text-white">
Coolify
</h1>
<p class="text-lg dark:text-neutral-400">
Two-Factor Authentication
</p>
</div>
},
pasteCode(event) {
event.preventDefault();
<div class="space-y-6">
@if (session('status'))
<div class="p-4 bg-success/10 border border-success rounded-lg">
<p class="text-sm text-success">{{ session('status') }}</p>
</div>
@endif
const pastedDigits = event.clipboardData.getData('text').replace(/\D/g, '').slice(0, 6).split('');
const inputs = event.currentTarget.querySelectorAll('input[type=text]');
@if ($errors->any())
<div class="p-4 bg-error/10 border border-error rounded-lg">
@foreach ($errors->all() as $error)
<p class="text-sm text-error">{{ $error }}</p>
@endforeach
</div>
@endif
pastedDigits.forEach((digit, index) => this.digits[index] = digit);
this.updateCode();
inputs[Math.min(pastedDigits.length, 6) - 1]?.focus();
},
}">
@if (session('status'))
<x-auth.alert type="success">{{ session('status') }}</x-auth.alert>
@endif
<div x-show="!showRecovery"
class="p-4 bg-neutral-50 dark:bg-coolgray-200 rounded-lg border border-neutral-200 dark:border-coolgray-400">
<div class="flex gap-3">
<svg class="size-5 flex-shrink-0 mt-0.5 text-coollabs dark:text-warning"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="text-sm dark:text-neutral-400">
Enter the verification code from your authenticator app to continue.
</p>
</div>
@if ($errors->any())
<x-auth.alert type="error">
<div class="flex flex-col gap-1">
@foreach ($errors->all() as $error)
<p>{{ $error }}</p>
@endforeach
</div>
</x-auth.alert>
@endif
<form action="/two-factor-challenge" method="POST" class="flex flex-col gap-4">
@csrf
<div x-show="!showRecovery">
<input type="hidden" name="code" x-model="code">
<div class="flex gap-2 justify-center" @paste="pasteCode($event)">
<template x-for="(digit, index) in digits" :key="index">
<input type="text" inputmode="numeric" maxlength="1" x-model="digits[index]"
@input="if ($event.target.value) { focusNext($event); updateCode(); }"
@keydown="focusPrevious($event)"
class="w-12 h-14 text-center text-2xl font-bold bg-white dark:bg-coolgray-100 border-2 border-neutral-200 dark:border-coolgray-300 rounded-lg focus:border-coollabs dark:focus:border-warning focus:outline-none focus:ring-0 transition-colors"
autocomplete="off" />
</template>
</div>
<button type="button" x-on:click="showRecovery = !showRecovery"
class="mt-4 text-sm dark:text-neutral-400 hover:text-black dark:hover:text-white hover:underline transition-colors cursor-pointer">
Use Recovery Code Instead
</button>
</div>
<div x-show="showRecovery" x-cloak>
<x-forms.input name="recovery_code" label="{{ __('input.recovery_code') }}" />
<button type="button" x-on:click="showRecovery = !showRecovery"
class="mt-2 text-sm dark:text-neutral-400 hover:text-black dark:hover:text-white hover:underline transition-colors cursor-pointer">
Use Authenticator Code Instead
</button>
</div>
<x-forms.button class="w-full justify-center py-3 box-boarding" type="submit" isHighlighted>
{{ __('auth.login') }}
</x-forms.button>
</form>
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-neutral-300 dark:border-coolgray-400"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-gray-50 dark:bg-base text-neutral-500 dark:text-neutral-400">
Need help?
</span>
</div>
</div>
<a href="/login"
class="block w-full text-center py-3 px-4 rounded-lg border border-neutral-300 dark:border-coolgray-400 font-medium hover:border-coollabs dark:hover:border-warning transition-colors">
Back to Login
</a>
</div>
<div class="auth-guidance">
<x-reicon name="info-circle" class="mt-0.5 size-4 shrink-0" />
<p x-show="!showRecovery">Enter the 6-digit code from your authenticator app.</p>
<p x-show="showRecovery" x-cloak>Enter one of the recovery codes you saved when setting up two-factor authentication.</p>
</div>
<form x-ref="challengeForm" action="/two-factor-challenge" method="POST" class="flex flex-col gap-4">
@csrf
<div x-show="!showRecovery" class="flex flex-col gap-3">
<input type="hidden" name="code" x-model="code" :disabled="showRecovery">
<div class="flex justify-center gap-2" aria-label="Two-factor authentication code"
@paste="pasteCode($event)">
<template x-for="(digit, index) in digits" :key="index">
<input type="text" inputmode="numeric" pattern="[0-9]*" maxlength="1"
x-model="digits[index]" :aria-label="`Digit ${index + 1}`"
@input="focusNext($event); updateCode()" @keydown="focusPrevious($event)"
class="h-12 w-11 rounded-md border border-neutral-300 bg-white text-center text-lg font-semibold text-neutral-900 transition-colors focus:border-warning focus:outline-none focus:ring-1 focus:ring-warning dark:border-white/10 dark:bg-coolgray-100 dark:text-white sm:h-14 sm:w-12 sm:text-xl"
autocomplete="one-time-code" />
</template>
</div>
<button type="button" class="auth-text-link self-center"
x-on:click="showRecovery = true; $nextTick(() => $refs.recoveryCode.focus())">
Use a recovery code
</button>
</div>
<div x-show="showRecovery" x-cloak class="flex flex-col gap-3">
<x-forms.input x-ref="recoveryCode" name="recovery_code" autocomplete="one-time-code"
x-bind:disabled="!showRecovery" label="{{ __('input.recovery_code') }}" />
<button type="button" class="auth-text-link self-center"
x-on:click="showRecovery = false; $nextTick(() => $el.closest('form').querySelector('input[type=text]').focus())">
Use an authenticator code
</button>
</div>
<x-forms.button class="w-full justify-center" type="submit" isHighlighted>
Verify and continue
</x-forms.button>
</form>
</div>
</section>
</x-layout-simple>
<x-slot:footer>
<span>Not your account?</span>
<a href="/login" class="auth-text-link">Back to login</a>
</x-slot:footer>
</x-auth.shell>
</x-layout-simple>
+16 -9
View File
@@ -1,12 +1,19 @@
<x-layout>
<section class="flex flex-col h-full lg:items-center lg:justify-center">
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto max-w-7xl lg:py-0">
<h1> Verification Email Sent </h1>
<div class="flex justify-center gap-2 text-center">
<br>To activate your account, please open the email and follow the
instructions.
<x-layout-simple>
<x-auth.shell title="Coolify" description="Verify your email address to activate your account.">
<div class="flex flex-col gap-4">
<div class="auth-guidance">
<x-reicon name="mail" class="mt-0.5 size-4 shrink-0" />
<p>We sent a verification link to your email address. Open it to continue to Coolify.</p>
</div>
<livewire:verify-email />
</div>
</section>
</x-layout>
<x-slot:footer>
<span class="text-center">
<span class="block sm:inline">Didn’t receive the email?</span>
<span class="block sm:ml-1 sm:inline">Check your spam folder or resend it.</span>
</span>
</x-slot:footer>
</x-auth.shell>
</x-layout-simple>
@@ -0,0 +1,286 @@
@props(['application', 'currentRoute', 'flush' => false])
@php
$applicationRouteParameters = [
'project_uuid' => $application->environment->project->uuid,
'environment_uuid' => $application->environment->uuid,
'application_uuid' => $application->uuid,
];
$configurationMenuItems = [
[
'label' => 'General',
'route' => 'project.application.configuration',
'active' => $currentRoute === 'project.application.configuration',
],
[
'label' => 'Domains',
'route' => 'project.application.domains',
'active' => $currentRoute === 'project.application.domains',
],
[
'label' => 'Advanced',
'route' => 'project.application.advanced',
'active' => $currentRoute === 'project.application.advanced',
],
[
'label' => 'Swarm',
'route' => 'project.application.swarm',
'active' => $currentRoute === 'project.application.swarm',
'visible' => $application->destination->server->isSwarm(),
],
[
'label' => 'Environment Variables',
'route' => 'project.application.environment-variables',
'active' => $currentRoute === 'project.application.environment-variables',
],
[
'label' => 'Persistent Storage',
'route' => 'project.application.persistent-storage',
'active' => $currentRoute === 'project.application.persistent-storage',
],
[
'label' => 'Backups',
'route' => 'project.application.backup.index',
'active' => str($currentRoute)->startsWith('project.application.backup'),
],
[
'label' => 'Terminal',
'route' => 'project.application.command',
'active' => $currentRoute === 'project.application.command',
'navigate' => false,
'visible' => ! $application->destination->server->isSwarm() && auth()->user()?->can('canAccessTerminal'),
],
[
'label' => 'Deployment',
'route' => 'project.application.deployment.index',
'active' => str($currentRoute)->startsWith('project.application.deployment'),
],
[
'label' => 'Runtime',
'route' => 'project.application.logs',
'active' => $currentRoute === 'project.application.logs',
],
[
'label' => 'Git Source',
'route' => 'project.application.source',
'active' => $currentRoute === 'project.application.source',
'visible' => $application->git_based(),
],
[
'label' => 'Servers',
'route' => 'project.application.servers',
'active' => $currentRoute === 'project.application.servers',
'badge' => true,
],
[
'label' => 'Scheduled Tasks',
'route' => 'project.application.scheduled-tasks.show',
'active' => str($currentRoute)->startsWith('project.application.scheduled-tasks'),
],
[
'label' => 'Webhooks',
'route' => 'project.application.webhooks',
'active' => $currentRoute === 'project.application.webhooks',
],
[
'label' => 'Preview Deployments',
'route' => 'project.application.preview-deployments',
'active' => $currentRoute === 'project.application.preview-deployments',
'visible' => $application->git_based() || $application->build_pack === 'dockerimage',
],
[
'label' => 'Healthcheck',
'route' => 'project.application.healthcheck',
'active' => $currentRoute === 'project.application.healthcheck',
'visible' => $application->build_pack !== 'dockercompose',
],
[
'label' => 'Rollback',
'route' => 'project.application.rollback',
'active' => $currentRoute === 'project.application.rollback',
],
[
'label' => 'Resource Limits',
'route' => 'project.application.resource-limits',
'active' => $currentRoute === 'project.application.resource-limits',
],
[
'label' => 'Resource Operations',
'route' => 'project.application.resource-operations',
'active' => $currentRoute === 'project.application.resource-operations',
],
[
'label' => 'Metrics',
'route' => 'project.application.metrics',
'active' => $currentRoute === 'project.application.metrics',
],
[
'label' => 'Tags',
'route' => 'project.application.tags',
'active' => $currentRoute === 'project.application.tags',
],
[
'label' => 'Danger Zone',
'route' => 'project.application.danger',
'active' => $currentRoute === 'project.application.danger',
],
];
$configurationMenuItems = array_values(array_filter(
$configurationMenuItems,
fn (array $item): bool => $item['visible'] ?? true,
));
// Icons follow the main sidebar's reicon set
$menuIcons = [
'General' => 'settings',
'Domains' => 'globe',
'Advanced' => 'grid',
'Swarm' => 'destinations',
'Environment Variables' => 'variables',
'Persistent Storage' => 'storages',
'Backups' => 'database',
'Terminal' => 'browser-terminal',
'Deployment' => 'time-back',
'Runtime' => 'unordered-list',
'Git Source' => 'sources',
'Servers' => 'servers',
'Scheduled Tasks' => 'calendar',
'Webhooks' => 'notifications',
'Preview Deployments' => 'eye',
'Healthcheck' => 'feedback',
'Rollback' => 'time-back',
'Resource Limits' => 'cpu',
'Resource Operations' => 'server-update',
'Metrics' => 'graph',
'Tags' => 'tags',
'Danger Zone' => 'shield-alert',
];
// Discord-style groups for the settings sidebar
$menuGroups = [
'Settings' => ['General', 'Domains', 'Advanced', 'Swarm', 'Environment Variables', 'Persistent Storage', 'Backups'],
'Build & deploy' => ['Git Source', 'Servers', 'Healthcheck'],
'Automation' => ['Scheduled Tasks', 'Webhooks', 'Preview Deployments'],
'Logs' => ['Deployment', 'Runtime'],
'Operations' => ['Terminal', 'Rollback', 'Resource Limits', 'Resource Operations', 'Metrics', 'Tags', 'Danger Zone'],
];
$groupedMenuItems = collect($menuGroups)
->map(fn (array $labels) => collect($configurationMenuItems)->whereIn('label', $labels)->values())
->filter(fn ($items) => $items->isNotEmpty());
// In-page sections (cards) shown as sub-items under the active page
$isComposeApp = $application->build_pack === 'dockercompose';
$pageSections = [
'project.application.configuration' => array_values(array_filter([
['id' => 'application-details-section', 'label' => 'Application details'],
['id' => 'access-section', 'label' => 'Access'],
['id' => 'build-pipeline-section', 'label' => 'Build pipeline'],
$isComposeApp ? null : ['id' => 'container-image-section', 'label' => 'Container image'],
$isComposeApp ? null : ['id' => 'networking-section', 'label' => 'Networking'],
$isComposeApp ? null : ['id' => 'runtime-section', 'label' => 'Runtime'],
$isComposeApp ? null : ['id' => 'security-section', 'label' => 'Security'],
['id' => 'deployment-lifecycle-section', 'label' => 'Deployment lifecycle'],
$isComposeApp ? null : ['id' => 'container-labels-section', 'label' => 'Container labels'],
])),
'project.application.advanced' => array_values(array_filter([
['id' => 'advanced-build-section', 'label' => 'Build'],
['id' => 'advanced-container-section', 'label' => 'Container'],
$application->git_based() ? ['id' => 'advanced-deployment-section', 'label' => 'Deployment'] : null,
$application->git_based() ? ['id' => 'advanced-git-section', 'label' => 'Git'] : null,
$isComposeApp ? ['id' => 'advanced-compose-section', 'label' => 'Docker compose'] : null,
['id' => 'advanced-proxy-section', 'label' => 'Proxy'],
['id' => 'advanced-operations-section', 'label' => 'Operations'],
['id' => 'advanced-logs-section', 'label' => 'Logs'],
$isComposeApp ? null : ['id' => 'advanced-gpu-section', 'label' => 'GPU'],
])),
'project.application.webhooks' => [
['id' => 'deploy-webhook-section', 'label' => 'Deploy webhook'],
['id' => 'manual-git-webhooks-section', 'label' => 'Manual Git webhooks'],
],
'project.application.preview-deployments' => array_values(array_filter([
['id' => 'preview-template-section', 'label' => 'URL template'],
$application->is_github_based()
? ['id' => 'preview-pull-requests-section', 'label' => 'Pull requests']
: null,
$application->build_pack === 'dockerimage'
? ['id' => 'manual-preview-section', 'label' => 'Manual preview']
: null,
['id' => 'preview-deployments-section', 'label' => 'Deployments'],
])),
'project.application.healthcheck' => [
['id' => 'healthcheck-configuration-section', 'label' => 'Configuration'],
[
'id' => $application->health_check_type === 'cmd'
? 'healthcheck-command-section'
: 'healthcheck-request-section',
'label' => $application->health_check_type === 'cmd' ? 'Command' : 'HTTP request',
],
['id' => 'healthcheck-timing-section', 'label' => 'Timing and retries'],
],
'project.application.rollback' => [
['id' => 'rollback-retention-section', 'label' => 'Image retention'],
['id' => 'rollback-images-section', 'label' => 'Available images'],
],
'project.application.resource-limits' => [
['id' => 'cpu-limits-section', 'label' => 'CPU'],
['id' => 'memory-limits-section', 'label' => 'Memory'],
],
'project.application.resource-operations' => [
['id' => 'clone-destination-section', 'label' => 'Clone destination'],
['id' => 'clone-environment-section', 'label' => 'Clone environment'],
['id' => 'move-resource-section', 'label' => 'Move resource'],
],
];
@endphp
<aside @class([
'application-settings-navigation min-w-0 xl:self-start',
'is-flush' => $flush,
])>
<nav aria-label="Configuration sections"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@foreach ($groupedMenuItems as $groupLabel => $groupItems)
@unless ($loop->first)
<div class="hidden xl:block my-2 border-t border-neutral-200 dark:border-white/[0.06]" aria-hidden="true"></div>
@endunless
<div class="nav-section hidden xl:block">{{ $groupLabel }}</div>
@foreach ($groupItems as $menuItem)
<a wire:key="application-settings-link-{{ str($menuItem['label'])->slug() }}"
@class([
'menu-item',
'menu-item-active' => $menuItem['active'],
])
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $applicationRouteParameters) }}">
<x-reicon :name="$menuIcons[$menuItem['label']] ?? 'settings'" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
@if ($menuItem['badge'] ?? false)
<span class="shrink-0">
<livewire:project.application.server-status-badge :application="$application" />
</span>
@endif
</a>
@if ($menuItem['active'] && count($pageSections[$menuItem['route']] ?? []) >= 4)
<div class="nav-children hidden flex-col gap-0.5 py-1 xl:flex"
x-data="{
activeSection: '',
scrollToSection(id) {
this.activeSection = id;
window.scrollToSettingsSection?.(id);
},
}">
@foreach ($pageSections[$menuItem['route']] as $section)
<button type="button" class="menu-subitem"
:class="activeSection === '{{ $section['id'] }}' && 'menu-subitem-active'"
@click="scrollToSection('{{ $section['id'] }}')">
<span class="menu-item-label text-left">{{ $section['label'] }}</span>
</button>
@endforeach
</div>
@endif
@endforeach
@endforeach
</nav>
</aside>
@@ -0,0 +1,32 @@
@props([
'title',
'description' => null,
'helper' => null,
'flush' => false,
])
<section {{ $attributes->merge(['class' => 'application-settings-section']) }}>
<header>
<div class="min-w-0 py-0.5">
@if (filled($description ?? $helper))
<h3>
<x-helper :helper="$description ?? $helper" :label="'More information about '.$title">
<x-slot:trigger>
<span class="underline underline-offset-4">{{ $title }}</span>
</x-slot:trigger>
</x-helper>
</h3>
@else
<h3>{{ $title }}</h3>
@endif
</div>
@isset($actions)
<div class="flex min-w-0 max-w-full flex-wrap items-center gap-2">
{{ $actions }}
</div>
@endisset
</header>
<div class="application-settings-section-body {{ $flush ? 'is-flush' : '' }}">
{{ $slot }}
</div>
</section>
@@ -1,86 +1,26 @@
<x-dropdown>
<x-slot:title>
<div class="relative" x-data="{ open: false }" @click.outside="open = false"
@keydown.escape.window="open = false">
<button type="button" class="button" @click="open = !open" :aria-expanded="open"
aria-haspopup="menu">
<x-reicon name="grid" class="size-3.5 opacity-70" />
Advanced
</x-slot>
@if ($application->status === 'running')
<x-reicon name="chevron-down" class="size-3 opacity-55" />
</button>
<div x-cloak x-show="open" x-transition.origin.top.right
class="listbox-panel top-full! right-0! left-auto! mt-1! w-60! min-w-0!" role="menu">
@can('deploy', $application)
<x-modal-confirmation title="Confirm Application Force Deployment?" buttonTitle="Force deploy"
submitAction="force_deploy_without_cache" :actions="[
'This application will be force deployed without build cache.',
]" :confirmWithText="false" :confirmWithPassword="false"
step2ButtonText="Confirm">
<x-slot:content>
<div class="dropdown-iteme">
<svg 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="M12.983 8.978c3.955 -.182 7.017 -1.446 7.017 -2.978c0 -1.657 -3.582 -3 -8 -3c-1.661 0 -3.204 .19 -4.483 .515m-2.783 1.228c-.471 .382 -.734 .808 -.734 1.257c0 1.22 1.944 2.271 4.734 2.74" />
<path
d="M4 6v6c0 1.657 3.582 3 8 3c.986 0 1.93 -.067 2.802 -.19m3.187 -.82c1.251 -.53 2.011 -1.228 2.011 -1.99v-6" />
<path d="M4 12v6c0 1.657 3.582 3 8 3c3.217 0 5.991 -.712 7.261 -1.74m.739 -3.26v-4" />
<path d="M3 3l18 18" />
</svg>
Force deploy (without
cache)
</div>
</x-slot:content>
</x-modal-confirmation>
<button type="button" class="listbox-option justify-start! gap-2.5!"
wire:click="{{ $application->status === 'running' ? 'force_deploy_without_cache' : 'deploy(true)' }}"
@click="open = false" role="menuitem">
<x-reicon name="refresh" class="size-3.5 opacity-70" />
Force deploy without cache
</button>
@else
<div class="dropdown-iteme" data-disabled>
<svg 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="M12.983 8.978c3.955 -.182 7.017 -1.446 7.017 -2.978c0 -1.657 -3.582 -3 -8 -3c-1.661 0 -3.204 .19 -4.483 .515m-2.783 1.228c-.471 .382 -.734 .808 -.734 1.257c0 1.22 1.944 2.271 4.734 2.74" />
<path
d="M4 6v6c0 1.657 3.582 3 8 3c.986 0 1.93 -.067 2.802 -.19m3.187 -.82c1.251 -.53 2.011 -1.228 2.011 -1.99v-6" />
<path d="M4 12v6c0 1.657 3.582 3 8 3c3.217 0 5.991 -.712 7.261 -1.74m.739 -3.26v-4" />
<path d="M3 3l18 18" />
</svg>
Force deploy (without
cache)
</div>
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled>
<x-reicon name="refresh" class="size-3.5 opacity-70" />
Force deploy without cache
</button>
@endcan
@else
@can('deploy', $application)
<x-modal-confirmation title="Confirm Application Force Deployment?" buttonTitle="Force deploy"
submitAction="deploy(true)" :actions="[
'This application will be force deployed without build cache.',
]" :confirmWithText="false" :confirmWithPassword="false"
step2ButtonText="Confirm">
<x-slot:content>
<div class="dropdown-item">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" 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="M12.983 8.978c3.955 -.182 7.017 -1.446 7.017 -2.978c0 -1.657 -3.582 -3 -8 -3c-1.661 0 -3.204 .19 -4.483 .515m-2.783 1.228c-.471 .382 -.734 .808 -.734 1.257c0 1.22 1.944 2.271 4.734 2.74" />
<path
d="M4 6v6c0 1.657 3.582 3 8 3c.986 0 1.93 -.067 2.802 -.19m3.187 -.82c1.251 -.53 2.011 -1.228 2.011 -1.99v-6" />
<path d="M4 12v6c0 1.657 3.582 3 8 3c3.217 0 5.991 -.712 7.261 -1.74m.739 -3.26v-4" />
<path d="M3 3l18 18" />
</svg>
Force deploy (without
cache)
</div>
</x-slot:content>
</x-modal-confirmation>
@else
<div class="dropdown-item" data-disabled>
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" 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="M12.983 8.978c3.955 -.182 7.017 -1.446 7.017 -2.978c0 -1.657 -3.582 -3 -8 -3c-1.661 0 -3.204 .19 -4.483 .515m-2.783 1.228c-.471 .382 -.734 .808 -.734 1.257c0 1.22 1.944 2.271 4.734 2.74" />
<path
d="M4 6v6c0 1.657 3.582 3 8 3c.986 0 1.93 -.067 2.802 -.19m3.187 -.82c1.251 -.53 2.011 -1.228 2.011 -1.99v-6" />
<path d="M4 12v6c0 1.657 3.582 3 8 3c3.217 0 5.991 -.712 7.261 -1.74m.739 -3.26v-4" />
<path d="M3 3l18 18" />
</svg>
Force deploy (without
cache)
</div>
@endcan
@endif
</x-dropdown>
</div>
</div>
@@ -1,26 +1,55 @@
<x-dropdown>
<x-slot:title>
Links
</x-slot>
@if (
@props(['application', 'fullWidth' => false])
@php
$hasLinks =
(data_get($application, 'fqdn') ||
collect(json_decode($this->application->docker_compose_domains))->contains(fn($fqdn) => !empty(data_get($fqdn, 'domain'))) ||
collect(json_decode($application->docker_compose_domains))->contains(
fn ($fqdn) => !empty(data_get($fqdn, 'domain')),
) ||
data_get($application, 'previews', collect([]))->count() > 0 ||
data_get($application, 'ports_mappings_array')) &&
data_get($application, 'settings.is_raw_compose_deployment_enabled') !== true)
<div class="flex flex-col gap-1">
data_get($application, 'settings.is_raw_compose_deployment_enabled') !== true;
$linkItemClasses = 'listbox-option justify-start! gap-2.5!';
@endphp
<div @class(['relative', 'w-full' => $fullWidth]) x-data="{ open: false }" @keydown.escape.window="open = false">
<button type="button" @click="open = !open" @click.outside="open = false" title="Open application links"
@class([
'app-tab shrink-0 gap-1' => !$fullWidth,
'button w-full justify-between' => $fullWidth,
])>
<span class="inline-flex items-center gap-2">
<x-reicon name="external-link" class="size-3.5 shrink-0 opacity-70" />
Links
</span>
<span class="inline-flex transition-transform" :class="open && 'rotate-180'">
<x-reicon name="chevron-down" class="size-3 opacity-55" />
</span>
</button>
<div x-show="open" x-cloak x-transition.origin.top.right role="menu"
@class([
'listbox-panel top-full! mt-1!',
'left-0! right-0! w-full! min-w-0! max-w-none!' => $fullWidth,
'right-0! left-auto! min-w-60! max-w-96!' => !$fullWidth,
])>
@if ($hasLinks)
@if (data_get($application, 'gitBrancLocation'))
<a target="_blank" class="dropdown-item" href="{{ $application->gitBranchLocation }}">
<a target="_blank" class="{{ $linkItemClasses }}" href="{{ $application->gitBranchLocation }}">
<x-git-icon git="{{ $application->source?->getMorphClass() }}" />
Git Repository
<span class="min-w-0 truncate">Git Repository</span>
</a>
@endif
@if (data_get($application, 'build_pack') === 'dockercompose')
@foreach (collect(json_decode($this->application->docker_compose_domains)) as $fqdn)
@foreach (collect(json_decode($application->docker_compose_domains)) as $fqdn)
@if (data_get($fqdn, 'domain'))
@foreach (explode(',', data_get($fqdn, 'domain')) as $domain)
<a class="dropdown-item" target="_blank" href="{{ getFqdnWithoutPort($domain) }}">
<x-external-link class="size-4" />{{ getFqdnWithoutPort($domain) }}
<a class="{{ $linkItemClasses }}" target="_blank" href="{{ getFqdnWithoutPort($domain) }}">
<span
class="shrink-0 rounded-md bg-success/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-success ring-1 ring-success/20">
Production
</span>
<span class="min-w-0 truncate">{{ getFqdnWithoutPort($domain) }}</span>
</a>
@endforeach
@endif
@@ -28,8 +57,12 @@
@endif
@if (data_get($application, 'fqdn'))
@foreach (str(data_get($application, 'fqdn'))->explode(',') as $fqdn)
<a class="dropdown-item" target="_blank" href="{{ getFqdnWithoutPort($fqdn) }}">
<x-external-link class="size-4" />{{ getFqdnWithoutPort($fqdn) }}
<a class="{{ $linkItemClasses }}" target="_blank" href="{{ getFqdnWithoutPort($fqdn) }}">
<span
class="shrink-0 rounded-md bg-success/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-success ring-1 ring-success/20">
Production
</span>
<span class="min-w-0 truncate">{{ getFqdnWithoutPort($fqdn) }}</span>
</a>
@endforeach
@endif
@@ -39,10 +72,13 @@
@foreach (collect(json_decode($preview->docker_compose_domains)) as $fqdn)
@if (data_get($fqdn, 'domain'))
@foreach (explode(',', data_get($fqdn, 'domain')) as $domain)
<a class="dropdown-item" target="_blank" href="{{ getFqdnWithoutPort($domain) }}">
<x-external-link class="size-4" />PR{{ data_get($preview, 'pull_request_id') }}
|
{{ getFqdnWithoutPort($domain) }}
<a class="{{ $linkItemClasses }}" target="_blank"
href="{{ getFqdnWithoutPort($domain) }}">
<span
class="shrink-0 rounded-md bg-coollabs/10 px-1.5 py-0.5 text-[10px] font-semibold text-coollabs ring-1 ring-coollabs/20 dark:bg-warning/10 dark:text-warning dark:ring-warning/20">
PR #{{ data_get($preview, 'pull_request_id') }}
</span>
<span class="min-w-0 truncate">{{ getFqdnWithoutPort($domain) }}</span>
</a>
@endforeach
@endif
@@ -51,11 +87,13 @@
@else
@foreach (data_get($application, 'previews') as $preview)
@if (data_get($preview, 'fqdn'))
<a class="dropdown-item" target="_blank"
<a class="{{ $linkItemClasses }}" target="_blank"
href="{{ getFqdnWithoutPort(data_get($preview, 'fqdn')) }}">
<x-external-link class="size-4" />
PR{{ data_get($preview, 'pull_request_id') }} |
{{ data_get($preview, 'fqdn') }}
<span
class="shrink-0 rounded-md bg-coollabs/10 px-1.5 py-0.5 text-[10px] font-semibold text-coollabs ring-1 ring-coollabs/20 dark:bg-warning/10 dark:text-warning dark:ring-warning/20">
PR #{{ data_get($preview, 'pull_request_id') }}
</span>
<span class="min-w-0 truncate">{{ getFqdnWithoutPort(data_get($preview, 'fqdn')) }}</span>
</a>
@endif
@endforeach
@@ -64,30 +102,28 @@
@if (data_get($application, 'ports_mappings_array'))
@foreach ($application->ports_mappings_array as $port)
@if ($application->destination->server->id === 0)
<a class="dropdown-item" target="_blank" href="http://localhost:{{ explode(':', $port)[0] }}">
<x-external-link class="size-4" />
Port {{ $port }}
<a class="{{ $linkItemClasses }}" target="_blank"
href="http://localhost:{{ explode(':', $port)[0] }}">
<span class="min-w-0 truncate">Port {{ $port }}</span>
</a>
@else
<a class="dropdown-item" target="_blank"
<a class="{{ $linkItemClasses }}" target="_blank"
href="http://{{ $application->destination->server->ip }}:{{ explode(':', $port)[0] }}">
<x-external-link class="size-4" />
{{ $application->destination->server->ip }}:{{ explode(':', $port)[0] }}
<span class="min-w-0 truncate">{{ $application->destination->server->ip }}:{{ explode(':', $port)[0] }}</span>
</a>
@if (count($application->additional_servers) > 0)
@foreach ($application->additional_servers as $server)
<a class="dropdown-item" target="_blank"
<a class="{{ $linkItemClasses }}" target="_blank"
href="http://{{ $server->ip }}:{{ explode(':', $port)[0] }}">
<x-external-link class="size-4" />
{{ $server->ip }}:{{ explode(':', $port)[0] }}
<span class="min-w-0 truncate">{{ $server->ip }}:{{ explode(':', $port)[0] }}</span>
</a>
@endforeach
@endif
@endif
@endforeach
@endif
</div>
@else
<div class="px-2 py-1.5 text-xs">No links available</div>
@endif
</x-dropdown>
@else
<div class="listbox-option justify-start! cursor-default!">No links available</div>
@endif
</div>
</div>
@@ -0,0 +1,26 @@
@props([
'type' => 'info',
])
@php
$styles = match ($type) {
'success' => 'border-success/35 bg-success/10 text-success',
'error' => 'border-error/35 bg-error/10 text-error',
'warning' => 'border-warning/35 bg-warning/10 text-warning',
default => 'border-neutral-300 bg-neutral-100 text-neutral-700 dark:border-white/10 dark:bg-white/[0.04] dark:text-fg-dim',
};
$icon = match ($type) {
'success' => 'check-circle',
'error' => 'alert-circle',
'warning' => 'alert-triangle',
default => 'info-circle',
};
@endphp
<div {{ $attributes->merge(['class' => "flex items-start gap-3 rounded-lg border px-3 py-2.5 text-sm {$styles}"]) }}>
<x-reicon :name="$icon" class="mt-0.5 size-4 shrink-0" />
<div class="min-w-0 flex-1 leading-5">
{{ $slot }}
</div>
</div>
@@ -0,0 +1,27 @@
@props([
'title',
'description' => null,
])
<section class="auth-shell application-settings-form">
<div class="auth-shell-content">
<div class="auth-card">
<div class="auth-card-heading">
<h1>{{ $title }}</h1>
@if ($description)
<p>{{ $description }}</p>
@endif
</div>
<div class="auth-card-body">
{{ $slot }}
</div>
@isset($footer)
<footer class="auth-card-footer">
{{ $footer }}
</footer>
@endisset
</div>
</div>
</section>
@@ -0,0 +1,76 @@
@props([
'context',
'parameters',
'section',
])
@php
$routes = match ($context) {
'application' => [
'back' => 'project.application.backup.index',
'general' => 'project.application.backup.show',
's3' => 'project.application.backup.s3',
'retention' => 'project.application.backup.retention',
'executions' => 'project.application.backup.executions',
'danger' => 'project.application.backup.danger',
],
'service' => [
'back' => 'project.service.database.backups',
'general' => 'project.service.database.backup.show',
's3' => 'project.service.database.backup.s3',
'retention' => 'project.service.database.backup.retention',
'executions' => 'project.service.database.backup.executions',
'danger' => 'project.service.database.backup.danger',
],
'service-volume' => [
'back' => 'project.service.volume-backups.index',
'general' => 'project.service.volume-backups.show',
's3' => 'project.service.volume-backups.s3',
'retention' => 'project.service.volume-backups.retention',
'executions' => 'project.service.volume-backups.executions',
'danger' => 'project.service.volume-backups.danger',
],
default => [
'back' => 'project.database.configuration',
'general' => 'project.database.backup.execution',
's3' => 'project.database.backup.s3',
'retention' => 'project.database.backup.retention',
'executions' => 'project.database.backup.executions',
'danger' => 'project.database.backup.danger',
],
};
$items = [
['key' => 'general', 'label' => 'General', 'icon' => 'settings'],
['key' => 's3', 'label' => 'S3 storage', 'icon' => 'storages'],
['key' => 'retention', 'label' => 'Retention', 'icon' => 'unordered-list'],
['key' => 'executions', 'label' => 'Executions', 'icon' => 'browser-terminal'],
['key' => 'danger', 'label' => 'Danger Zone', 'icon' => 'shield-alert'],
];
$backLabel = $context === 'database' ? 'Back to database' : 'Back to backups';
$backParameters = $context === 'database'
? collect($parameters)->except('backup_uuid')->all()
: $parameters;
@endphp
<aside class="application-settings-navigation min-w-0 xl:self-start">
<nav aria-label="Backup settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
<div class="nav-section hidden xl:block">Backup</div>
<a class="menu-item" {{ wireNavigate() }} href="{{ route($routes['back'], $backParameters) }}">
<x-reicon name="logout" class="menu-item-icon rotate-180" />
<span class="menu-item-label">{{ $backLabel }}</span>
</a>
@foreach ($items as $item)
<a @class([
'menu-item',
'menu-item-active' => $section === $item['key'],
])
{{ wireNavigate() }} href="{{ route($routes[$item['key']], $parameters) }}">
<x-reicon :name="$item['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $item['label'] }}</span>
</a>
@endforeach
</nav>
</aside>
@@ -1,46 +1,35 @@
@props(['currentStep' => 1, 'totalSteps' => 3])
<div class="w-full max-w-2xl mx-auto mb-8">
<div class="flex items-center justify-between">
<div class="mx-auto mb-6 w-full max-w-3xl">
<div
class="grid grid-cols-3 overflow-hidden rounded-[10px] border border-neutral-200 bg-white dark:border-white/[0.08] dark:bg-white/[0.025]">
@for ($i = 1; $i <= $totalSteps; $i++)
<div class="flex items-center {{ $i < $totalSteps ? 'flex-1' : '' }}">
<div class="flex flex-col items-center">
<div
class="flex items-center justify-center size-10 rounded-full border-2 transition-all duration-300
{{ $i < $currentStep ? 'bg-success border-success' : 'border-neutral-200 dark:border-coolgray-300' }}
{{ $i === $currentStep ? 'bg-white dark:bg-coolgray-500' : '' }}
">
@if ($i < $currentStep)
<svg class="size-5 text-white" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"
fill="currentColor">
<path fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd" />
</svg>
@else
<span
class="text-sm font-bold text-black dark:text-white">
{{ $i }}
</span>
@endif
</div>
<span
class="mt-2 text-xs font-medium text-black dark:text-white">
@if ($i === 1)
Server
@elseif ($i === 2)
Connection
@elseif ($i === 3)
Complete
@endif
</span>
</div>
@if ($i < $totalSteps)
<div
class="flex-1 h-0.5 mx-4 transition-all duration-300
{{ $i < $currentStep ? 'bg-success' : 'bg-neutral-200 dark:bg-coolgray-300' }}">
</div>
@endif
<div @class([
'flex min-h-10 items-center justify-center gap-2 border-r border-neutral-200 px-3 text-[11px] font-medium last:border-r-0 dark:border-white/[0.08]',
'bg-coollabs/[0.07] text-coollabs dark:bg-warning/[0.09] dark:text-warning' => $i === $currentStep,
'text-neutral-500 dark:text-fg-dim' => $i !== $currentStep,
])>
<span @class([
'flex size-5 items-center justify-center rounded-full border text-[10px] font-semibold',
'border-coollabs/25 bg-coollabs/10 dark:border-warning/25 dark:bg-warning/15' => $i === $currentStep,
'border-emerald-500/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' => $i < $currentStep,
'border-neutral-200 dark:border-white/[0.1]' => $i > $currentStep,
])>
@if ($i < $currentStep)
<x-reicon name="check-circle" class="size-3" />
@else
{{ $i }}
@endif
</span>
<span class="hidden sm:inline">
@if ($i === 1)
Server
@elseif ($i === 2)
Connection
@else
Complete
@endif
</span>
</div>
@endfor
</div>
@@ -1,13 +1,15 @@
<div class="w-full max-w-4xl">
<div class=" rounded-lg shadow-sm border border-neutral-200 dark:border-coolgray-300 overflow-hidden">
<div class="p-8 lg:p-12">
<h1 class="text-3xl font-bold lg:text-4xl mb-4">{{ $title }}</h1>
@isset($question)
<div class="text-base lg:text-lg dark:text-neutral-400 mb-8">
{{ $question }}
</div>
@endisset
<div class="application-settings-form w-full max-w-3xl">
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
<h2>{{ $title }}</h2>
@isset($question)
<p>{{ $question }}</p>
@endisset
</div>
</div>
<div class="application-settings-section-body">
@if ($actions)
<div class="flex flex-col gap-4">
{{ $actions }}
@@ -16,14 +18,15 @@
</div>
@isset($explanation)
<div class=" border-t border-neutral-200 dark:border-coolgray-300 p-8 lg:p-12 ">
<h3 class="text-sm font-bold uppercase tracking-wide mb-4 dark:text-neutral-400">
Technical Details
</h3>
<div class="space-y-3 text-sm dark:text-neutral-400">
<div
class="border-t border-neutral-200 bg-neutral-50 px-4 py-3 dark:border-white/[0.08] dark:bg-white/[0.02]">
<p class="mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-neutral-400 dark:text-fg-faint">
Technical details
</p>
<div class="space-y-2 text-[12px] leading-5 text-neutral-500 dark:text-fg-dim">
{{ $explanation }}
</div>
</div>
@endisset
</div>
</section>
</div>
@@ -0,0 +1,31 @@
@props(['label', 'items', 'title'])
<span class="shrink-0 px-0.5 text-neutral-300 dark:text-fg-faint">/</span>
<div class="relative min-w-0 shrink" x-data="{ open: false }" @keydown.escape.window="open = false">
<div class="flex h-8 min-w-0 items-center gap-1">
<button type="button" @click="open = !open" @click.outside="open = false" title="Switch resource"
class="flex h-8 min-w-0 items-center gap-1.5 rounded-md px-2 opacity-70 transition-[background-color,opacity] hover:bg-neutral-100 hover:opacity-100 dark:hover:bg-white/[0.05]">
<span class="min-w-0 truncate font-semibold text-black dark:text-fg">{{ $label }}</span>
<svg class="size-4 shrink-0 text-neutral-400 dark:text-fg-faint" viewBox="0 0 24 24" fill="none">
<path d="M8 9l4-4 4 4M8 15l4 4 4-4" stroke="currentColor" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
@isset($meta)
{{ $meta }}
@endisset
</div>
<div x-show="open" x-cloak x-transition.opacity.duration.120ms
class="listbox-panel scrollbar left-0! z-[90]! max-h-80! min-w-56 max-w-72">
<div class="px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-neutral-400 dark:text-fg-faint">
{{ $title }}
</div>
@foreach ($items as $item)
<a href="{{ $item['href'] }}" {{ wireNavigate() }} @click="open = false"
class="listbox-option {{ $item['active'] ? 'bg-neutral-100 font-medium text-black dark:bg-white/[0.07] dark:text-fg' : '' }}">
<span class="min-w-0 flex-1 truncate">{{ $item['label'] }}</span>
</a>
@endforeach
</div>
</div>
+42 -51
View File
@@ -1,69 +1,60 @@
@props(['type' => 'warning', 'title' => 'Warning', 'class' => '', 'dismissible' => false, 'onDismiss' => null])
@props([
'type' => 'warning',
'title' => 'Warning',
'class' => '',
'dismissible' => false,
'onDismiss' => null,
])
@php
$icons = [
'warning' => '<svg class="w-5 h-5 text-warning-600 dark:text-warning-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path></svg>',
'danger' => '<svg class="w-5 h-5 text-red-600 dark:text-red-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path></svg>',
'info' => '<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path></svg>',
'success' => '<svg class="w-5 h-5 text-green-600 dark:text-green-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path></svg>'
];
$colors = [
$styles = [
'warning' => [
'bg' => 'bg-warning-50 dark:bg-warning-900/30',
'border' => 'border-warning-300 dark:border-warning-800',
'title' => 'text-warning-800 dark:text-warning-300',
'text' => 'text-warning-700 dark:text-warning-200'
'icon' => 'alert-triangle',
'shell' => 'border-warning/25 bg-warning/[0.07] dark:border-warning/20 dark:bg-warning/[0.06]',
'iconClass' => 'text-warning-700 dark:text-warning',
'titleClass' => 'text-warning-900 dark:text-warning',
'textClass' => 'text-warning-900/75 dark:text-warning/70',
],
'danger' => [
'bg' => 'bg-red-50 dark:bg-red-900/30',
'border' => 'border-red-300 dark:border-red-800',
'title' => 'text-red-800 dark:text-red-300',
'text' => 'text-red-700 dark:text-red-200'
'icon' => 'alert-circle',
'shell' => 'border-red-300/60 bg-red-50 dark:border-red-500/20 dark:bg-red-500/[0.07]',
'iconClass' => 'text-red-600 dark:text-red-400',
'titleClass' => 'text-red-800 dark:text-red-300',
'textClass' => 'text-red-700/80 dark:text-red-300/75',
],
'info' => [
'bg' => 'bg-blue-50 dark:bg-blue-900/30',
'border' => 'border-blue-300 dark:border-blue-800',
'title' => 'text-blue-800 dark:text-blue-300',
'text' => 'text-blue-700 dark:text-blue-200'
'icon' => 'info-circle',
'shell' => 'border-coollabs/20 bg-coollabs/[0.055] dark:border-warning/15 dark:bg-warning/[0.045]',
'iconClass' => 'text-coollabs dark:text-warning',
'titleClass' => 'text-coollabs dark:text-warning',
'textClass' => 'text-neutral-600 dark:text-fg-dim',
],
'success' => [
'bg' => 'bg-green-50 dark:bg-green-900/30',
'border' => 'border-green-300 dark:border-green-800',
'title' => 'text-green-800 dark:text-green-300',
'text' => 'text-green-700 dark:text-green-200'
]
'icon' => 'check-circle',
'shell' => 'border-emerald-300/60 bg-emerald-50 dark:border-emerald-500/20 dark:bg-emerald-500/[0.07]',
'iconClass' => 'text-emerald-600 dark:text-emerald-400',
'titleClass' => 'text-emerald-800 dark:text-emerald-300',
'textClass' => 'text-emerald-700/80 dark:text-emerald-300/75',
],
];
$colorScheme = $colors[$type] ?? $colors['warning'];
$icon = $icons[$type] ?? $icons['warning'];
$style = $styles[$type] ?? $styles['warning'];
@endphp
<div {{ $attributes->merge(['class' => 'relative p-4 border rounded-lg ' . $colorScheme['bg'] . ' ' . $colorScheme['border'] . ' ' . $class]) }}>
<div class="flex items-start">
<div class="flex-shrink-0">
{!! $icon !!}
<div
{{ $attributes->merge(['class' => 'relative rounded-lg border px-3 py-2.5 ' . $style['shell'] . ' ' . $class]) }}>
<div class="flex items-start gap-2.5">
<x-reicon :name="$style['icon']" class="mt-0.5 size-4 shrink-0 {{ $style['iconClass'] }}" />
<div class="min-w-0 flex-1 {{ $dismissible ? 'pr-7' : '' }}">
<div class="text-[12px] font-semibold {{ $style['titleClass'] }}">{{ $title }}</div>
<div class="mt-0.5 text-[12px] leading-5 {{ $style['textClass'] }}">{{ $slot }}</div>
</div>
<div class="ml-3 {{ $dismissible ? 'pr-8' : '' }}">
<div class="text-base font-bold {{ $colorScheme['title'] }}">
{{ $title }}
</div>
<div class="mt-2 text-sm {{ $colorScheme['text'] }}">
{{ $slot }}
</div>
</div>
@if($dismissible && $onDismiss)
@if ($dismissible && $onDismiss)
<button type="button" @click.stop="{{ $onDismiss }}"
class="absolute top-2 right-2 p-1 rounded hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
aria-label="Dismiss">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" class="w-4 h-4 {{ $colorScheme['text'] }}">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
class="absolute top-1.5 right-1.5 flex size-7 items-center justify-center rounded-md transition-colors hover:bg-black/[0.05] dark:hover:bg-white/[0.06]"
aria-label="Dismiss">
<x-reicon name="x" class="size-3.5 {{ $style['iconClass'] }}" />
</button>
@endif
</div>
</div>
</div>
@@ -0,0 +1,51 @@
@props([
'title',
'description' => null,
'status' => 'idle', // idle | pending | running | success | error
'icon' => null,
])
@php
$statusClasses = match ($status) {
'success' => 'border-emerald-500/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
'error' => 'border-red-500/25 bg-red-500/10 text-red-600 dark:text-red-400',
'running' => 'border-coollabs/25 bg-coollabs/10 text-coollabs dark:border-warning/25 dark:bg-warning/15 dark:text-warning',
'pending' => 'border-neutral-200 text-neutral-400 dark:border-white/[0.1] dark:text-fg-faint',
default => 'border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.035] dark:text-fg-dim',
};
@endphp
<div {{ $attributes->merge(['class' => 'flex min-h-14 items-center gap-3 px-4 py-3']) }}>
<span
class="flex size-8 shrink-0 items-center justify-center rounded-lg border {{ $statusClasses }}">
@if ($status === 'success')
<x-reicon name="check-circle" class="size-4" />
@elseif ($status === 'error')
<x-reicon name="alert-circle" class="size-4" />
@elseif ($status === 'running')
<svg class="size-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4">
</circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
@elseif ($icon)
<x-reicon :name="$icon" class="size-4" />
@else
<span class="size-1.5 rounded-full bg-current opacity-50"></span>
@endif
</span>
<span class="min-w-0">
<span class="block text-[13px] font-semibold">{{ $title }}</span>
@if ($description)
<span class="mt-0.5 block text-[11px] text-neutral-500 dark:text-fg-faint">{{ $description }}</span>
@endif
@if ($slot->isNotEmpty())
<div class="mt-0.5 text-[11px] text-neutral-500 dark:text-fg-faint">
{{ $slot }}
</div>
@endif
</span>
</div>
@@ -0,0 +1,153 @@
@props([
'section',
'parameters' => [],
'title' => null,
'subtitle' => null,
// When false (default), hide the in-flow H1 once the desktop shell is active
// (lg+: main sidebar + fixed layer-2 tabs). Below lg the mobile topbar is used
// and the page title stays visible. Pass true for resource-detail names.
'titleOnDesktop' => false,
'mobileTitleOnly' => false,
])
@php
$items = match ($section) {
'team' => [
['label' => 'General', 'route' => 'team.index', 'active' => request()->routeIs('team.index', 'team.member.index', 'team.admin-view', 'team.danger-zone')],
],
'profile' => [
['label' => 'General', 'route' => 'profile', 'active' => request()->routeIs('profile')],
['label' => 'Appearance', 'route' => 'profile.appearance', 'active' => request()->routeIs('profile.appearance')],
],
'notifications' => [
['label' => 'Email', 'route' => 'notifications.email', 'active' => request()->routeIs('notifications.*')],
],
'security' => [
['label' => 'Private Keys', 'route' => 'security.private-key.index', 'active' => request()->routeIs('security.*')],
],
'settings' => [
['label' => 'General', 'route' => 'settings.index', 'active' => request()->routeIs('settings.*')],
],
'source' => [
[
'label' => 'General',
'route' => 'source.github.show',
'active' => request()->routeIs('source.github.show', 'source.github.permissions', 'source.github.resources', 'source.github.danger'),
],
],
'destination' => [
['label' => 'General', 'route' => 'destination.show', 'active' => request()->routeIs('destination.show', 'destination.resources', 'destination.danger')],
],
'storage' => [
[
'label' => 'General',
'route' => 'storage.show',
'active' => request()->routeIs('storage.show', 'storage.danger', 'storage.resources'),
],
],
'subscription' => [
[
'label' => 'Plan',
'route' => 'subscription.show',
'active' => request()->routeIs('subscription.show'),
'icon' => 'subscription',
// Unsubscribed accounts cannot open /subscription (middleware forces /subscription/new).
'visible' => isSubscriptionActive() || isSubscriptionOnGracePeriod(),
],
[
'label' => 'Pricing',
'route' => 'subscription.index',
'active' => request()->routeIs('subscription.index'),
'icon' => 'dashboard',
// Active subscriptions are redirected away from pricing by middleware.
'visible' => ! isSubscriptionActive(),
],
],
default => [],
};
$items = array_values(array_filter($items, fn (array $item): bool => $item['visible'] ?? true));
// A single self-link tab (e.g. only "Pricing" on /subscription/new) is not navigation.
$showTabs = count($items) >= 2;
$hasTitle = filled($title);
$hasActions = isset($actions);
$showNav = $showTabs || $hasActions;
@endphp
@if ($hasTitle)
<div class="flex flex-col">
<header @class([
'order-1 mb-4 flex min-w-0 items-start justify-between gap-4 lg:order-2',
'lg:mb-8' => $titleOnDesktop || ! $showNav,
// Hide with the desktop chrome (sidebar + fixed tabs), not xl.
// Keep title on desktop when there is no fixed layer-2 tab strip.
'lg:hidden' => $mobileTitleOnly || (! $titleOnDesktop && $showNav),
])>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<h1 class="min-w-0 truncate text-[24px]! leading-7! font-semibold! tracking-tight!">{{ $title }}</h1>
@isset($titleMeta)
<div class="flex shrink-0 flex-wrap items-center gap-2">
{{ $titleMeta }}
</div>
@endisset
</div>
@if (filled($subtitle))
<p class="mt-1 text-[13px] text-neutral-500 dark:text-fg-dim">{{ $subtitle }}</p>
@endif
</div>
@isset($titleActions)
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
{{ $titleActions }}
</div>
@endisset
</header>
@if ($showNav)
<div class="order-2 lg:order-1">
@endif
@endif
@if ($showNav)
<nav class="mb-6 w-full lg:mb-0">
<div class="w-full lg:fixed lg:top-12 lg:right-0 lg:z-30 lg:h-12 lg:w-auto lg:border-b lg:border-neutral-200 lg:bg-white/95 lg:pr-4 lg:pl-2 lg:backdrop-blur lg:transition-[left] lg:duration-200 lg:dark:border-white/[0.06] lg:dark:bg-panel/95"
:class="[typeof collapsed !== 'undefined' && collapsed ? 'lg:left-16' : 'lg:left-56']">
{{-- Mobile: stack tabs then actions so long buttons never crush the tab strip.
Desktop: single fixed row with tabs left and actions right. --}}
<div
class="flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3 lg:h-full lg:gap-4">
@if ($showTabs)
<div class="flex min-w-0 w-full items-center gap-0.5 overflow-x-auto rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 sm:flex-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
@foreach ($items as $item)
<a @class([
'app-tab shrink-0',
'bg-coollabs/10 text-coollabs shadow-sm ring-1 ring-coollabs/25 hover:bg-coollabs/15 dark:bg-warning/15 dark:text-warning dark:ring-warning/25 dark:hover:bg-warning/20' => $item['active'],
])
{{ wireNavigate() }} href="{{ route($item['route'], $parameters) }}">
@if ($item['icon'] ?? null)
<x-reicon :name="$item['icon']" class="size-3.5" />
@endif
{{ $item['label'] }}
</a>
@endforeach
</div>
@endif
@isset($actions)
<div
class="flex w-full shrink-0 flex-wrap items-center justify-end gap-2 sm:w-auto [&_.button]:whitespace-nowrap">
{{ $actions }}
</div>
@endisset
</div>
</div>
<div class="hidden lg:block lg:h-12" aria-hidden="true"></div>
</nav>
@endif
@if ($hasTitle)
@if ($showNav)
</div>
@endif
</div>
@endif
@@ -18,7 +18,7 @@
$urlHelper = 'If you change the user/password/port, this could be different. This is with the default values.';
@endphp
<div class="flex flex-col gap-2">
<div class="space-y-5">
@if ($isPasswordHiddenForMember)
<x-forms.input :label="$label . ' URL (internal)'" disabled value="Hidden (only admins can view)" />
<x-forms.input :label="$label . ' URL (public)'" disabled value="Hidden (only admins can view)" />
@@ -35,10 +35,14 @@
@endif
@if ($supportsSsl)
<div class="flex flex-col gap-2 pt-4">
<div class="flex items-center justify-between py-2">
<div class="flex items-center justify-between w-full">
<h3>SSL Configuration</h3>
<div class="border-t border-neutral-200 pt-5 dark:border-white/[0.06]">
<div class="mb-4 flex items-center justify-between gap-3">
<div>
<h3 class="text-sm font-semibold text-black dark:text-fg">SSL configuration</h3>
<p class="mt-1 text-xs text-neutral-500 dark:text-fg-dim">
Encryption settings can only be changed while the database is stopped.
</p>
</div>
@if ($enableSsl && $certificateValidUntil)
<x-modal-confirmation title="Regenerate SSL Certificates"
buttonTitle="Regenerate SSL Certificates" :actions="[
@@ -47,10 +51,9 @@
]"
submitAction="regenerateSslCertificate" :confirmWithText="false" :confirmWithPassword="false" />
@endif
</div>
</div>
@if ($enableSsl && $certificateValidUntil)
<span class="text-sm">Valid until:
<div class="mb-4 text-sm text-neutral-600 dark:text-fg-dim">Valid until:
@if (now()->gt($certificateValidUntil))
<span class="text-red-500">{{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired</span>
@elseif(now()->addDays(30)->gt($certificateValidUntil))
@@ -59,40 +62,24 @@
@else
<span>{{ $certificateValidUntil->format('d.m.Y H:i:s') }}</span>
@endif
</span>
@endif
<div class="flex flex-col gap-2">
<div class="w-64" wire:key="enable_ssl">
@if ($isExited)
<x-forms.checkbox id="enableSsl" label="Enable SSL" wire:model.live="enableSsl"
instantSave="instantSaveSSL" canGate="update" :canResource="$database" />
@else
<x-forms.checkbox id="enableSsl" label="Enable SSL" wire:model.live="enableSsl"
instantSave="instantSaveSSL" disabled
helper="Database should be stopped to change this setting." canGate="update"
:canResource="$database" />
@endif
</div>
@if ($sslModeOptions && $enableSsl)
<div class="mx-2">
@if ($isExited)
<x-forms.select id="sslMode" label="SSL Mode" wire:model.live="sslMode"
instantSave="instantSaveSSL" :helper="$sslModeHelper" canGate="update"
:canResource="$database">
@foreach ($sslModeOptions as $value => $option)
<option value="{{ $value }}" title="{{ $option['title'] ?? '' }}">{{ $option['label'] }}</option>
@endforeach
</x-forms.select>
@else
<x-forms.select id="sslMode" label="SSL Mode" instantSave="instantSaveSSL" disabled
helper="Database should be stopped to change this setting." canGate="update"
:canResource="$database">
@foreach ($sslModeOptions as $value => $option)
<option value="{{ $value }}" title="{{ $option['title'] ?? '' }}">{{ $option['label'] }}</option>
@endforeach
</x-forms.select>
@endif
</div>
@endif
<div class="grid gap-4 sm:grid-cols-2">
<x-forms.listbox id="enableSsl" label="SSL"
onChange="instantSaveSSL"
:disabled="! $isExited || ! auth()->user()?->can('update', $database)"
:options="[
['value' => true, 'label' => 'Enabled'],
['value' => false, 'label' => 'Disabled'],
]" />
@if ($sslModeOptions)
<x-forms.listbox id="sslMode" label="SSL mode" :helper="$sslModeHelper"
onChange="instantSaveSSL"
:disabled="! $enableSsl || ! $isExited || ! auth()->user()?->can('update', $database)"
:options="collect($sslModeOptions)->map(fn ($option, $value) => [
'value' => $value,
'label' => $option['label'],
])->values()->all()" />
@endif
</div>
</div>
@@ -0,0 +1,95 @@
@props(['database', 'currentRoute'])
@php
$databaseRouteParameters = [
'project_uuid' => $database->environment->project->uuid,
'environment_uuid' => $database->environment->uuid,
'database_uuid' => $database->uuid,
];
$configurationItems = collect([
['label' => 'General', 'route' => 'project.database.configuration', 'icon' => 'settings'],
['label' => 'Environment Variables', 'route' => 'project.database.environment-variables', 'icon' => 'variables'],
['label' => 'Persistent Storage', 'route' => 'project.database.persistent-storage', 'icon' => 'storages'],
['label' => 'Backups', 'route' => 'project.database.backup.index', 'icon' => 'database', 'visible' => $database->isBackupSolutionAvailable()],
['label' => 'Import Backup', 'route' => 'project.database.import-backup', 'icon' => 'upload', 'navigate' => false, 'visible' => auth()->user()?->can('update', $database)],
['label' => 'Servers', 'route' => 'project.database.servers', 'icon' => 'servers'],
['label' => 'Runtime', 'route' => 'project.database.logs', 'icon' => 'unordered-list', 'navigate' => false],
['label' => 'Terminal', 'route' => 'project.database.command', 'icon' => 'browser-terminal', 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')],
['label' => 'Webhooks', 'route' => 'project.database.webhooks', 'icon' => 'notifications'],
['label' => 'Healthcheck', 'route' => 'project.database.healthcheck', 'icon' => 'feedback'],
['label' => 'Resource Limits', 'route' => 'project.database.resource-limits', 'icon' => 'cpu'],
['label' => 'Resource Operations', 'route' => 'project.database.resource-operations', 'icon' => 'server-update'],
['label' => 'Metrics', 'route' => 'project.database.metrics', 'icon' => 'graph'],
['label' => 'Tags', 'route' => 'project.database.tags', 'icon' => 'tags'],
['label' => 'Danger Zone', 'route' => 'project.database.danger', 'icon' => 'shield-alert'],
])->filter(fn (array $item): bool => $item['visible'] ?? true)
->map(fn (array $item): array => [
...$item,
'active' => $currentRoute === $item['route']
|| ($item['route'] === 'project.database.backup.index'
&& str($currentRoute)->startsWith('project.database.backup')),
]);
$menuGroups = [
'Settings' => ['General', 'Environment Variables', 'Persistent Storage', 'Backups', 'Import Backup', 'Servers'],
'Automation' => ['Webhooks', 'Healthcheck'],
'Logs' => ['Runtime'],
'Operations' => ['Terminal', 'Resource Limits', 'Resource Operations', 'Metrics', 'Tags', 'Danger Zone'],
];
$groupedItems = collect($menuGroups)
->map(fn (array $labels) => $configurationItems->whereIn('label', $labels)->values())
->filter(fn ($items) => $items->isNotEmpty());
$pageSections = $database->type() === 'standalone-postgresql'
? [
['id' => 'database-details-section', 'label' => 'Database details'],
['id' => 'credentials-section', 'label' => 'Credentials'],
['id' => 'initialization-section', 'label' => 'Initialization'],
['id' => 'runtime-network-section', 'label' => 'Runtime and network'],
['id' => 'public-access-section', 'label' => 'Public access'],
['id' => 'configuration-section', 'label' => 'Configuration'],
['id' => 'log-delivery-section', 'label' => 'Log delivery'],
['id' => 'initialization-scripts-section', 'label' => 'Initialization scripts'],
]
: [];
@endphp
<aside class="application-settings-navigation min-w-0 xl:self-start">
<nav aria-label="Database settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@foreach ($groupedItems as $groupLabel => $groupItems)
@unless ($loop->first)
<div class="my-2 hidden border-t border-neutral-200 xl:block dark:border-white/[0.06]" aria-hidden="true"></div>
@endunless
<div class="nav-section hidden xl:block">{{ $groupLabel }}</div>
@foreach ($groupItems as $menuItem)
<a @class(['menu-item', 'menu-item-active' => $menuItem['active']])
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $databaseRouteParameters) }}">
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
</a>
@if ($menuItem['active'] && $menuItem['route'] === 'project.database.configuration' && $pageSections !== [])
<div class="nav-children hidden flex-col gap-0.5 py-1 xl:flex"
x-data="{
activeSection: '',
scrollToSection(id) {
this.activeSection = id;
window.scrollToSettingsSection?.(id);
},
}">
@foreach ($pageSections as $section)
<button type="button" class="menu-subitem"
:class="activeSection === '{{ $section['id'] }}' && 'menu-subitem-active'"
@click="scrollToSection('{{ $section['id'] }}')">
<span class="menu-item-label text-left">{{ $section['label'] }}</span>
</button>
@endforeach
</div>
@endif
@endforeach
@endforeach
</nav>
</aside>
@@ -1,111 +1,119 @@
@props([
'diff' => null,
'compact' => false,
'compact' => true,
])
@php
$changes = collect(data_get($diff, 'changes', []))->values()->all();
$count = count($changes);
$requiresBuild = collect($changes)->contains(fn ($change) => data_get($change, 'impact') === 'build');
$changes = collect(data_get($diff, 'changes', []))->values();
@endphp
@if ($count > 0)
<div @class([
'text-xs' => $compact,
'text-sm' => ! $compact,
])>
<div class="mb-2 flex flex-wrap items-center gap-2 font-semibold text-black dark:text-white">
<span>{{ $count }} configuration {{ $count === 1 ? 'change' : 'changes' }}</span>
<span @class([
'rounded-sm px-1.5 py-0.5 text-[0.65rem] font-semibold uppercase leading-none',
'bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-300' => $requiresBuild,
'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300' => ! $requiresBuild,
])>
{{ $requiresBuild ? 'Rebuild required' : 'Redeploy required' }}
</span>
</div>
@if ($changes->isNotEmpty())
<div @class(['space-y-3' => $compact, 'space-y-5' => ! $compact])>
@foreach ($changes->groupBy('section_label') as $sectionLabel => $sectionChanges)
<div>
<h4
@class([
'font-semibold uppercase tracking-wide text-neutral-500 dark:text-fg-faint',
'mb-1 text-[10px]' => $compact,
'mb-2 text-[11px]' => ! $compact,
])>
{{ $sectionLabel }}
</h4>
@unless ($compact)
<div class="space-y-4">
@foreach (collect($changes)->groupBy('section_label') as $sectionLabel => $sectionChanges)
<div>
<div class="mb-0.5 text-[0.65rem] font-semibold uppercase tracking-wide text-neutral-600 dark:text-neutral-400">
{{ $sectionLabel }}
<div class="overflow-x-auto rounded-md ring-1 ring-neutral-200 dark:ring-white/[0.08]">
<div class="min-w-[560px]">
<div
@class([
'grid grid-cols-[minmax(8rem,0.75fr)_minmax(12rem,1fr)_1.25rem_minmax(12rem,1fr)_1.5rem] items-center text-[10px] font-medium text-neutral-500 dark:bg-white/[0.04] dark:text-fg-faint bg-neutral-100',
'gap-2 px-2 py-1' => $compact,
'gap-3 px-3 py-2' => ! $compact,
])>
<div>Field</div>
<div>Current</div>
<div></div>
<div>New</div>
<div></div>
</div>
<div class="rounded-sm border border-neutral-300 dark:border-coolgray-200">
<div class="grid grid-cols-[12rem_1fr_1.5rem_1fr] items-center gap-2 bg-neutral-100 px-3 py-1.5 text-[0.65rem] font-semibold uppercase tracking-wide text-neutral-500 dark:bg-coolgray-200 dark:text-neutral-400">
<div>Field</div>
<div>From</div>
<div></div>
<div>To</div>
</div>
<div class="divide-y divide-neutral-300 dark:divide-coolgray-200">
@foreach ($sectionChanges as $change)
@php
$changeKey = (string) data_get($change, 'key');
$expandable = data_get($change, 'expandable', false);
$oldDisplay = (string) data_get($change, 'old_display_value');
$newDisplay = (string) data_get($change, 'new_display_value');
$oldFull = data_get($change, 'old_full_value') ?? $oldDisplay;
$newFull = data_get($change, 'new_full_value') ?? $newDisplay;
$label = (string) data_get($change, 'label');
$labelTruncated = mb_strlen($label) > 20;
$rowExpandable = $expandable || $labelTruncated;
@endphp
<div class="grid grid-cols-[12rem_1fr_1.5rem_1fr] items-start gap-2 px-3 py-1.5 text-neutral-700 dark:text-neutral-300">
<div class="min-w-0 shrink-0 font-medium text-black dark:text-white">
@if ($rowExpandable)
<div class="break-words"
:class="expandedRows['{{ $changeKey }}'] ? '' : 'truncate'"
x-text="expandedRows['{{ $changeKey }}'] ? @js($label) : @js((string) str($label)->limit(20))"></div>
@else
{{ $label }}
@endif
</div>
<div class="min-w-0 text-red-700 dark:text-red-400/80">
@if ($expandable)
<div class="break-words"
:class="expandedRows['{{ $changeKey }}'] ? 'whitespace-pre-wrap' : 'truncate'"
x-text="expandedRows['{{ $changeKey }}'] ? @js($oldFull) : @js($oldDisplay)"></div>
@else
<div class="truncate">{{ $oldDisplay }}</div>
@endif
</div>
<div class="text-center text-neutral-500 dark:text-neutral-400">→</div>
<div class="flex min-w-0 items-start gap-1 text-green-700 dark:text-green-500">
<div class="min-w-0 flex-1">
@if ($expandable)
<div class="break-words"
:class="expandedRows['{{ $changeKey }}'] ? 'whitespace-pre-wrap' : 'truncate'"
x-text="expandedRows['{{ $changeKey }}'] ? @js($newFull) : @js($newDisplay)"></div>
@else
<div class="truncate">{{ $newDisplay }}</div>
@endif
<div class="divide-y divide-neutral-200 bg-white dark:divide-white/[0.07] dark:bg-transparent">
@foreach ($sectionChanges as $change)
@php
$changeKey = (string) data_get($change, 'key');
$expandable = data_get($change, 'expandable', false);
$oldDisplay = (string) data_get($change, 'old_display_value');
$newDisplay = (string) data_get($change, 'new_display_value');
$oldFull = data_get($change, 'old_full_value') ?? $oldDisplay;
$newFull = data_get($change, 'new_full_value') ?? $newDisplay;
$label = (string) data_get($change, 'label');
$labelTruncated = mb_strlen($label) > 24;
$rowExpandable = $expandable || $labelTruncated;
@endphp
<div
@class([
'grid grid-cols-[minmax(8rem,0.75fr)_minmax(12rem,1fr)_1.25rem_minmax(12rem,1fr)_1.5rem] items-start transition-colors hover:bg-neutral-50 dark:hover:bg-white/[0.025]',
'gap-2 px-2 py-1.5 text-xs' => $compact,
'gap-3 px-3 py-2.5 text-sm' => ! $compact,
])>
<div class="min-w-0 font-medium text-neutral-900 dark:text-fg">
@if ($rowExpandable)
<div class="break-words"
:class="expandedRows['{{ $changeKey }}'] ? '' : 'truncate'"
x-text="expandedRows['{{ $changeKey }}'] ? @js($label) : @js((string) str($label)->limit(24))">
</div>
@if ($rowExpandable)
<button type="button"
x-on:click="expandedRows['{{ $changeKey }}'] = ! expandedRows['{{ $changeKey }}']"
:aria-expanded="!! expandedRows['{{ $changeKey }}']"
title="Toggle full value"
class="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center text-neutral-400 transition hover:text-black dark:hover:text-white">
<svg x-show="! expandedRows['{{ $changeKey }}']" class="h-3.5 w-3.5"
viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M13.28 7.78l3.22-3.22v2.69a.75.75 0 0 0 1.5 0v-4.5a.75.75 0 0 0-.75-.75h-4.5a.75.75 0 0 0 0 1.5h2.69l-3.22 3.22a.75.75 0 0 0 1.06 1.06ZM2 17.25v-4.5a.75.75 0 0 1 1.5 0v2.69l3.22-3.22a.75.75 0 0 1 1.06 1.06L4.56 16.5h2.69a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1-.75-.75Z" clip-rule="evenodd" />
</svg>
<svg x-show="expandedRows['{{ $changeKey }}']" x-cloak class="h-3.5 w-3.5"
viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M3.28 2.22a.75.75 0 0 0-1.06 1.06L5.44 6.5H2.75a.75.75 0 0 0 0 1.5h4.5A.75.75 0 0 0 8 7.25v-4.5a.75.75 0 0 0-1.5 0v2.69L3.28 2.22ZM13.5 2.75a.75.75 0 0 0-1.5 0v4.5c0 .414.336.75.75.75h4.5a.75.75 0 0 0 0-1.5h-2.69l3.22-3.22a.75.75 0 0 0-1.06-1.06L13.5 5.44V2.75ZM3.28 17.78l3.22-3.22v2.69a.75.75 0 0 0 1.5 0v-4.5a.75.75 0 0 0-.75-.75h-4.5a.75.75 0 0 0 0 1.5h2.69l-3.22 3.22a.75.75 0 1 0 1.06 1.06ZM12 12.75c0-.414.336-.75.75-.75h4.5a.75.75 0 0 1 0 1.5h-2.69l3.22 3.22a.75.75 0 1 1-1.06 1.06l-3.22-3.22v2.69a.75.75 0 0 1-1.5 0v-4.5Z" clip-rule="evenodd" />
</svg>
</button>
@endif
</div>
@else
{{ $label }}
@endif
</div>
@endforeach
</div>
<div class="min-w-0 text-red-600 dark:text-red-400">
@if ($expandable)
<div class="break-words"
:class="expandedRows['{{ $changeKey }}'] ? 'whitespace-pre-wrap' : 'truncate'"
x-text="expandedRows['{{ $changeKey }}'] ? @js($oldFull) : @js($oldDisplay)">
</div>
@else
<div class="truncate">{{ $oldDisplay }}</div>
@endif
</div>
<div class="flex justify-center pt-0.5 text-neutral-400 dark:text-fg-faint">
<x-reicon name="arrow-right" class="size-3" />
</div>
<div class="min-w-0 text-emerald-600 dark:text-emerald-400">
@if ($expandable)
<div class="break-words"
:class="expandedRows['{{ $changeKey }}'] ? 'whitespace-pre-wrap' : 'truncate'"
x-text="expandedRows['{{ $changeKey }}'] ? @js($newFull) : @js($newDisplay)">
</div>
@else
<div class="truncate">{{ $newDisplay }}</div>
@endif
</div>
<div class="flex justify-end">
@if ($rowExpandable)
<button type="button"
x-on:click="expandedRows['{{ $changeKey }}'] = ! expandedRows['{{ $changeKey }}']"
:aria-expanded="!! expandedRows['{{ $changeKey }}']"
title="Toggle full value"
class="flex size-5 items-center justify-center rounded text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-700 dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg">
<x-reicon name="eye"
x-show="! expandedRows['{{ $changeKey }}']"
class="size-3" />
<x-reicon name="eye-off2"
x-show="expandedRows['{{ $changeKey }}']"
x-cloak class="size-3" />
</button>
@endif
</div>
</div>
@endforeach
</div>
</div>
@endforeach
</div>
</div>
@endunless
@endforeach
</div>
@endif
@@ -10,27 +10,25 @@
:class="{ 'z-40': modalOpen }" class="relative w-auto h-auto">
<template x-teleport="body">
<div x-show="modalOpen"
class="fixed top-0 lg:pt-10 left-0 z-99 flex items-start justify-center w-screen h-screen" x-cloak>
<div x-show="modalOpen" class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
class="fixed inset-0 z-99 flex min-h-full items-center justify-center overflow-y-auto p-4" x-cloak>
<div x-show="modalOpen" class="absolute inset-0 bg-black/50 backdrop-blur-[2px]"></div>
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
class="relative w-full py-6 border rounded-sm min-w-full lg:min-w-[36rem] max-w-[48rem] bg-neutral-100 border-neutral-400 dark:bg-base px-7 dark:border-coolgray-300">
<div class="flex justify-between items-center pb-3">
<h2 class="pr-8 font-bold">Domain Already In Use</h2>
class="application-settings-form application-settings-section relative w-full lg:min-w-[36rem] lg:max-w-2xl"
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
<header>
<h3>Domain already in use</h3>
<button @click="modalOpen = false; $wire.set('showDomainConflictModal', false)"
class="flex absolute top-2 right-2 justify-center items-center w-8 h-8 rounded-full dark:text-white hover:bg-coolgray-300">
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
class="flex size-7 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg">
<x-reicon name="x" class="size-4" />
</button>
</div>
<div class="relative w-auto">
<x-callout type="danger" title="Domain Conflict Detected" class="mb-4">
</header>
<div class="application-settings-section-body">
<x-callout type="danger" title="Domain conflict detected" class="mb-4">
The following domain(s) are already in use by other resources. Using the same domain for
multiple resources can cause routing conflicts and unpredictable behavior.
</x-callout>
@@ -38,7 +36,7 @@
<div class="mb-4">
<ul class="space-y-2">
@foreach ($conflicts as $conflict)
<li class="flex items-start text-red-500">
<li class="flex items-start text-[12px] leading-5 text-red-600 dark:text-red-400">
<div>
<strong>{{ $conflict['domain'] }}</strong> is used by
@if ($conflict['resource_type'] === 'instance')
@@ -69,14 +67,12 @@
@endif
</x-callout>
<div class="flex flex-wrap gap-2 justify-between mt-4">
<x-forms.button @click="modalOpen = false; $wire.set('showDomainConflictModal', false)"
class="w-auto dark:bg-coolgray-200 dark:hover:bg-coolgray-300">
<div class="mt-4 flex flex-wrap justify-end gap-2 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
<x-forms.button @click="modalOpen = false; $wire.set('showDomainConflictModal', false)">
Cancel
</x-forms.button>
<x-forms.button wire:click="{{ $confirmAction }}" @click="modalOpen = false" class="w-auto"
isError>
I understand, proceed anyway
<x-forms.button wire:click="{{ $confirmAction }}" @click="modalOpen = false" isError>
Proceed anyway
</x-forms.button>
</div>
</div>
@@ -0,0 +1,78 @@
@props([
'title',
'description' => null,
'size' => 'base', // sm | base | lg
'iconName' => null, // reicon name; use with icon-name="…"
])
@php
$minHeight = match ($size) {
'sm' => 'min-h-44',
'lg' => 'min-h-96',
default => 'min-h-80',
};
$iconBox = match ($size) {
'sm' => 'mb-3 size-10',
'lg' => 'mb-4 size-12',
default => 'mb-4 size-11',
};
$iconSize = match ($size) {
'sm' => 'size-4.5',
'lg' => 'size-6',
default => 'size-5',
};
$titleClass = match ($size) {
'sm' => 'text-[14px] font-semibold text-black dark:text-fg',
'lg' => 'text-base font-semibold text-black dark:text-fg',
default => 'text-[15px] font-semibold text-black dark:text-fg',
};
$descriptionClass = match ($size) {
'sm' => 'mt-1 max-w-sm text-[12px] leading-5 text-neutral-500 dark:text-fg-dim',
default => 'mt-1 max-w-sm text-[13px] leading-5 text-neutral-500 dark:text-fg-dim',
};
$hasIconSlot = isset($icon) && $icon instanceof \Illuminate\View\ComponentSlot && ! $icon->isEmpty();
$hasIconName = filled($iconName);
$hasIcon = $hasIconSlot || $hasIconName;
// Prefer contents; fall back to actions (legacy slot name used in several views).
$footer = null;
if (isset($contents) && $contents instanceof \Illuminate\View\ComponentSlot && ! $contents->isEmpty()) {
$footer = $contents;
} elseif (isset($actions) && $actions instanceof \Illuminate\View\ComponentSlot && ! $actions->isEmpty()) {
$footer = $actions;
}
@endphp
{{-- Empty state: dashed card, icon badge, title, description, optional actions. --}}
<div
{{ $attributes->merge([
'class' => "empty-state flex w-full flex-col items-center justify-center rounded-xl border border-dashed border-neutral-300 px-6 py-10 text-center dark:border-white/[0.1] {$minHeight}",
]) }}>
@if ($hasIcon)
<div
class="{{ $iconBox }} flex items-center justify-center rounded-xl border border-neutral-200 bg-white text-neutral-400 shadow-sm dark:border-white/[0.08] dark:bg-white/[0.035] dark:text-fg-faint">
@if ($hasIconSlot)
{{ $icon }}
@else
<x-reicon :name="$iconName" class="{{ $iconSize }}" />
@endif
</div>
@endif
<h2 class="{{ $titleClass }}">{{ $title }}</h2>
@if ($description)
<p class="{{ $descriptionClass }}">{{ $description }}</p>
@endif
@if ($footer)
<div class="mt-4 flex flex-wrap items-center justify-center gap-2">
{{ $footer }}
</div>
@endif
</div>
@@ -0,0 +1,67 @@
@props([
'code',
'title',
'description' => null,
'showGoBack' => true,
'showDashboard' => true,
'primaryHref' => null,
'primaryLabel' => null,
'tone' => 'accent', // accent | danger
'showContact' => true,
])
@php
$codeClass = $tone === 'danger'
? 'error-code error-code-danger'
: 'error-code';
@endphp
<main {{ $attributes->merge(['class' => 'error-shell']) }}>
<div class="error-shell-content">
<p class="{{ $codeClass }}">{{ $code }}</p>
<h1 class="error-title">{{ $title }}</h1>
@if (filled($description))
<p class="error-description">{{ $description }}</p>
@endif
@if ($slot->isNotEmpty())
<div class="error-extra">
{{ $slot }}
</div>
@endif
<div class="error-actions">
@if (filled($primaryHref) && filled($primaryLabel))
<a href="{{ $primaryHref }}">
<x-forms.button type="button">{{ $primaryLabel }}</x-forms.button>
</a>
@endif
@if ($showGoBack)
<a href="{{ url()->previous() }}">
<x-forms.button type="button">Go back</x-forms.button>
</a>
@endif
@if ($showDashboard)
<a href="{{ route('dashboard') }}" {{ wireNavigate() }}>
<x-forms.button type="button">Dashboard</x-forms.button>
</a>
@endif
@if ($showContact)
<a
target="_blank"
rel="noopener noreferrer"
href="{{ config('constants.urls.contact') }}">
<x-forms.button type="button">
Contact support
<x-external-link class="inline-flex size-3 text-current" />
</x-forms.button>
</a>
@endif
</div>
</div>
</main>
@@ -1,26 +1,71 @@
@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((string) $wireClickValue);
} elseif ($hasExplicitWireTarget) {
$loadingTarget = trim((string) $wireTargetValue);
}
}
$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 }"
@mouseenter="_t = setTimeout(() => {
visible = true;
$nextTick(() => requestAnimationFrame(() => {
const tip = $refs.tip;
if (!tip) return;
const r = $el.getBoundingClientRect();
const t = tip.getBoundingClientRect();
let top = r.top - t.height - 6;
let left = r.left;
if (top < 4) top = r.bottom + 6;
if (left + t.width > innerWidth - 8) left = innerWidth - 8 - t.width;
if (left < 4) left = 4;
tip.style.top = top + 'px';
tip.style.left = left + 'px';
}));
}, 300)"
@mouseleave="clearTimeout(_t); visible = false">
x-data="{
visible: false,
_t: null,
showTooltip(delay = 0) {
clearTimeout(this._t);
this._t = setTimeout(() => {
this.visible = true;
this.positionTooltip();
}, delay);
},
hideTooltip() {
clearTimeout(this._t);
this.visible = false;
},
positionTooltip() {
this.$nextTick(() => requestAnimationFrame(() => {
const tip = this.$refs.tip;
if (!tip) return;
const r = this.$el.getBoundingClientRect();
const t = tip.getBoundingClientRect();
let top = r.top - t.height - 6;
let left = r.left;
if (top < 4) top = r.bottom + 6;
if (left + t.width > innerWidth - 8) left = innerWidth - 8 - t.width;
if (left < 4) left = 4;
tip.style.top = top + 'px';
tip.style.left = left + 'px';
}));
}
}"
@if ($disabled) tabindex="0" :aria-describedby="visible ? $id('button-tooltip') : null" @endif
@mouseenter="showTooltip(300)"
@mouseleave="hideTooltip()"
@focusin="showTooltip()"
@focusout="hideTooltip()"
@click.outside="hideTooltip()">
@endif
<button @disabled($disabled) {{ $attributes->merge(['class' => $defaultClass]) }}
{{ $attributes->merge(['type' => 'button']) }}
<button @disabled($disabled) @if ($isHighlighted) isHighlighted @endif
@if ($authDisabled || filled($tooltip)) :aria-describedby="visible ? $id('button-tooltip') : null" @endif
{{ $attributes->merge(['class' => $defaultClass, 'type' => 'button'])->merge($loadingAttributes) }}
@isset($confirm)
x-on:click="toggleConfirmModal('{{ $confirm }}', '{{ explode('(', $confirmAction)[0] }}')"
@endisset
@@ -28,19 +73,14 @@
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">
<div x-ref="tip" x-show="visible" x-cloak :id="$id('button-tooltip')" role="tooltip"
class="auth-tooltip">
{{ $tooltip ?: 'You do not have permission to perform this action.' }}
</div>
</span>
@@ -11,36 +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']) }}
wire:model={{ $value ?? $modelBinding }} 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>
@@ -0,0 +1,22 @@
@props([
'title' => 'Advanced settings',
'contentClass' => '',
])
<div x-data="{ open: false }" {{ $attributes->class(['flex flex-col gap-4']) }}>
<button type="button" x-on:click="open = !open"
class="flex items-center gap-2 text-left text-sm font-medium hover:underline" :aria-expanded="open">
<svg class="size-4 transition-transform" x-bind:class="open && 'rotate-90'" viewBox="0 0 20 20"
fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd"
d="M7.21 14.77a.75.75 0 0 1 .02-1.06L11.168 10 7.23 6.29a.75.75 0 1 1 1.04-1.08l4.5 4.25a.75.75 0 0 1 0 1.08l-4.5 4.25a.75.75 0 0 1-1.06-.02Z"
clip-rule="evenodd" />
</svg>
{{ $title }}
</button>
<div x-show="open" x-cloak
class="rounded-lg border border-neutral-200 p-4 dark:border-coolgray-400 {{ $contentClass }}">
{{ $slot }}
</div>
</div>
@@ -1,26 +1,29 @@
@props(['text', 'label' => null])
<div class="w-full" x-data="{ copied: false, isSecure: window.isSecureContext }">
<div class="w-full"
x-data="{ copied: false, canCopy: window.isSecureContext && typeof navigator.clipboard?.writeText === 'function' }">
@if ($label)
<label class="flex gap-1 items-center mb-1 text-sm font-medium text-black dark:text-white">{{ $label }}</label>
@endif
<div class="relative">
<input type="text" value="{{ $text }}"
class="input pr-11 bg-white dark:bg-coolgray-100 dark:read-only:bg-coolgray-100 dark:read-only:text-white"
class="input bg-white dark:bg-coolgray-100 dark:read-only:bg-coolgray-100 dark:read-only:text-white"
x-bind:class="{ 'input-with-copy-button': canCopy }"
readonly
@keydown.prevent @paste.prevent @cut.prevent @drop.prevent
@focus="$event.target.select()">
<button
x-show="isSecure"
x-show="canCopy"
type="button"
@click.prevent="copied = true; navigator.clipboard.writeText({{ Js::from($text) }}); setTimeout(() => copied = false, 1000)"
class="absolute right-2 top-1/2 -translate-y-1/2 rounded-sm p-1.5 text-neutral-500 transition-colors hover:text-neutral-700 focus-visible:ring-2 focus-visible:ring-coollabs focus-visible:ring-offset-2 dark:text-neutral-400 dark:hover:text-white dark:focus-visible:ring-warning dark:focus-visible:ring-offset-base"
class="copy-button flex absolute inset-y-0 right-0 z-10 items-center pr-2 cursor-pointer text-neutral-500 transition-colors hover:text-black focus-visible:ring-2 focus-visible:ring-coollabs focus-visible:ring-offset-2 dark:text-neutral-400 dark:hover:text-white dark:focus-visible:ring-warning dark:focus-visible:ring-offset-base"
title="Copy to clipboard"
aria-label="Copy to clipboard">
<svg x-show="!copied" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg x-show="!copied" class="size-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<svg x-show="copied" class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg x-show="copied" class="size-[18px] text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</button>
@@ -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,34 +225,22 @@
@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
{{-- Dropdown for suggestions --}}
<div x-show="showDropdown"
x-transition
class="absolute z-[60] w-full mt-1 bg-white dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-400 rounded shadow-lg">
<div x-show="showDropdown" x-cloak x-transition.origin.top
class="listbox-panel top-full! z-[60]! mt-1! w-full! min-w-0! max-w-full!" role="listbox">
<template x-if="suggestions.length === 0 && currentScope">
<div class="px-3 py-2 text-sm text-neutral-500 dark:text-neutral-400">
<div class="px-2 py-2 text-sm text-neutral-500 dark:text-fg-dim">
<div>No shared variables found in <span class="font-semibold" x-text="currentScope"></span> scope.</div>
<a :href="getScopeUrl(currentScope)"
class="text-coollabs dark:text-warning hover:underline text-xs mt-1 inline-block"
class="mt-1 inline-block text-xs text-coollabs hover:underline dark:text-warning"
target="_blank">
Add <span x-text="currentScope"></span> variables →
</a>
@@ -259,24 +249,25 @@
<div x-show="suggestions.length > 0"
x-ref="dropdownList"
class="max-h-48 overflow-y-scroll"
class="max-h-48 overflow-y-auto"
style="scrollbar-width: thin;">
<template x-for="(suggestion, index) in suggestions" :key="index">
<div :id="'suggestion-' + index"
@click="selectSuggestion(suggestion)"
class="px-3 py-2 cursor-pointer hover:bg-neutral-100 dark:hover:bg-coolgray-200 flex items-center gap-2"
:class="{ 'bg-neutral-50 dark:bg-coolgray-300': index === selectedIndex }">
class="listbox-option justify-start! gap-2.5!"
:class="{ 'bg-neutral-100 dark:bg-white/[0.08]': index === selectedIndex }"
role="option" :aria-selected="index === selectedIndex">
<template x-if="suggestion.type === 'scope'">
<span class="text-xs px-2 py-0.5 bg-coollabs/10 dark:bg-warning/10 text-coollabs dark:text-warning rounded">
<span class="rounded-md border border-warning/25 bg-warning/10 px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-warning">
SCOPE
</span>
</template>
<template x-if="suggestion.type === 'variable'">
<span class="text-xs px-2 py-0.5 bg-green-500/10 text-green-600 dark:text-green-400 rounded">
<span class="rounded-md border border-emerald-500/25 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-emerald-600 dark:text-emerald-400">
VAR
</span>
</template>
<span class="text-sm font-mono" x-text="suggestion.display"></span>
<span class="min-w-0 truncate font-mono text-sm" x-text="suggestion.display"></span>
</div>
</template>
</div>
@@ -3,14 +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
</label>
</div>
@endif
@if ($type === 'password')
<div class="relative" x-data="{ type: 'password' }" @success.window="type = 'password'">
@@ -26,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,88 @@
@props([
'id' => null,
'htmlId' => null,
'label' => null,
'helper' => null,
'required' => false,
'options' => [], // list of ['value' => ..., 'label' => ..., 'disabled' => bool]
'placeholder' => 'Select…',
'emptyText' => 'No options available.',
'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,
'tooltip' => true,
])
@php
$triggerId = ($htmlId ?? $id).'-trigger';
@endphp
<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="{{ $triggerId }}" 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="{{ $triggerId }}" type="button" class="listbox-trigger" @click="open = !open"
@disabled($disabled) {{ $attributes->whereStartsWith('x-bind:disabled') }} aria-haspopup="listbox"
:aria-expanded="open" @if ($tooltip) :title="current" @endif>
<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">
<div x-show="options.length === 0"
class="px-3 py-2 text-[13px] text-neutral-500 dark:text-fg-dim">
{{ $emptyText }}
</div>
<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'">
+95 -26
View File
@@ -1,21 +1,60 @@
@props([
'helper',
'label' => 'More information',
])
<div x-data="{
open: false,
pinned: false,
hideTimer: null,
style: '',
show(pinned = false) {
this.pinned = pinned;
visualViewport: null,
reposition: null,
init() {
this.visualViewport = window.visualViewport;
this.reposition = () => this.open && this.position();
this.visualViewport?.addEventListener('resize', this.reposition);
this.visualViewport?.addEventListener('scroll', this.reposition);
},
destroy() {
this.visualViewport?.removeEventListener('resize', this.reposition);
this.visualViewport?.removeEventListener('scroll', this.reposition);
},
show() {
this.cancelHide();
this.open = true;
this.$nextTick(() => this.position());
},
hide() {
if (!this.pinned) {
this.cancelHide();
this.hideTimer = setTimeout(() => {
this.open = false;
}, 150);
},
cancelHide() {
if (this.hideTimer) {
clearTimeout(this.hideTimer);
this.hideTimer = null;
}
},
close() {
this.pinned = false;
this.cancelHide();
this.open = false;
},
closeWhenFocusLeaves() {
this.$nextTick(() => {
const activeElement = document.activeElement;
if (!this.$refs.trigger?.contains(activeElement) && !this.$refs.popup?.contains(activeElement)) {
this.close();
}
});
},
closeWhenPointerIsOutside(event) {
if (!this.open || this.$refs.trigger?.contains(event.target) || this.$refs.popup?.contains(event.target)) {
return;
}
this.close();
},
position() {
const trigger = this.$refs.trigger;
const popup = this.$refs.popup;
@@ -24,39 +63,69 @@
return;
}
const padding = 8;
const viewport = window.visualViewport;
const viewportLeft = viewport?.offsetLeft ?? 0;
const viewportTop = viewport?.offsetTop ?? 0;
const viewportWidth = viewport?.width ?? window.innerWidth;
const viewportHeight = viewport?.height ?? window.innerHeight;
const viewportRight = viewportLeft + viewportWidth;
const viewportBottom = viewportTop + viewportHeight;
const availableWidth = Math.max(0, viewportWidth - padding * 2);
const availableHeight = Math.max(0, viewportHeight - padding * 2);
const maxPopupWidth = 320;
const popupWidth = Math.min(maxPopupWidth, availableWidth);
this.style = `max-width: ${popupWidth}px; max-height: ${availableHeight}px; overflow-y: auto;`;
const triggerRect = trigger.getBoundingClientRect();
const popupRect = popup.getBoundingClientRect();
const padding = 8;
let top = triggerRect.bottom + padding;
let left = triggerRect.right - popupRect.width;
if (top + popupRect.height > window.innerHeight - padding) {
if (top + popupRect.height > viewportBottom - padding) {
top = triggerRect.top - popupRect.height - padding;
}
left = Math.min(Math.max(padding, left), window.innerWidth - popupRect.width - padding);
top = Math.max(padding, top);
left = Math.min(Math.max(viewportLeft + padding, left), viewportRight - popupRect.width - padding);
top = Math.min(Math.max(viewportTop + padding, top), viewportBottom - popupRect.height - padding);
this.style = `top: ${top}px; left: ${left}px;`;
this.style += ` top: ${top}px; left: ${left}px;`;
}
}" @click.outside="close" @keydown.window.escape="close" @resize.window="open && position()" @scroll.window="open && position()"
{{ $attributes->merge(['class' => 'inline-block align-middle']) }}>
<div x-ref="trigger" class="info-helper" @mouseenter="show(false)" @mouseleave="hide" @click.stop="open && pinned ? close() : show(true)">
@isset($icon)
}" @pointerdown.window="closeWhenPointerIsOutside($event)" @keydown.window.escape="close"
@resize.window="open && position()" @scroll.window="open && position()"
{{ $attributes->merge(['class' => 'relative inline-block align-middle']) }}>
{{-- button (not div) so label-for associations do not steal the click on mobile --}}
<button type="button" x-ref="trigger" data-icon-tooltip-ignore
@class([
'info-helper relative inline-flex shrink-0 items-center justify-center border-0 bg-transparent p-0 leading-none',
'size-3.5' => ! isset($trigger),
])
aria-label="{{ $label }}" :aria-describedby="open ? $id('helper-popup') : null"
@mouseenter="show()" @mouseleave="hide" @focus="show()" @blur="closeWhenFocusLeaves()"
@click.prevent.stop="show()">
@isset($trigger)
{{ $trigger }}
@elseif (isset($icon))
{{ $icon }}
@else
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="w-4 h-4 stroke-current">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<x-reicon name="info-circle"
class="size-3.5 text-neutral-400 transition-colors hover:text-neutral-600 dark:text-fg-faint dark:hover:text-fg-dim"
aria-hidden="true" />
@endisset
</div>
<template x-teleport="body">
<div x-ref="popup" x-show="open" x-cloak :style="style"
class="fixed z-[9999] w-max max-w-[min(20rem,calc(100vw-2rem))] rounded-sm border border-neutral-300 bg-neutral-200 text-xs text-neutral-700 shadow-lg whitespace-normal break-words dark:border-coolgray-500 dark:bg-coolgray-400 dark:text-neutral-300">
<div class="p-4">
{!! $helper !!}
</div>
</button>
<div x-ref="popup" x-show="open" x-cloak :id="$id('helper-popup')" role="tooltip"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 translate-y-0.5"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
:style="style"
class="info-helper-popup fixed z-[9999] w-max max-w-[min(20rem,calc(100vw-2rem))] whitespace-normal"
@mouseenter="cancelHide()" @mouseleave="hide()" @focusout="closeWhenFocusLeaves()" @click.stop>
<div class="px-3 py-2.5 text-[13px] leading-5">
{!! $helper !!}
</div>
</template>
</div>
</div>
@@ -0,0 +1,68 @@
<div x-data="{
visible: false,
text: '',
x: 0,
y: 0,
below: false,
activeTarget: null,
isIconAction(target) {
if (target.matches('[data-icon-tooltip-ignore]')) return false;
return target.matches('[data-tooltip], .icon-button') || target.querySelector('svg');
},
prepare(root) {
root.querySelectorAll?.('button[title], a[title], [data-tooltip]').forEach((target) => {
if (!this.isIconAction(target)) return;
target.dataset.iconTooltip = target.dataset.tooltip || target.getAttribute('aria-label') || target.title;
if (!target.getAttribute('aria-label')) target.setAttribute('aria-label', target.dataset.iconTooltip);
target.removeAttribute('title');
});
},
findTarget(event) {
const target = event.target.closest('button, a, [data-tooltip]');
if (target?.matches('.menu-item, .menu-subitem') && target.closest('nav')) return null;
return target && this.isIconAction(target) ? target : null;
},
show(event) {
const target = this.findTarget(event);
if (!target) return;
const text = target.dataset.tooltip || target.dataset.iconTooltip || target.getAttribute('aria-label');
if (!text) return;
this.activeTarget = target;
this.text = text;
this.visible = true;
const rect = target.getBoundingClientRect();
this.below = rect.top < 48;
this.x = rect.left + rect.width / 2;
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));
});
},
hide(event) {
if (event?.relatedTarget && this.activeTarget?.contains(event.relatedTarget)) return;
this.visible = false;
this.activeTarget = null;
},
init() {
this.prepare(document);
new MutationObserver((mutations) => mutations.forEach((mutation) => mutation.addedNodes.forEach((node) => {
if (node.nodeType !== Node.ELEMENT_NODE) return;
this.prepare(node);
if (node.matches?.('button[title], a[title], [data-tooltip]')) this.prepare(node.parentElement);
}))).observe(document.body, { childList: true, subtree: true });
document.addEventListener('mouseover', (event) => this.show(event));
document.addEventListener('mouseout', (event) => this.hide(event));
document.addEventListener('focusin', (event) => this.show(event));
document.addEventListener('focusout', (event) => this.hide(event));
document.addEventListener('click', () => this.hide());
window.addEventListener('scroll', () => this.hide(), true);
window.addEventListener('resize', () => this.hide());
},
}" class="contents">
<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'"
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">
</div>
</div>
@@ -1,13 +1,11 @@
@props(['text' => null])
<div class="inline-flex items-center justify-center" {{ $attributes }}>
@if (isset($text))
<div>{{ $text }}</div>
@endif
<svg class="w-4 h-4 mx-1 ml-3 dark:text-warning animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
<span {{ $attributes->merge(['class' => 'inline-flex shrink-0 items-center gap-1.5']) }}>
<svg class="size-3.5 shrink-0 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="9" stroke="currentColor" stroke-width="3" />
<path class="opacity-75" d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" stroke-width="3"
stroke-linecap="round" />
</svg>
</div>
@if (isset($text))
<span>{{ $text }}</span>
@endif
</span>
+13 -11
View File
@@ -1,13 +1,15 @@
@props(['text' => null])
<div class="inline-flex items-center justify-center" {{ $attributes }}>
@if (isset($text))
<div>{{ $text }}</div>
@endif
<svg class="w-4 h-4 mx-1 ml-3 text-coollabs dark:text-warning animate-spin" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
@props(['text' => null, 'compact' => false])
<div {{ $attributes->merge(['class' => 'inline-flex items-center justify-center gap-2 text-[13px] text-neutral-500 dark:text-fg-dim']) }}
role="status" aria-live="polite">
<svg @class(['loading-indicator shrink-0 animate-spin', 'size-3' => $compact, 'size-4' => ! $compact]) viewBox="0 0 24 24"
fill="none" aria-hidden="true">
<circle class="opacity-20" cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2" />
<path class="opacity-80" d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" stroke-width="2"
stroke-linecap="round" />
</svg>
@if (isset($text))
<span>{{ $text }}</span>
@endif
</div>
@@ -31,6 +31,7 @@
@php
use App\Models\InstanceSettings;
use Illuminate\View\ComponentSlot;
// Global setting to disable ALL two-step confirmation (text + password)
$disableTwoStepConfirmation = data_get(InstanceSettings::get(), 'disable_two_step_confirmation');
// Skip ONLY password confirmation for OAuth users (they have no password)
@@ -41,6 +42,8 @@
}
// When password step is skipped, Step 2 becomes final - change button text from "Continue" to "Confirm"
$effectiveStep2ButtonText = ($skipPasswordConfirmation && $step2ButtonText === 'Continue') ? 'Confirm' : $step2ButtonText;
// Prefer a rich HTML button title when callers pass <x-slot:button-title> (ComponentSlot).
$resolvedButtonTitle = $buttonTitle instanceof ComponentSlot ? $buttonTitle : $buttonTitle;
@endphp
<div {{ $ignoreWire ? 'wire:ignore' : '' }} x-data="{
@@ -131,12 +134,19 @@
}"
@keydown.escape.window="if (modalOpen) { modalOpen = false; resetModal(); }" :class="{ 'z-40': modalOpen }"
@class([
'relative h-auto',
'w-full' => $buttonFullWidth,
'w-full sm:w-auto' => ! $buttonFullWidth,
// Default to inline-flex so shells with only a programmatic/hidden trigger
// do not paint a full-width empty block above navigation.
'relative h-auto max-w-full',
'flex w-full' => $buttonFullWidth,
'inline-flex w-auto' => ! $buttonFullWidth,
])>
@if (isset($trigger))
<div class="w-full" @click="modalOpen=true">
<div @class([
'max-w-full',
'flex w-full min-w-0' => $buttonFullWidth,
'inline-flex' => ! $buttonFullWidth,
])
@click="modalOpen=true">
{{ $trigger }}
</div>
@elseif ($customButton)
@@ -158,41 +168,41 @@
@if ($disabled)
@if ($buttonFullWidth)
<x-forms.button class="w-full" isError disabled :authDisabled="$authDisabled" :tooltip="$disabledTooltip" wire:target>
{{ $buttonTitle }}
{{ $resolvedButtonTitle }}
</x-forms.button>
@else
<x-forms.button isError disabled :authDisabled="$authDisabled" :tooltip="$disabledTooltip" wire:target>
{{ $buttonTitle }}
{{ $resolvedButtonTitle }}
</x-forms.button>
@endif
@elseif ($isErrorButton)
@if ($buttonFullWidth)
<x-forms.button class="w-full" isError @click="modalOpen=true">
{{ $buttonTitle }}
{{ $resolvedButtonTitle }}
</x-forms.button>
@else
<x-forms.button isError @click="modalOpen=true">
{{ $buttonTitle }}
{{ $resolvedButtonTitle }}
</x-forms.button>
@endif
@elseif($isHighlightedButton)
@if ($buttonFullWidth)
<x-forms.button @click="modalOpen=true" class="flex gap-2 w-full" isHighlighted wire:target>
{{ $buttonTitle }}
{{ $resolvedButtonTitle }}
</x-forms.button>
@else
<x-forms.button @click="modalOpen=true" class="flex gap-2" isHighlighted wire:target>
{{ $buttonTitle }}
{{ $resolvedButtonTitle }}
</x-forms.button>
@endif
@else
@if ($buttonFullWidth)
<x-forms.button @click="modalOpen=true" class="flex gap-2 w-full" wire:target>
{{ $buttonTitle }}
{{ $resolvedButtonTitle }}
</x-forms.button>
@else
<x-forms.button @click="modalOpen=true" class="flex gap-2" wire:target>
{{ $buttonTitle }}
{{ $resolvedButtonTitle }}
</x-forms.button>
@endif
@endif
@@ -200,8 +210,8 @@
@endif
<template x-teleport="body">
<div x-show="modalOpen"
class="fixed top-0 left-0 z-99 flex items-center justify-center w-screen h-screen p-0 sm:p-4" x-cloak>
<div x-show="modalOpen" class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs">
class="fixed inset-0 z-99 flex min-h-full items-center justify-center overflow-y-auto p-4" x-cloak>
<div x-show="modalOpen" class="absolute inset-0 bg-black/50 backdrop-blur-[2px]">
</div>
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
@@ -209,18 +219,17 @@
x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
class="relative w-full border rounded-none sm:rounded-sm min-w-full lg:min-w-[36rem] max-w-full sm:max-w-[48rem] h-screen sm:h-auto max-h-screen sm:max-h-[calc(100vh-2rem)] bg-neutral-100 border-neutral-400 dark:bg-base dark:border-coolgray-300 flex flex-col">
<div class="flex justify-between items-center py-6 px-7 shrink-0">
<h3 class="pr-8 text-2xl font-bold">{{ $title }}</h3>
<button @click="modalOpen = false; resetModal()"
class="flex absolute top-2 right-2 justify-center items-center w-8 h-8 rounded-full dark:text-white hover:bg-coolgray-300">
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
class="application-settings-form application-settings-section relative flex max-h-[calc(100dvh-2rem)] w-full flex-col lg:min-w-[36rem] lg:max-w-2xl"
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
<header class="flex-nowrap!">
<h3 class="min-w-0 flex-1 truncate">{{ $title }}</h3>
<button type="button" @click="modalOpen = false; resetModal()"
class="flex size-7 shrink-0 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg">
<x-reicon name="x" class="size-4" />
</button>
</div>
<div class="relative w-auto overflow-y-auto px-7 pb-6" style="-webkit-overflow-scrolling: touch;">
</header>
<div class="application-settings-section-body min-h-0 flex-1 overflow-y-auto"
style="-webkit-overflow-scrolling: touch;">
@if (!empty($checkboxes))
<!-- Step 1: Select actions -->
<div x-show="step === 1">
@@ -233,11 +242,7 @@
</div>
@endforeach
<div class="flex flex-wrap gap-2 justify-between mt-4">
<x-forms.button @click="modalOpen = false; resetModal()"
class="w-24 dark:bg-coolgray-200 dark:hover:bg-coolgray-300">
Cancel
</x-forms.button>
<div class="mt-4 flex flex-wrap justify-end gap-2 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
<x-forms.button @click="step++" class="w-auto" isError>
<span x-text="step1ButtonText"></span>
</x-forms.button>
@@ -250,37 +255,25 @@
<x-callout type="danger" title="Warning" class="mb-4">
{!! $warningMessage ?: 'This operation is permanent and cannot be undone. Please think again before proceeding!' !!}
</x-callout>
<div class="mb-4">The following actions will be performed:</div>
<div class="mb-2 text-[12px] font-medium text-neutral-700 dark:text-fg-dim">The following actions will be performed:</div>
<ul class="mb-4 space-y-2">
@foreach ($actions as $action)
<li class="flex items-center text-red-500">
<svg class="shrink-0 mr-2 w-5 h-5" fill="none" stroke="currentColor"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12"></path>
</svg>
<li class="flex items-start gap-2 text-[12px] leading-5 text-red-600 dark:text-red-400">
<x-reicon name="trash" class="mt-0.5 size-3.5 shrink-0" />
<span>{{ $action }}</span>
</li>
@endforeach
@foreach ($checkboxes as $checkbox)
<template x-if="selectedActions.includes('{{ $checkbox['id'] }}')">
<li class="flex items-center text-red-500">
<svg class="shrink-0 mr-2 w-5 h-5" fill="none" stroke="currentColor"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12"></path>
</svg>
<li class="flex items-start gap-2 text-[12px] leading-5 text-red-600 dark:text-red-400">
<x-reicon name="trash" class="mt-0.5 size-3.5 shrink-0" />
<span>{{ $checkbox['label'] }}</span>
</li>
</template>
@if (isset($checkbox['default_warning']))
<template x-if="!selectedActions.includes('{{ $checkbox['id'] }}')">
<li class="flex items-center text-red-500">
<svg class="shrink-0 mr-2 w-5 h-5" fill="none" stroke="currentColor"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12"></path>
</svg>
<li class="flex items-start gap-2 text-[12px] leading-5 text-red-600 dark:text-red-400">
<x-reicon name="trash" class="mt-0.5 size-3.5 shrink-0" />
<span>{{ $checkbox['default_warning'] }}</span>
</li>
</template>
@@ -290,8 +283,8 @@
@if (!$disableTwoStepConfirmation)
@if ($confirmWithText)
<div class="mb-4">
<h4 class="mb-2 text-lg font-semibold">Confirm Actions</h4>
<p class="mb-2 text-sm">{{ $confirmationLabel }}</p>
<h4 class="mb-1 text-[12px] font-semibold">Confirm actions</h4>
<p class="mb-2 text-[12px] leading-5 text-neutral-500 dark:text-fg-dim">{{ $confirmationLabel }}</p>
<div class="relative mb-2" x-data="{ decodedText: confirmationText }">
<div class="relative">
<input type="text" x-model="decodedText" readonly class="input">
@@ -310,28 +303,26 @@
</div>
<label for="userConfirmationText"
class="block mt-4 text-sm font-medium text-gray-700 dark:text-gray-300">
class="mt-4 mb-1.5 block text-[12px] font-medium text-neutral-700 dark:text-fg-dim">
{{ $shortConfirmationLabel }}
</label>
<input type="text" x-model="userConfirmationText"
class="p-2 mt-1 px-3 w-full rounded-sm input">
<input type="text" x-model="userConfirmationText" class="input w-full">
</div>
@endif
@endif
<div class="flex flex-wrap gap-2 justify-between mt-4">
<div class="mt-4 flex flex-wrap justify-end gap-2 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
@if (!empty($checkboxes))
<x-forms.button @click="step--"
class="w-24 dark:bg-coolgray-200 dark:hover:bg-coolgray-300">
<x-forms.button @click="step--">
Back
</x-forms.button>
@else
<x-forms.button @click="modalOpen = false; resetModal()"
class="w-24 dark:bg-coolgray-200 dark:hover:bg-coolgray-300">
<x-forms.button @click="modalOpen = false; resetModal()">
Cancel
</x-forms.button>
@endif
<x-forms.button
:showLoadingIndicator="false"
x-bind:disabled="submitting || (!disableTwoStepConfirmation && confirmWithText && userConfirmationText !==
confirmationText)"
class="w-auto" isError
@@ -352,8 +343,8 @@
});
}
">
<span x-show="!submitting" x-text="step2ButtonText"></span>
<x-loading x-show="submitting" text="Processing..." />
<x-loading-on-button x-show="submitting" x-cloak />
<span x-text="step2ButtonText"></span>
</x-forms.button>
</div>
</div>
@@ -386,12 +377,12 @@
@enderror
</div>
<div class="flex flex-wrap gap-2 justify-between mt-4">
<x-forms.button @click="step--"
class="w-24 dark:bg-coolgray-200 dark:hover:bg-coolgray-300">
<div class="mt-4 flex flex-wrap justify-end gap-2 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
<x-forms.button @click="step--">
Back
</x-forms.button>
<x-forms.button x-bind:disabled="!password || submitting" class="w-auto" isError
<x-forms.button :showLoadingIndicator="false"
x-bind:disabled="!password || submitting" class="w-auto" isError
@click="
if (dispatchEvent) {
$wire.dispatch(dispatchEventType, dispatchEventMessage);
@@ -410,8 +401,8 @@
submitting = false;
});
">
<span x-show="!submitting" x-text="step3ButtonText"></span>
<x-loading x-show="submitting" text="Processing..." />
<x-loading-on-button x-show="submitting" x-cloak />
<span x-text="step3ButtonText"></span>
</x-forms.button>
</div>
</div>
@@ -9,18 +9,23 @@
'closeOutside' => true,
'isFullWidth' => false,
'wireIgnore' => true,
// Optional Livewire bool property to entangle open state (survives Livewire re-renders).
'wireOpen' => null,
'contentClicks' => true,
'isLarge' => false,
])
@php
$modalId = 'modal-' . uniqid();
@endphp
<div x-data="{ modalOpen: false }"
<div x-data="{ modalOpen: @if ($wireOpen) $wire.entangle(@js($wireOpen)) @else false @endif }"
x-init="$watch('modalOpen', value => { if (!value) { $wire.dispatch('modalClosed') } })"
:class="{ 'z-40': modalOpen }" @keydown.window.escape="modalOpen=false"
class="relative w-auto h-auto" @close-modal.window="modalOpen=false" @if ($wireIgnore) wire:ignore @endif>
{{ $attributes->class(['relative', $isFullWidth ? 'h-full w-full' : 'h-auto w-auto']) }}
@close-modal.window="modalOpen=false" @if ($wireIgnore) wire:ignore @endif>
@if ($content)
<div @click="modalOpen=true">
<div @if ($contentClicks) @click="modalOpen=true" @endif @class(['h-full w-full' => $isFullWidth])>
{{ $content }}
</div>
@else
@@ -41,7 +46,7 @@
<div x-show="modalOpen" x-transition:enter="ease-out duration-100" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
class="absolute inset-0 w-full h-full bg-black/50 backdrop-blur-[2px]"></div>
<div @if ($closeOutside) @click.self="modalOpen=false" @endif class="relative flex min-h-full items-start justify-center p-4 sm:items-center">
<div id="{{ $modalId }}" x-show="modalOpen" x-trap.inert.noscroll="modalOpen"
x-transition:enter="ease-out duration-100"
@@ -50,18 +55,25 @@
x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
class="relative flex max-h-[calc(100dvh-2rem)] w-full flex-col overflow-hidden rounded-sm border border-neutral-200 bg-white drop-shadow-sm dark:border-coolgray-300 dark:bg-base lg:w-auto lg:min-w-2xl lg:max-w-4xl">
<div class="flex items-center justify-between py-6 px-6 shrink-0">
<h3 class="text-2xl font-bold">{{ $title }}</h3>
<button @click="modalOpen=false"
class="absolute cursor-pointer top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base">
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
@class([
'application-settings-form application-settings-section relative flex max-h-[calc(100dvh-2rem)] w-full flex-col overflow-hidden',
'lg:w-[95vw]! lg:max-w-7xl!' => $isLarge,
'lg:w-auto lg:min-w-2xl lg:max-w-4xl' => ! $isLarge,
])
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
<header class="flex-nowrap!">
<h3 class="min-w-0 flex-1 truncate">{{ $title }}</h3>
@isset($headerActions)
<div class="flex shrink-0 items-center gap-2">
{{ $headerActions }}
</div>
@endisset
<button type="button" @click="modalOpen=false"
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-neutral-500 outline-0 transition-colors hover:bg-neutral-100 hover:text-black focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg">
<x-reicon name="x" class="size-4" />
</button>
</div>
<div class="relative min-h-0 flex-1 overflow-y-auto px-6 pb-6 pt-1"
</header>
<div class="application-settings-section-body min-h-0 flex-1 overflow-y-auto"
style="-webkit-overflow-scrolling: touch;">
{{ $slot }}
</div>
Loaded 100 of 1274 files, more files were not shown because too many files have changed in this diff. Show more