mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-15 16:14:35 +00:00
Merge remote-tracking branch 'origin/next' into feat/noindex-domains
This commit is contained in:
commit
003e44d123
1274 files changed
+156179
-25249
No files matched your search
+3742
-10
File diff suppressed because it is too large.
Load diff
+79
-23
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 }
|
||||
@@ -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';
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function csrfToken(): string {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ?? '';
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
declare module '*.css';
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'">
|
||||
|
||||
@@ -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>
|
||||
@@ -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
Reference in new issue
Block a user