diff --git a/tsunami/app/hooks.go b/tsunami/app/hooks.go index c53767f6a0..6b6ebbd36c 100644 --- a/tsunami/app/hooks.go +++ b/tsunami/app/hooks.go @@ -6,9 +6,12 @@ package app import ( "context" "fmt" + "log" "time" + "github.com/google/uuid" "github.com/wavetermdev/waveterm/tsunami/engine" + "github.com/wavetermdev/waveterm/tsunami/rpctypes" "github.com/wavetermdev/waveterm/tsunami/util" "github.com/wavetermdev/waveterm/tsunami/vdom" ) @@ -186,3 +189,113 @@ func UseAfter(duration time.Duration, timeoutFn func(), deps []any) { } }, deps) } + +// ModalConfig contains all configuration options for modals +type ModalConfig struct { + Icon string `json:"icon,omitempty"` // Optional icon to display (emoji or icon name) + Title string `json:"title"` // Modal title + Text string `json:"text,omitempty"` // Optional body text + OkText string `json:"oktext,omitempty"` // Optional OK button text (defaults to "OK") + CancelText string `json:"canceltext,omitempty"` // Optional Cancel button text for confirm modals (defaults to "Cancel") + OnClose func() `json:"-"` // Optional callback for alert modals when dismissed + OnResult func(bool) `json:"-"` // Optional callback for confirm modals with the result (true = confirmed, false = cancelled) +} + +// UseAlertModal returns a boolean indicating if the modal is open and a function to trigger it +func UseAlertModal() (modalOpen bool, triggerAlert func(config ModalConfig)) { + isOpen := UseLocal(false) + + trigger := func(config ModalConfig) { + if isOpen.Get() { + log.Printf("warning: UseAlertModal trigger called while modal is already open") + if config.OnClose != nil { + go func() { + defer func() { + util.PanicHandler("UseAlertModal callback goroutine", recover()) + }() + time.Sleep(10 * time.Millisecond) + config.OnClose() + }() + } + return + } + isOpen.Set(true) + + // Create modal config for backend + modalId := uuid.New().String() + backendConfig := rpctypes.ModalConfig{ + ModalId: modalId, + ModalType: "alert", + Icon: config.Icon, + Title: config.Title, + Text: config.Text, + OkText: config.OkText, + CancelText: config.CancelText, + } + + // Show modal and wait for result in a goroutine + go func() { + defer func() { + util.PanicHandler("UseAlertModal goroutine", recover()) + }() + resultChan := engine.GetDefaultClient().ShowModal(backendConfig) + <-resultChan // Wait for result (always dismissed for alerts) + isOpen.Set(false) + if config.OnClose != nil { + config.OnClose() + } + }() + } + + return isOpen.Get(), trigger +} + +// UseConfirmModal returns a boolean indicating if the modal is open and a function to trigger it +func UseConfirmModal() (modalOpen bool, triggerConfirm func(config ModalConfig)) { + isOpen := UseLocal(false) + + trigger := func(config ModalConfig) { + if isOpen.Get() { + log.Printf("warning: UseConfirmModal trigger called while modal is already open") + if config.OnResult != nil { + go func() { + defer func() { + util.PanicHandler("UseConfirmModal callback goroutine", recover()) + }() + time.Sleep(10 * time.Millisecond) + config.OnResult(false) + }() + } + return + } + isOpen.Set(true) + + // Create modal config for backend + modalId := uuid.New().String() + backendConfig := rpctypes.ModalConfig{ + ModalId: modalId, + ModalType: "confirm", + Icon: config.Icon, + Title: config.Title, + Text: config.Text, + OkText: config.OkText, + CancelText: config.CancelText, + } + + // Show modal and wait for result in a goroutine + go func() { + defer func() { + util.PanicHandler("UseConfirmModal goroutine", recover()) + }() + resultChan := engine.GetDefaultClient().ShowModal(backendConfig) + result := <-resultChan + isOpen.Set(false) + if config.OnResult != nil { + config.OnResult(result) + } + }() + } + + return isOpen.Get(), trigger +} + diff --git a/tsunami/demo/modaltest/app.go b/tsunami/demo/modaltest/app.go new file mode 100644 index 0000000000..e2ca335054 --- /dev/null +++ b/tsunami/demo/modaltest/app.go @@ -0,0 +1,156 @@ +package main + +import ( + "github.com/wavetermdev/waveterm/tsunami/app" + "github.com/wavetermdev/waveterm/tsunami/vdom" +) + +const AppTitle = "Modal Test (Tsunami Demo)" +const AppShortDesc = "Test alert and confirm modals in Tsunami" + +var App = app.DefineComponent("App", func(_ struct{}) any { + // State to track modal results + alertResult := app.UseLocal("") + confirmResult := app.UseLocal("") + + // Hook for alert modal + alertOpen, triggerAlert := app.UseAlertModal() + + // Hook for confirm modal + confirmOpen, triggerConfirm := app.UseConfirmModal() + + // Event handlers for alert + handleShowAlert := func() { + triggerAlert(app.ModalConfig{ + Icon: "⚠️", + Title: "Alert Message", + Text: "This is an alert modal. Click OK to dismiss.", + OnClose: func() { + alertResult.Set("Alert dismissed") + }, + }) + } + + handleShowAlertSimple := func() { + triggerAlert(app.ModalConfig{ + Title: "Simple Alert", + Text: "This alert has no icon and custom OK text.", + OkText: "Got it!", + OnClose: func() { + alertResult.Set("Simple alert dismissed") + }, + }) + } + + // Event handlers for confirm + handleShowConfirm := func() { + triggerConfirm(app.ModalConfig{ + Icon: "❓", + Title: "Confirm Action", + Text: "Do you want to proceed with this action?", + OnResult: func(confirmed bool) { + if confirmed { + confirmResult.Set("User confirmed the action") + } else { + confirmResult.Set("User cancelled the action") + } + }, + }) + } + + handleShowConfirmCustom := func() { + triggerConfirm(app.ModalConfig{ + Icon: "🗑️", + Title: "Delete Item", + Text: "Are you sure you want to delete this item? This action cannot be undone.", + OkText: "Delete", + CancelText: "Keep", + OnResult: func(confirmed bool) { + if confirmed { + confirmResult.Set("Item deleted") + } else { + confirmResult.Set("Item kept") + } + }, + }) + } + + // Read state values + currentAlertResult := alertResult.Get() + currentConfirmResult := confirmResult.Get() + + return vdom.H("div", map[string]any{ + "className": "max-w-4xl mx-auto p-8", + }, + vdom.H("h1", map[string]any{ + "className": "text-3xl font-bold mb-6 text-white", + }, "Tsunami Modal Test"), + + // Alert Modal Section + vdom.H("div", map[string]any{ + "className": "mb-8 p-6 bg-gray-800 rounded-lg border border-gray-700", + }, + vdom.H("h2", map[string]any{ + "className": "text-2xl font-semibold mb-4 text-white", + }, "Alert Modals"), + vdom.H("div", map[string]any{ + "className": "flex gap-4 mb-4", + }, + vdom.H("button", map[string]any{ + "className": "px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed", + "onClick": handleShowAlert, + "disabled": alertOpen, + }, "Show Alert with Icon"), + vdom.H("button", map[string]any{ + "className": "px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed", + "onClick": handleShowAlertSimple, + "disabled": alertOpen, + }, "Show Simple Alert"), + ), + vdom.If(currentAlertResult != "", vdom.H("div", map[string]any{ + "className": "mt-4 p-3 bg-gray-700 rounded text-gray-200", + }, "Result: ", currentAlertResult)), + ), + + // Confirm Modal Section + vdom.H("div", map[string]any{ + "className": "mb-8 p-6 bg-gray-800 rounded-lg border border-gray-700", + }, + vdom.H("h2", map[string]any{ + "className": "text-2xl font-semibold mb-4 text-white", + }, "Confirm Modals"), + vdom.H("div", map[string]any{ + "className": "flex gap-4 mb-4", + }, + vdom.H("button", map[string]any{ + "className": "px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed", + "onClick": handleShowConfirm, + "disabled": confirmOpen, + }, "Show Confirm Modal"), + vdom.H("button", map[string]any{ + "className": "px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed", + "onClick": handleShowConfirmCustom, + "disabled": confirmOpen, + }, "Show Delete Confirm"), + ), + vdom.If(currentConfirmResult != "", vdom.H("div", map[string]any{ + "className": "mt-4 p-3 bg-gray-700 rounded text-gray-200", + }, "Result: ", currentConfirmResult)), + ), + + // Status info + vdom.H("div", map[string]any{ + "className": "p-6 bg-gray-800 rounded-lg border border-gray-700", + }, + vdom.H("h2", map[string]any{ + "className": "text-2xl font-semibold mb-4 text-white", + }, "Modal Status"), + vdom.H("div", map[string]any{ + "className": "text-gray-300", + }, + vdom.H("div", nil, "Alert Modal Open: ", vdom.IfElse(alertOpen, "Yes", "No")), + vdom.H("div", nil, "Confirm Modal Open: ", vdom.IfElse(confirmOpen, "Yes", "No")), + ), + ), + ) +}) diff --git a/tsunami/demo/modaltest/go.mod b/tsunami/demo/modaltest/go.mod new file mode 100644 index 0000000000..a2c875ebf0 --- /dev/null +++ b/tsunami/demo/modaltest/go.mod @@ -0,0 +1,12 @@ +module github.com/wavetermdev/waveterm/tsunami/demo/modaltest + +go 1.24.6 + +require github.com/wavetermdev/waveterm/tsunami v0.0.0-00010101000000-000000000000 + +require ( + github.com/google/uuid v1.6.0 // indirect + github.com/outrigdev/goid v0.3.0 // indirect +) + +replace github.com/wavetermdev/waveterm/tsunami => /Users/mike/work/waveterm/tsunami diff --git a/tsunami/demo/modaltest/go.sum b/tsunami/demo/modaltest/go.sum new file mode 100644 index 0000000000..4c44991dfc --- /dev/null +++ b/tsunami/demo/modaltest/go.sum @@ -0,0 +1,4 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/outrigdev/goid v0.3.0 h1:t/otQD3EXc45cLtQVPUnNgEyRaTQA4cPeu3qVcrsIws= +github.com/outrigdev/goid v0.3.0/go.mod h1:hEH7f27ypN/GHWt/7gvkRoFYR0LZizfUBIAbak4neVE= diff --git a/tsunami/demo/modaltest/static/tw.css b/tsunami/demo/modaltest/static/tw.css new file mode 100644 index 0000000000..744d5af20b --- /dev/null +++ b/tsunami/demo/modaltest/static/tw.css @@ -0,0 +1,1308 @@ +/*! tailwindcss v4.1.16 | MIT License | https://tailwindcss.com */ +@layer properties; +@layer theme, base, components, utilities; +@layer theme { + :root, :host { + --font-sans: "Inter", sans-serif; + --font-mono: "Hack", monospace; + --color-red-100: oklch(93.6% 0.032 17.717); + --color-red-500: oklch(63.7% 0.237 25.331); + --color-red-600: oklch(57.7% 0.245 27.325); + --color-red-700: oklch(50.5% 0.213 27.518); + --color-red-800: oklch(44.4% 0.177 26.899); + --color-green-600: oklch(62.7% 0.194 149.214); + --color-green-700: oklch(52.7% 0.154 150.069); + --color-blue-500: oklch(62.3% 0.214 259.815); + --color-blue-600: oklch(54.6% 0.245 262.881); + --color-blue-700: oklch(48.8% 0.243 264.376); + --color-gray-200: oklch(92.8% 0.006 264.531); + --color-gray-300: oklch(87.2% 0.01 258.338); + --color-gray-500: oklch(55.1% 0.027 264.364); + --color-gray-600: oklch(44.6% 0.03 256.802); + --color-gray-700: oklch(37.3% 0.034 259.733); + --color-gray-800: oklch(27.8% 0.033 256.848); + --color-black: #000; + --color-white: #fff; + --spacing: 0.25rem; + --container-md: 28rem; + --container-4xl: 56rem; + --text-sm: 0.875rem; + --text-sm--line-height: calc(1.25 / 0.875); + --text-base: 1rem; + --text-base--line-height: calc(1.5 / 1); + --text-lg: 1.125rem; + --text-lg--line-height: calc(1.75 / 1.125); + --text-xl: 1.25rem; + --text-xl--line-height: calc(1.75 / 1.25); + --text-2xl: 1.5rem; + --text-2xl--line-height: calc(2 / 1.5); + --text-3xl: 1.875rem; + --text-3xl--line-height: calc(2.25 / 1.875); + --text-4xl: 2.25rem; + --text-4xl--line-height: calc(2.5 / 2.25); + --font-weight-semibold: 600; + --font-weight-bold: 700; + --leading-relaxed: 1.625; + --radius-lg: 0.5rem; + --ease-in: cubic-bezier(0.4, 0, 1, 1); + --ease-out: cubic-bezier(0, 0, 0.2, 1); + --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + --default-font-family: var(--font-sans); + --default-mono-font-family: var(--font-mono); + --radius: 8px; + --color-background: rgb(34, 34, 34); + --color-primary: rgb(247, 247, 247); + --color-secondary: rgba(215, 218, 224, 0.7); + --color-muted: rgba(215, 218, 224, 0.5); + --color-accent-300: rgb(110, 231, 133); + --color-panel: rgba(255, 255, 255, 0.12); + --color-border: rgba(255, 255, 255, 0.16); + --color-accent: rgb(88, 193, 66); + } +} +@layer base { + *, ::after, ::before, ::backdrop, ::file-selector-button { + box-sizing: border-box; + margin: 0; + padding: 0; + border: 0 solid; + } + html, :host { + line-height: 1.5; + -webkit-text-size-adjust: 100%; + tab-size: 4; + font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); + font-feature-settings: var(--default-font-feature-settings, normal); + font-variation-settings: var(--default-font-variation-settings, normal); + -webkit-tap-highlight-color: transparent; + } + hr { + height: 0; + color: inherit; + border-top-width: 1px; + } + abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; + } + h1, h2, h3, h4, h5, h6 { + font-size: inherit; + font-weight: inherit; + } + a { + color: inherit; + -webkit-text-decoration: inherit; + text-decoration: inherit; + } + b, strong { + font-weight: bolder; + } + code, kbd, samp, pre { + font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); + font-feature-settings: var(--default-mono-font-feature-settings, normal); + font-variation-settings: var(--default-mono-font-variation-settings, normal); + font-size: 1em; + } + small { + font-size: 80%; + } + sub, sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; + } + sub { + bottom: -0.25em; + } + sup { + top: -0.5em; + } + table { + text-indent: 0; + border-color: inherit; + border-collapse: collapse; + } + :-moz-focusring { + outline: auto; + } + progress { + vertical-align: baseline; + } + summary { + display: list-item; + } + ol, ul, menu { + list-style: none; + } + img, svg, video, canvas, audio, iframe, embed, object { + display: block; + vertical-align: middle; + } + img, video { + max-width: 100%; + height: auto; + } + button, input, select, optgroup, textarea, ::file-selector-button { + font: inherit; + font-feature-settings: inherit; + font-variation-settings: inherit; + letter-spacing: inherit; + color: inherit; + border-radius: 0; + background-color: transparent; + opacity: 1; + } + :where(select:is([multiple], [size])) optgroup { + font-weight: bolder; + } + :where(select:is([multiple], [size])) optgroup option { + padding-inline-start: 20px; + } + ::file-selector-button { + margin-inline-end: 4px; + } + ::placeholder { + opacity: 1; + } + @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { + ::placeholder { + color: currentcolor; + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, currentcolor 50%, transparent); + } + } + } + textarea { + resize: vertical; + } + ::-webkit-search-decoration { + -webkit-appearance: none; + } + ::-webkit-date-and-time-value { + min-height: 1lh; + text-align: inherit; + } + ::-webkit-datetime-edit { + display: inline-flex; + } + ::-webkit-datetime-edit-fields-wrapper { + padding: 0; + } + ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { + padding-block: 0; + } + ::-webkit-calendar-picker-indicator { + line-height: 1; + } + :-moz-ui-invalid { + box-shadow: none; + } + button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button { + appearance: button; + } + ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { + height: auto; + } + [hidden]:where(:not([hidden="until-found"])) { + display: none !important; + } +} +@layer utilities { + .collapse { + visibility: collapse; + } + .invisible { + visibility: hidden; + } + .visible { + visibility: visible; + } + .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border-width: 0; + } + .not-sr-only { + position: static; + width: auto; + height: auto; + padding: 0; + margin: 0; + overflow: visible; + clip-path: none; + white-space: normal; + } + .absolute { + position: absolute; + } + .fixed { + position: fixed; + } + .relative { + position: relative; + } + .static { + position: static; + } + .sticky { + position: sticky; + } + .inset-0 { + inset: calc(var(--spacing) * 0); + } + .isolate { + isolation: isolate; + } + .isolation-auto { + isolation: auto; + } + .z-50 { + z-index: 50; + } + .container { + width: 100%; + @media (width >= 40rem) { + max-width: 40rem; + } + @media (width >= 48rem) { + max-width: 48rem; + } + @media (width >= 64rem) { + max-width: 64rem; + } + @media (width >= 80rem) { + max-width: 80rem; + } + @media (width >= 96rem) { + max-width: 96rem; + } + } + .mx-4 { + margin-inline: calc(var(--spacing) * 4); + } + .mx-auto { + margin-inline: auto; + } + .my-6 { + margin-block: calc(var(--spacing) * 6); + } + .mt-2 { + margin-top: calc(var(--spacing) * 2); + } + .mt-3 { + margin-top: calc(var(--spacing) * 3); + } + .mt-4 { + margin-top: calc(var(--spacing) * 4); + } + .mt-5 { + margin-top: calc(var(--spacing) * 5); + } + .mt-6 { + margin-top: calc(var(--spacing) * 6); + } + .mb-2 { + margin-bottom: calc(var(--spacing) * 2); + } + .mb-3 { + margin-bottom: calc(var(--spacing) * 3); + } + .mb-4 { + margin-bottom: calc(var(--spacing) * 4); + } + .mb-6 { + margin-bottom: calc(var(--spacing) * 6); + } + .mb-8 { + margin-bottom: calc(var(--spacing) * 8); + } + .ml-4 { + margin-left: calc(var(--spacing) * 4); + } + .block { + display: block; + } + .contents { + display: contents; + } + .flex { + display: flex; + } + .flow-root { + display: flow-root; + } + .grid { + display: grid; + } + .hidden { + display: none; + } + .inline { + display: inline; + } + .inline-block { + display: inline-block; + } + .inline-flex { + display: inline-flex; + } + .inline-grid { + display: inline-grid; + } + .inline-table { + display: inline-table; + } + .list-item { + display: list-item; + } + .table { + display: table; + } + .table-caption { + display: table-caption; + } + .table-cell { + display: table-cell; + } + .table-column { + display: table-column; + } + .table-column-group { + display: table-column-group; + } + .table-footer-group { + display: table-footer-group; + } + .table-header-group { + display: table-header-group; + } + .table-row { + display: table-row; + } + .table-row-group { + display: table-row-group; + } + .min-h-full { + min-height: 100%; + } + .min-h-screen { + min-height: 100vh; + } + .w-full { + width: 100%; + } + .max-w-4xl { + max-width: var(--container-4xl); + } + .max-w-md { + max-width: var(--container-md); + } + .max-w-none { + max-width: none; + } + .min-w-full { + min-width: 100%; + } + .shrink { + flex-shrink: 1; + } + .grow { + flex-grow: 1; + } + .border-collapse { + border-collapse: collapse; + } + .translate-none { + translate: none; + } + .scale-3d { + scale: var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z); + } + .transform { + transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); + } + .touch-pinch-zoom { + --tw-pinch-zoom: pinch-zoom; + touch-action: var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,); + } + .resize { + resize: both; + } + .list-inside { + list-style-position: inside; + } + .list-decimal { + list-style-type: decimal; + } + .list-disc { + list-style-type: disc; + } + .flex-col { + flex-direction: column; + } + .flex-wrap { + flex-wrap: wrap; + } + .items-center { + align-items: center; + } + .justify-center { + justify-content: center; + } + .justify-end { + justify-content: flex-end; + } + .gap-3 { + gap: calc(var(--spacing) * 3); + } + .gap-4 { + gap: calc(var(--spacing) * 4); + } + .space-y-1 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse))); + } + } + .space-y-reverse { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 1; + } + } + .space-x-reverse { + :where(& > :not(:last-child)) { + --tw-space-x-reverse: 1; + } + } + .divide-x { + :where(& > :not(:last-child)) { + --tw-divide-x-reverse: 0; + border-inline-style: var(--tw-border-style); + border-inline-start-width: calc(1px * var(--tw-divide-x-reverse)); + border-inline-end-width: calc(1px * calc(1 - var(--tw-divide-x-reverse))); + } + } + .divide-y { + :where(& > :not(:last-child)) { + --tw-divide-y-reverse: 0; + border-bottom-style: var(--tw-border-style); + border-top-style: var(--tw-border-style); + border-top-width: calc(1px * var(--tw-divide-y-reverse)); + border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); + } + } + .divide-y-reverse { + :where(& > :not(:last-child)) { + --tw-divide-y-reverse: 1; + } + } + .truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .overflow-auto { + overflow: auto; + } + .overflow-x-auto { + overflow-x: auto; + } + .rounded { + border-radius: var(--radius); + } + .rounded-lg { + border-radius: var(--radius-lg); + } + .rounded-s { + border-start-start-radius: var(--radius); + border-end-start-radius: var(--radius); + } + .rounded-ss { + border-start-start-radius: var(--radius); + } + .rounded-e { + border-start-end-radius: var(--radius); + border-end-end-radius: var(--radius); + } + .rounded-se { + border-start-end-radius: var(--radius); + } + .rounded-ee { + border-end-end-radius: var(--radius); + } + .rounded-es { + border-end-start-radius: var(--radius); + } + .rounded-t { + border-top-left-radius: var(--radius); + border-top-right-radius: var(--radius); + } + .rounded-l { + border-top-left-radius: var(--radius); + border-bottom-left-radius: var(--radius); + } + .rounded-tl { + border-top-left-radius: var(--radius); + } + .rounded-r { + border-top-right-radius: var(--radius); + border-bottom-right-radius: var(--radius); + } + .rounded-tr { + border-top-right-radius: var(--radius); + } + .rounded-b { + border-bottom-right-radius: var(--radius); + border-bottom-left-radius: var(--radius); + } + .rounded-br { + border-bottom-right-radius: var(--radius); + } + .rounded-bl { + border-bottom-left-radius: var(--radius); + } + .border { + border-style: var(--tw-border-style); + border-width: 1px; + } + .border-x { + border-inline-style: var(--tw-border-style); + border-inline-width: 1px; + } + .border-y { + border-block-style: var(--tw-border-style); + border-block-width: 1px; + } + .border-s { + border-inline-start-style: var(--tw-border-style); + border-inline-start-width: 1px; + } + .border-e { + border-inline-end-style: var(--tw-border-style); + border-inline-end-width: 1px; + } + .border-t { + border-top-style: var(--tw-border-style); + border-top-width: 1px; + } + .border-r { + border-right-style: var(--tw-border-style); + border-right-width: 1px; + } + .border-b { + border-bottom-style: var(--tw-border-style); + border-bottom-width: 1px; + } + .border-l { + border-left-style: var(--tw-border-style); + border-left-width: 1px; + } + .border-l-4 { + border-left-style: var(--tw-border-style); + border-left-width: 4px; + } + .border-border { + border-color: var(--color-border); + } + .border-gray-700 { + border-color: var(--color-gray-700); + } + .border-red-500 { + border-color: var(--color-red-500); + } + .bg-background { + background-color: var(--color-background); + } + .bg-black { + background-color: var(--color-black); + } + .bg-black\/50 { + background-color: color-mix(in srgb, #000 50%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-black) 50%, transparent); + } + } + .bg-blue-600 { + background-color: var(--color-blue-600); + } + .bg-gray-600 { + background-color: var(--color-gray-600); + } + .bg-gray-700 { + background-color: var(--color-gray-700); + } + .bg-gray-800 { + background-color: var(--color-gray-800); + } + .bg-green-600 { + background-color: var(--color-green-600); + } + .bg-panel { + background-color: var(--color-panel); + } + .bg-red-100 { + background-color: var(--color-red-100); + } + .bg-red-600 { + background-color: var(--color-red-600); + } + .bg-repeat { + background-repeat: repeat; + } + .mask-no-clip { + mask-clip: no-clip; + } + .mask-repeat { + mask-repeat: repeat; + } + .p-3 { + padding: calc(var(--spacing) * 3); + } + .p-4 { + padding: calc(var(--spacing) * 4); + } + .p-6 { + padding: calc(var(--spacing) * 6); + } + .p-8 { + padding: calc(var(--spacing) * 8); + } + .px-1 { + padding-inline: calc(var(--spacing) * 1); + } + .px-4 { + padding-inline: calc(var(--spacing) * 4); + } + .py-0\.5 { + padding-block: calc(var(--spacing) * 0.5); + } + .py-2 { + padding-block: calc(var(--spacing) * 2); + } + .pl-4 { + padding-left: calc(var(--spacing) * 4); + } + .text-left { + text-align: left; + } + .font-mono { + font-family: var(--font-mono); + } + .text-2xl { + font-size: var(--text-2xl); + line-height: var(--tw-leading, var(--text-2xl--line-height)); + } + .text-3xl { + font-size: var(--text-3xl); + line-height: var(--tw-leading, var(--text-3xl--line-height)); + } + .text-4xl { + font-size: var(--text-4xl); + line-height: var(--tw-leading, var(--text-4xl--line-height)); + } + .text-base { + font-size: var(--text-base); + line-height: var(--tw-leading, var(--text-base--line-height)); + } + .text-lg { + font-size: var(--text-lg); + line-height: var(--tw-leading, var(--text-lg--line-height)); + } + .text-sm { + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + } + .text-xl { + font-size: var(--text-xl); + line-height: var(--tw-leading, var(--text-xl--line-height)); + } + .leading-relaxed { + --tw-leading: var(--leading-relaxed); + line-height: var(--leading-relaxed); + } + .font-bold { + --tw-font-weight: var(--font-weight-bold); + font-weight: var(--font-weight-bold); + } + .font-semibold { + --tw-font-weight: var(--font-weight-semibold); + font-weight: var(--font-weight-semibold); + } + .text-wrap { + text-wrap: wrap; + } + .text-clip { + text-overflow: clip; + } + .text-ellipsis { + text-overflow: ellipsis; + } + .text-accent { + color: var(--color-accent); + } + .text-gray-200 { + color: var(--color-gray-200); + } + .text-gray-300 { + color: var(--color-gray-300); + } + .text-muted { + color: var(--color-muted); + } + .text-primary { + color: var(--color-primary); + } + .text-red-800 { + color: var(--color-red-800); + } + .text-secondary { + color: var(--color-secondary); + } + .text-white { + color: var(--color-white); + } + .capitalize { + text-transform: capitalize; + } + .lowercase { + text-transform: lowercase; + } + .normal-case { + text-transform: none; + } + .uppercase { + text-transform: uppercase; + } + .italic { + font-style: italic; + } + .not-italic { + font-style: normal; + } + .diagonal-fractions { + --tw-numeric-fraction: diagonal-fractions; + font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); + } + .lining-nums { + --tw-numeric-figure: lining-nums; + font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); + } + .oldstyle-nums { + --tw-numeric-figure: oldstyle-nums; + font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); + } + .ordinal { + --tw-ordinal: ordinal; + font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); + } + .proportional-nums { + --tw-numeric-spacing: proportional-nums; + font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); + } + .slashed-zero { + --tw-slashed-zero: slashed-zero; + font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); + } + .stacked-fractions { + --tw-numeric-fraction: stacked-fractions; + font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); + } + .tabular-nums { + --tw-numeric-spacing: tabular-nums; + font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); + } + .normal-nums { + font-variant-numeric: normal; + } + .line-through { + text-decoration-line: line-through; + } + .no-underline { + text-decoration-line: none; + } + .overline { + text-decoration-line: overline; + } + .underline { + text-decoration-line: underline; + } + .antialiased { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + .subpixel-antialiased { + -webkit-font-smoothing: auto; + -moz-osx-font-smoothing: auto; + } + .shadow { + --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .shadow-xl { + --tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 8px 10px -6px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .inset-ring { + --tw-inset-ring-shadow: inset 0 0 0 1px var(--tw-inset-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .blur { + --tw-blur: blur(8px); + filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); + } + .drop-shadow { + --tw-drop-shadow-size: drop-shadow(0 1px 2px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.1))) drop-shadow(0 1px 1px var(--tw-drop-shadow-color, rgb(0 0 0 / 0.06))); + --tw-drop-shadow: drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow( 0 1px 1px rgb(0 0 0 / 0.06)); + filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); + } + .invert { + --tw-invert: invert(100%); + filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); + } + .filter { + filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); + } + .backdrop-blur { + --tw-backdrop-blur: blur(8px); + -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + } + .backdrop-grayscale { + --tw-backdrop-grayscale: grayscale(100%); + -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + } + .backdrop-invert { + --tw-backdrop-invert: invert(100%); + -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + } + .backdrop-sepia { + --tw-backdrop-sepia: sepia(100%); + -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + } + .backdrop-filter { + -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + } + .ease-in { + --tw-ease: var(--ease-in); + transition-timing-function: var(--ease-in); + } + .ease-in-out { + --tw-ease: var(--ease-in-out); + transition-timing-function: var(--ease-in-out); + } + .ease-out { + --tw-ease: var(--ease-out); + transition-timing-function: var(--ease-out); + } + .divide-x-reverse { + :where(& > :not(:last-child)) { + --tw-divide-x-reverse: 1; + } + } + .ring-inset { + --tw-ring-inset: inset; + } + .hover\:bg-blue-700 { + &:hover { + @media (hover: hover) { + background-color: var(--color-blue-700); + } + } + } + .hover\:bg-gray-700 { + &:hover { + @media (hover: hover) { + background-color: var(--color-gray-700); + } + } + } + .hover\:bg-green-700 { + &:hover { + @media (hover: hover) { + background-color: var(--color-green-700); + } + } + } + .hover\:bg-red-700 { + &:hover { + @media (hover: hover) { + background-color: var(--color-red-700); + } + } + } + .hover\:text-accent-300 { + &:hover { + @media (hover: hover) { + color: var(--color-accent-300); + } + } + } + .focus\:ring-2 { + &:focus { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + } + .focus\:ring-blue-500 { + &:focus { + --tw-ring-color: var(--color-blue-500); + } + } + .focus\:ring-gray-500 { + &:focus { + --tw-ring-color: var(--color-gray-500); + } + } + .focus\:outline-none { + &:focus { + --tw-outline-style: none; + outline-style: none; + } + } + .disabled\:cursor-not-allowed { + &:disabled { + cursor: not-allowed; + } + } + .disabled\:opacity-50 { + &:disabled { + opacity: 50%; + } + } +} +@property --tw-scale-x { + syntax: "*"; + inherits: false; + initial-value: 1; +} +@property --tw-scale-y { + syntax: "*"; + inherits: false; + initial-value: 1; +} +@property --tw-scale-z { + syntax: "*"; + inherits: false; + initial-value: 1; +} +@property --tw-rotate-x { + syntax: "*"; + inherits: false; +} +@property --tw-rotate-y { + syntax: "*"; + inherits: false; +} +@property --tw-rotate-z { + syntax: "*"; + inherits: false; +} +@property --tw-skew-x { + syntax: "*"; + inherits: false; +} +@property --tw-skew-y { + syntax: "*"; + inherits: false; +} +@property --tw-pan-x { + syntax: "*"; + inherits: false; +} +@property --tw-pan-y { + syntax: "*"; + inherits: false; +} +@property --tw-pinch-zoom { + syntax: "*"; + inherits: false; +} +@property --tw-space-y-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-space-x-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-divide-x-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-border-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} +@property --tw-divide-y-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-leading { + syntax: "*"; + inherits: false; +} +@property --tw-font-weight { + syntax: "*"; + inherits: false; +} +@property --tw-ordinal { + syntax: "*"; + inherits: false; +} +@property --tw-slashed-zero { + syntax: "*"; + inherits: false; +} +@property --tw-numeric-figure { + syntax: "*"; + inherits: false; +} +@property --tw-numeric-spacing { + syntax: "*"; + inherits: false; +} +@property --tw-numeric-fraction { + syntax: "*"; + inherits: false; +} +@property --tw-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-inset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-ring-inset { + syntax: "*"; + inherits: false; +} +@property --tw-ring-offset-width { + syntax: ""; + inherits: false; + initial-value: 0px; +} +@property --tw-ring-offset-color { + syntax: "*"; + inherits: false; + initial-value: #fff; +} +@property --tw-ring-offset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-blur { + syntax: "*"; + inherits: false; +} +@property --tw-brightness { + syntax: "*"; + inherits: false; +} +@property --tw-contrast { + syntax: "*"; + inherits: false; +} +@property --tw-grayscale { + syntax: "*"; + inherits: false; +} +@property --tw-hue-rotate { + syntax: "*"; + inherits: false; +} +@property --tw-invert { + syntax: "*"; + inherits: false; +} +@property --tw-opacity { + syntax: "*"; + inherits: false; +} +@property --tw-saturate { + syntax: "*"; + inherits: false; +} +@property --tw-sepia { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-drop-shadow-size { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-blur { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-brightness { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-contrast { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-grayscale { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-hue-rotate { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-invert { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-opacity { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-saturate { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-sepia { + syntax: "*"; + inherits: false; +} +@property --tw-ease { + syntax: "*"; + inherits: false; +} +@layer properties { + @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { + *, ::before, ::after, ::backdrop { + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-scale-z: 1; + --tw-rotate-x: initial; + --tw-rotate-y: initial; + --tw-rotate-z: initial; + --tw-skew-x: initial; + --tw-skew-y: initial; + --tw-pan-x: initial; + --tw-pan-y: initial; + --tw-pinch-zoom: initial; + --tw-space-y-reverse: 0; + --tw-space-x-reverse: 0; + --tw-divide-x-reverse: 0; + --tw-border-style: solid; + --tw-divide-y-reverse: 0; + --tw-leading: initial; + --tw-font-weight: initial; + --tw-ordinal: initial; + --tw-slashed-zero: initial; + --tw-numeric-figure: initial; + --tw-numeric-spacing: initial; + --tw-numeric-fraction: initial; + --tw-shadow: 0 0 #0000; + --tw-shadow-color: initial; + --tw-shadow-alpha: 100%; + --tw-inset-shadow: 0 0 #0000; + --tw-inset-shadow-color: initial; + --tw-inset-shadow-alpha: 100%; + --tw-ring-color: initial; + --tw-ring-shadow: 0 0 #0000; + --tw-inset-ring-color: initial; + --tw-inset-ring-shadow: 0 0 #0000; + --tw-ring-inset: initial; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-offset-shadow: 0 0 #0000; + --tw-blur: initial; + --tw-brightness: initial; + --tw-contrast: initial; + --tw-grayscale: initial; + --tw-hue-rotate: initial; + --tw-invert: initial; + --tw-opacity: initial; + --tw-saturate: initial; + --tw-sepia: initial; + --tw-drop-shadow: initial; + --tw-drop-shadow-color: initial; + --tw-drop-shadow-alpha: 100%; + --tw-drop-shadow-size: initial; + --tw-backdrop-blur: initial; + --tw-backdrop-brightness: initial; + --tw-backdrop-contrast: initial; + --tw-backdrop-grayscale: initial; + --tw-backdrop-hue-rotate: initial; + --tw-backdrop-invert: initial; + --tw-backdrop-opacity: initial; + --tw-backdrop-saturate: initial; + --tw-backdrop-sepia: initial; + --tw-ease: initial; + } + } +} diff --git a/tsunami/engine/clientimpl.go b/tsunami/engine/clientimpl.go index 083c6134da..6f8d638d2b 100644 --- a/tsunami/engine/clientimpl.go +++ b/tsunami/engine/clientimpl.go @@ -5,6 +5,7 @@ package engine import ( "context" + "encoding/json" "fmt" "io/fs" "log" @@ -27,6 +28,11 @@ const TsunamiListenAddrEnvVar = "TSUNAMI_LISTENADDR" const DefaultListenAddr = "localhost:0" const DefaultComponentName = "App" +type ModalState struct { + Config rpctypes.ModalConfig + ResultChan chan bool // Channel to receive the result (true = confirmed/ok, false = cancelled) +} + type ssEvent struct { Event string Data []byte @@ -58,6 +64,10 @@ type ClientImpl struct { StaticFS fs.FS ManifestFileBytes []byte + // for modals + OpenModals map[string]*ModalState // map of modalId to modal state + OpenModalsLock *sync.Mutex + // for notification // Atomics so we never drop "last event" timing info even if wakeCh is full. // 0 means "no pending batch". @@ -73,6 +83,8 @@ func makeClient() *ClientImpl { DoneCh: make(chan struct{}), SSEChannels: make(map[string]chan ssEvent), SSEChannelsLock: &sync.Mutex{}, + OpenModals: make(map[string]*ModalState), + OpenModalsLock: &sync.Mutex{}, UrlHandlerMux: http.NewServeMux(), ServerId: uuid.New().String(), RootElem: vdom.H(DefaultComponentName, nil), @@ -369,3 +381,73 @@ func (c *ClientImpl) SetAppMeta(m AppMeta) { defer c.Lock.Unlock() c.Meta = m } + +// addModalToMap adds a modal to the map and returns the result channel +func (c *ClientImpl) addModalToMap(config rpctypes.ModalConfig) chan bool { + c.OpenModalsLock.Lock() + defer c.OpenModalsLock.Unlock() + + resultChan := make(chan bool, 1) + c.OpenModals[config.ModalId] = &ModalState{ + Config: config, + ResultChan: resultChan, + } + return resultChan +} + +// ShowModal displays a modal and returns a channel that will receive the result +func (c *ClientImpl) ShowModal(config rpctypes.ModalConfig) chan bool { + resultChan := c.addModalToMap(config) + + data, err := json.Marshal(config) + if err != nil { + log.Printf("failed to marshal modal config: %v", err) + c.CloseModal(config.ModalId, false) + return resultChan + } + + err = c.SendSSEvent(ssEvent{Event: "showmodal", Data: data}) + if err != nil { + log.Printf("failed to send modal SSE event: %v", err) + c.CloseModal(config.ModalId, false) + return resultChan + } + + return resultChan +} + +// removeModalFromMap removes a modal from the map and returns its state +func (c *ClientImpl) removeModalFromMap(modalId string) *ModalState { + c.OpenModalsLock.Lock() + defer c.OpenModalsLock.Unlock() + + modalState, exists := c.OpenModals[modalId] + if exists { + delete(c.OpenModals, modalId) + } + return modalState +} + +// CloseModal closes a modal with the given result +func (c *ClientImpl) CloseModal(modalId string, result bool) { + modalState := c.removeModalFromMap(modalId) + if modalState != nil { + modalState.ResultChan <- result + close(modalState.ResultChan) + } +} + +// CloseAllModals closes all open modals with cancelled result +// This is called when the FE requests a resync (page refresh or new client) +func (c *ClientImpl) CloseAllModals() { + c.OpenModalsLock.Lock() + modalIds := make([]string, 0, len(c.OpenModals)) + for modalId := range c.OpenModals { + modalIds = append(modalIds, modalId) + } + c.OpenModalsLock.Unlock() + + for _, modalId := range modalIds { + c.CloseModal(modalId, false) + } +} diff --git a/tsunami/engine/serverhandlers.go b/tsunami/engine/serverhandlers.go index 56005753a6..43f4b47ebe 100644 --- a/tsunami/engine/serverhandlers.go +++ b/tsunami/engine/serverhandlers.go @@ -57,6 +57,7 @@ func (h *httpHandlers) registerHandlers(mux *http.ServeMux, opts handlerOpts) { mux.HandleFunc("/api/config", h.handleConfig) mux.HandleFunc("/api/schemas", h.handleSchemas) mux.HandleFunc("/api/manifest", h.handleManifest(opts.ManifestFile)) + mux.HandleFunc("/api/modalresult", h.handleModalResult) mux.HandleFunc("/dyn/", h.handleDynContent) // Add handler for static files at /static/ path @@ -162,6 +163,11 @@ func (h *httpHandlers) processFrontendUpdate(feUpdate *rpctypes.VDomFrontendUpda h.Client.Root.RenderTs = feUpdate.Ts + // Close all open modals on resync (e.g., page refresh) + if feUpdate.Resync { + h.Client.CloseAllModals() + } + // run events h.Client.RunEvents(feUpdate.Events) // update refs @@ -309,6 +315,40 @@ func (h *httpHandlers) handleSchemas(w http.ResponseWriter, r *http.Request) { } } +func (h *httpHandlers) handleModalResult(w http.ResponseWriter, r *http.Request) { + defer func() { + panicErr := util.PanicHandler("handleModalResult", recover()) + if panicErr != nil { + http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError) + } + }() + + setNoCacheHeaders(w) + + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, fmt.Sprintf("failed to read request body: %v", err), http.StatusBadRequest) + return + } + + var result rpctypes.ModalResult + if err := json.Unmarshal(body, &result); err != nil { + http.Error(w, fmt.Sprintf("failed to parse JSON: %v", err), http.StatusBadRequest) + return + } + + h.Client.CloseModal(result.ModalId, result.Confirm) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]any{"success": true}) +} + func (h *httpHandlers) handleDynContent(w http.ResponseWriter, r *http.Request) { defer func() { panicErr := util.PanicHandler("handleDynContent", recover()) diff --git a/tsunami/frontend/src/element/modals.tsx b/tsunami/frontend/src/element/modals.tsx new file mode 100644 index 0000000000..3550d8ebb6 --- /dev/null +++ b/tsunami/frontend/src/element/modals.tsx @@ -0,0 +1,97 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import React, { useEffect } from "react"; + +interface ModalProps { + config: ModalConfig; + onClose: (confirmed: boolean) => void; +} + +export const AlertModal: React.FC = ({ config, onClose }) => { + const handleOk = () => { + onClose(true); + }; + + // Handle escape key + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(false); + } + }; + window.addEventListener("keydown", handleEscape); + return () => window.removeEventListener("keydown", handleEscape); + }, [onClose]); + + return ( +
+
+
+
+ {config.icon &&
{config.icon}
} +

{config.title}

+
+ {config.text &&

{config.text}

} +
+ +
+
+
+
+ ); +}; + +export const ConfirmModal: React.FC = ({ config, onClose }) => { + const handleConfirm = () => { + onClose(true); + }; + + const handleCancel = () => { + onClose(false); + }; + + // Handle escape key + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(false); + } + }; + window.addEventListener("keydown", handleEscape); + return () => window.removeEventListener("keydown", handleEscape); + }, [onClose]); + + return ( +
+
+
+
+ {config.icon &&
{config.icon}
} +

{config.title}

+
+ {config.text &&

{config.text}

} +
+ + +
+
+
+
+ ); +}; diff --git a/tsunami/frontend/src/model/tsunami-model.tsx b/tsunami/frontend/src/model/tsunami-model.tsx index 5639904fb3..9ad6ac0a31 100644 --- a/tsunami/frontend/src/model/tsunami-model.tsx +++ b/tsunami/frontend/src/model/tsunami-model.tsx @@ -109,6 +109,7 @@ export class TsunamiModel { cachedTitle: string | null = null; cachedShortDesc: string | null = null; reason: string | null = null; + currentModal: jotai.PrimitiveAtom = jotai.atom(null); constructor() { this.clientId = getOrCreateClientId(); @@ -139,6 +140,16 @@ export class TsunamiModel { this.queueUpdate(true, "asyncinitiation"); }); + this.serverEventSource.addEventListener("showmodal", (event: MessageEvent) => { + dlog("showmodal SSE event received", event); + try { + const config: ModalConfig = JSON.parse(event.data); + getDefaultStore().set(this.currentModal, config); + } catch (e) { + console.error("Failed to parse modal config:", e); + } + }); + this.serverEventSource.addEventListener("error", (event) => { console.error("SSE connection error:", event); }); @@ -653,4 +664,30 @@ export class TsunamiModel { } return feUpdate; } + + async sendModalResult(modalId: string, confirm: boolean) { + const result: ModalResult = { + modalid: modalId, + confirm: confirm, + }; + + try { + const response = await fetch("/api/modalresult", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(result), + }); + + if (!response.ok) { + console.error("Failed to send modal result:", response.statusText); + } + } catch (error) { + console.error("Error sending modal result:", error); + } + + // Clear the current modal + getDefaultStore().set(this.currentModal, null); + } } diff --git a/tsunami/frontend/src/types/vdom.d.ts b/tsunami/frontend/src/types/vdom.d.ts index 3a4421ead2..58651fca5f 100644 --- a/tsunami/frontend/src/types/vdom.d.ts +++ b/tsunami/frontend/src/types/vdom.d.ts @@ -79,6 +79,23 @@ type VDomMessage = { params?: any[]; }; +// rpctypes.ModalConfig +type ModalConfig = { + modalid: string; + modaltype: "alert" | "confirm"; + icon?: string; + title: string; + text?: string; + oktext?: string; + canceltext?: string; +}; + +// rpctypes.ModalResult +type ModalResult = { + modalid: string; + confirm: boolean; +}; + // vdom.VDomRef type VDomRef = { type: "ref"; diff --git a/tsunami/frontend/src/vdom.tsx b/tsunami/frontend/src/vdom.tsx index 97219fcb72..37de4c0f1c 100644 --- a/tsunami/frontend/src/vdom.tsx +++ b/tsunami/frontend/src/vdom.tsx @@ -7,6 +7,7 @@ import * as jotai from "jotai"; import * as React from "react"; import { twMerge } from "tailwind-merge"; +import { AlertModal, ConfirmModal } from "@/element/modals"; import { Markdown } from "@/element/markdown"; import { getTextChildren } from "@/model/model-utils"; import type { TsunamiModel } from "@/model/tsunami-model"; @@ -362,10 +363,27 @@ function VDomInnerView({ model }: VDomViewProps) { function VDomView({ model }: VDomViewProps) { let viewRef = React.useRef(null); let contextActive = jotai.useAtomValue(model.contextActive); + let currentModal = jotai.useAtomValue(model.currentModal); model.viewRef = viewRef; + + const handleModalClose = React.useCallback( + (confirmed: boolean) => { + if (currentModal) { + model.sendModalResult(currentModal.modalid, confirmed); + } + }, + [model, currentModal] + ); + return (
{contextActive ? : null} + {currentModal && currentModal.modaltype === "alert" && ( + + )} + {currentModal && currentModal.modaltype === "confirm" && ( + + )}
); } diff --git a/tsunami/rpctypes/protocoltypes.go b/tsunami/rpctypes/protocoltypes.go index 4385b6f731..f2728f0bb6 100644 --- a/tsunami/rpctypes/protocoltypes.go +++ b/tsunami/rpctypes/protocoltypes.go @@ -189,3 +189,20 @@ type VDomMessage struct { StackTrace string `json:"stacktrace,omitempty"` Params []any `json:"params,omitempty"` } + +// ModalConfig contains all configuration options for modals +type ModalConfig struct { + ModalId string `json:"modalid"` // Unique identifier for the modal + ModalType string `json:"modaltype"` // "alert" or "confirm" + Icon string `json:"icon,omitempty"` // Optional icon to display (emoji or icon name) + Title string `json:"title"` // Modal title + Text string `json:"text,omitempty"` // Optional body text + OkText string `json:"oktext,omitempty"` // Optional OK button text (defaults to "OK") + CancelText string `json:"canceltext,omitempty"` // Optional Cancel button text for confirm modals (defaults to "Cancel") +} + +// ModalResult contains the result of a modal interaction +type ModalResult struct { + ModalId string `json:"modalid"` // ID of the modal + Confirm bool `json:"confirm"` // true = confirmed/ok, false = cancelled +} diff --git a/tsunami/templates/tailwind.css b/tsunami/templates/tailwind.css index 72db71b2b7..65f2fe4ff5 100644 --- a/tsunami/templates/tailwind.css +++ b/tsunami/templates/tailwind.css @@ -4,6 +4,7 @@ @import "tailwindcss"; @source inline("bg-background text-primary"); /* index.html */ @source inline("p-4 border border-red-500 bg-red-100 text-red-800 rounded font-mono font-bold mb-2"); /* error component */ +@source inline("fixed inset-0 z-50 flex items-center justify-center bg-black/50 bg-black bg-opacity-50 bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4 p-6 border border-gray-700 flex-col gap-4 gap-3 text-4xl text-xl font-semibold text-white text-gray-300 justify-end mt-4 px-4 py-2 bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 bg-gray-600 hover:bg-gray-700 focus:ring-gray-500 mt-2"); /* modals */ @theme { --color-background: rgb(34, 34, 34); /* default background color */ @@ -33,8 +34,9 @@ --font-sans: "Inter", sans-serif; /* regular text font */ --font-mono: "Hack", monospace; /* monospace, code, terminal, command font */ - --font-markdown: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, - "Apple Color Emoji", "Segoe UI Emoji"; + --font-markdown: + -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", + "Segoe UI Emoji"; --text-xxs: 10px; /* small, very fine text */ --text-title: 18px; /* font size for titles */