diff --git a/src/gui/src/UI/Dashboard/TabTeams.js b/src/gui/src/UI/Dashboard/TabTeams.js
index fc64da024..e651b4a2d 100644
--- a/src/gui/src/UI/Dashboard/TabTeams.js
+++ b/src/gui/src/UI/Dashboard/TabTeams.js
@@ -17,9 +17,8 @@
* along with this program. If not, see
${limit ? i18n('teams_seat_limit', { limit }) : html_encode(e.message)}
`; message += `${i18n('teams_seat_limit_upgrade')}
`; - const answer = await UIAlert({ - type: 'warning', + const answer = await dialog($el_window, { + title: i18n('teams_add_account', [], false), + tone: 'warning', message, buttons: canUpgrade ? [ - { label: i18n('teams_see_plans'), value: 'upgrade', type: 'primary' }, - { label: i18n('cancel'), value: 'no' }, + { label: i18n('cancel', [], false), value: 'no' }, + { label: i18n('teams_see_plans', [], false), value: 'upgrade', type: 'primary' }, ] - : [{ label: i18n('ok'), value: 'no' }], - ...modalOptions($el_window), + : [{ label: i18n('ok', [], false), value: 'no', type: 'primary' }], }); if ( answer === 'upgrade' ) new window.UIUpgradeAccount().open_as_window(); }; const showError = ($el_window, e) => { if ( e?.code === 'seat_limit_reached' ) return showSeatLimit($el_window, e); - return UIAlert({ - type: 'error', - message: e?.message ?? i18n('error_unknown_cause'), - ...modalOptions($el_window), + return dialog($el_window, { + title: i18n('something_went_wrong', [], false), + tone: 'error', + message: html_encode(e?.message ?? i18n('error_unknown_cause', [], false)), }); }; +/** `label` names the action and titles the dialog; `kind` colours its button. */ const confirm = async ($el_window, message, label, kind = 'danger') => { - const answer = await UIAlert({ - type: 'confirm', + const answer = await dialog($el_window, { + title: label, + tone: kind === 'danger' ? 'danger' : undefined, message, buttons: [ + { label: i18n('cancel', [], false), value: 'no' }, { label, value: 'yes', type: kind }, - { label: i18n('cancel'), value: 'no' }, ], - ...modalOptions($el_window), }); return answer === 'yes'; }; @@ -618,7 +613,7 @@ const reissueCredential = async ($el_window, username) => { const ok = await confirm( $el_window, `${i18n('teams_confirm_reissue', { username })}
`, - i18n('teams_reissue_credential'), + i18n('teams_reissue_credential', [], false), ); if ( ! ok ) return; try { @@ -635,7 +630,7 @@ const setMemberEnabled = async ($el_window, username, enabled) => { const ok = await confirm( $el_window, `${i18n('teams_confirm_disable', { username })}
`, - i18n('teams_disable_account'), + i18n('teams_disable_account', [], false), ); if ( ! ok ) return; } @@ -650,11 +645,14 @@ const setMemberEnabled = async ($el_window, username, enabled) => { }; const renameTeam = async ($el_window) => { - const name = await UIPrompt({ + const name = await dialog($el_window, { + title: i18n('teams_rename', [], false), message: i18n('teams_rename_prompt'), - placeholder: i18n('teams_team_name'), - defaultValue: state.selected.name ?? '', - ...modalOptions($el_window), + input: { placeholder: i18n('teams_team_name', [], false), value: state.selected.name ?? '' }, + buttons: [ + { label: i18n('cancel', [], false), value: false }, + { label: i18n('teams_rename', [], false), value: 'ok', type: 'primary' }, + ], }); if ( name === false || name.trim() === '' ) return; try { @@ -669,7 +667,7 @@ const deleteMemberAccount = async ($el_window, username) => { const ok = await confirm( $el_window, i18n('teams_confirm_delete_account', { username }), - i18n('teams_delete_account'), + i18n('teams_delete_account', [], false), ); if ( ! ok ) return; try { @@ -687,7 +685,7 @@ const setDirectoryEnabled = async ($el_window, enabled) => { const ok = await confirm( $el_window, i18n('teams_directory_confirm'), - i18n('teams_directory_confirm_action'), + i18n('teams_directory_confirm_action', [], false), // Reversible, and the wording says so — red would overstate it. 'primary', ); @@ -704,10 +702,14 @@ const setDirectoryEnabled = async ($el_window, enabled) => { }; const createTeam = async ($el_window) => { - const name = await UIPrompt({ + const name = await dialog($el_window, { + title: i18n('teams_create_team', [], false), message: i18n('teams_create_team_prompt'), - placeholder: i18n('teams_team_name'), - ...modalOptions($el_window), + input: { placeholder: i18n('teams_team_name', [], false) }, + buttons: [ + { label: i18n('cancel', [], false), value: false }, + { label: i18n('teams_create_team', [], false), value: 'ok', type: 'primary' }, + ], }); if ( name === false || name.trim() === '' ) return; try { @@ -724,7 +726,7 @@ const deleteTeam = async ($el_window) => { $el_window, `${i18n('teams_confirm_delete_team', { team: teamName(state.selected) })}
` + `${i18n('teams_confirm_delete_billing')}
`, - i18n('teams_delete_team'), + i18n('teams_delete_team', [], false), ); if ( ! ok ) return; try { diff --git a/src/gui/src/UI/Dashboard/TabTeams.render.test.js b/src/gui/src/UI/Dashboard/TabTeams.render.test.js index 9cb96449f..6e30e5b6b 100644 --- a/src/gui/src/UI/Dashboard/TabTeams.render.test.js +++ b/src/gui/src/UI/Dashboard/TabTeams.render.test.js @@ -2,8 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js'; -vi.mock('../UIAlert.js', () => ({ default: vi.fn() })); -vi.mock('../UIPrompt.js', () => ({ default: vi.fn() })); +vi.mock('./UIDashboardDialog.js', () => ({ default: vi.fn() })); globalThis.$ = jQuery; globalThis.jQuery = jQuery; diff --git a/src/gui/src/UI/Dashboard/TabTeams.test.js b/src/gui/src/UI/Dashboard/TabTeams.test.js index 58a670783..97ca802fa 100644 --- a/src/gui/src/UI/Dashboard/TabTeams.test.js +++ b/src/gui/src/UI/Dashboard/TabTeams.test.js @@ -1,9 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; -// The tab pulls in the dialog primitives, which pull in the whole window stack. -// Only the tab-object contract is under test here. -vi.mock('../UIAlert.js', () => ({ default: vi.fn() })); -vi.mock('../UIPrompt.js', () => ({ default: vi.fn() })); +// The tab's dialogs mount into the window; only the tab-object contract is under test here. +vi.mock('./UIDashboardDialog.js', () => ({ default: vi.fn() })); globalThis.i18n = (key) => key; globalThis.html_encode = (value) => String(value); diff --git a/src/gui/src/UI/Dashboard/UIDashboardDialog.js b/src/gui/src/UI/Dashboard/UIDashboardDialog.js new file mode 100644 index 000000000..60968ca59 --- /dev/null +++ b/src/gui/src/UI/Dashboard/UIDashboardDialog.js @@ -0,0 +1,172 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, seeok
', buttons: [{ label: 'y', value: 1 }] }); + expect($('.dashboard-dialog-title').html()).toBe('<b>x</b>'); + expect($('.dashboard-dialog-btn').html()).toBe('<i>y</i>'); + expect($('.dashboard-dialog-message p')).toHaveLength(1); + }); + + it('mounts into the given container and marks the tone', () => { + document.body.innerHTML = ''; + UIDashboardDialog({ message: 'm', tone: 'danger', $container: $('.host') }); + expect($('.host .dashboard-dialog-overlay')).toHaveLength(1); + expect($('.dashboard-dialog').hasClass('dashboard-dialog-danger')).toBe(true); + expect($('.dashboard-dialog-icon')).toHaveLength(1); + }); + + it('removes itself from the DOM after the exit transition', async () => { + vi.useFakeTimers(); + const p = UIDashboardDialog({ message: 'm' }); + $('.dashboard-dialog-btn').trigger('click'); + await p; + expect(overlay()).toHaveLength(1); + vi.advanceTimersByTime(250); + expect(overlay()).toHaveLength(0); + }); +}); diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css index f793b63ba..060eb9b2d 100644 --- a/src/gui/src/css/dashboard.css +++ b/src/gui/src/css/dashboard.css @@ -8980,3 +8980,300 @@ body.dashboard-mode .notifications-close-all { flex: 1; } } + +/* ====================================== + Dashboard dialog (confirm / alert / prompt) + ====================================== */ + +.dashboard-dialog-overlay { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + box-sizing: border-box; + background: var(--dashboard-shadow-overlay, rgba(0, 0, 0, 0.5)); + opacity: 0; + transition: opacity 200ms ease; + z-index: 1000; +} + +.dashboard-dialog-overlay.dashboard-dialog-show { + opacity: 1; +} + +.dashboard-dialog { + display: flex; + flex-direction: column; + width: min(440px, 100%); + max-height: 100%; + background: var(--dashboard-card-background); + border: 1px solid var(--dashboard-border); + border-radius: 14px; + box-shadow: 0 12px 40px var(--dashboard-shadow-medium); + overflow: hidden; + transform: scale(0.96); + opacity: 0; + transition: transform 200ms ease, opacity 200ms ease; +} + +.dashboard-dialog-overlay.dashboard-dialog-show .dashboard-dialog { + transform: scale(1); + opacity: 1; +} + +.dashboard-dialog:focus { + outline: none; +} + +.dashboard-dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 16px 18px; + border-bottom: 1px solid var(--dashboard-border); + flex-shrink: 0; +} + +.dashboard-dialog-title { + min-width: 0; + font-size: 15px; + font-weight: 600; + color: var(--dashboard-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dashboard-dialog-close { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: none; + border-radius: 8px; + background: transparent; + color: var(--dashboard-text-secondary); + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.dashboard-dialog-close svg { + width: 18px; + height: 18px; +} + +@media (hover: hover) { + .dashboard-dialog-close:hover { + background: var(--dashboard-hover); + color: var(--dashboard-text-primary); + } +} + +.dashboard-dialog-close:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--select-ring); +} + +.dashboard-dialog-body { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 20px 18px; + overflow-y: auto; +} + +.dashboard-dialog-icon { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--select-tint); + color: var(--select-color); +} + +.dashboard-dialog-icon svg { + width: 20px; + height: 20px; +} + +.dashboard-dialog-danger .dashboard-dialog-icon, +.dashboard-dialog-error .dashboard-dialog-icon { + background: var(--dashboard-danger-background); + color: var(--dashboard-danger-text); +} + +.dashboard-dialog-warning .dashboard-dialog-icon { + background: var(--dashboard-warning-background); + color: var(--dashboard-warning-text); +} + +.dashboard-dialog-content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 14px; +} + +.dashboard-dialog-message { + font-size: 14px; + line-height: 1.55; + color: var(--dashboard-text-primary); + overflow-wrap: anywhere; +} + +.dashboard-dialog-message p { + margin: 0 0 10px; +} + +.dashboard-dialog-message p:last-child { + margin-bottom: 0; +} + +.dashboard-dialog-field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.dashboard-dialog-field > span { + font-size: 12px; + font-weight: 600; + color: var(--dashboard-text-secondary); +} + +.dashboard-dialog-input { + width: 100%; + height: 38px; + padding: 0 12px; + border: 1px solid var(--dashboard-border); + border-radius: 10px; + background: var(--dashboard-input-background); + color: var(--dashboard-text-primary); + font-family: inherit; + font-size: 14px; + box-sizing: border-box; + transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease; +} + +.dashboard-dialog-input:focus { + outline: none; + border-color: var(--select-color); + background: var(--dashboard-card-background); + box-shadow: 0 0 0 3px var(--select-ring); +} + +.dashboard-dialog-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 14px 18px; + border-top: 1px solid var(--dashboard-border); + flex-shrink: 0; +} + +.dashboard-dialog-btn { + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + min-width: 88px; + height: 36px; + padding: 0 16px; + border: 1px solid var(--dashboard-border); + border-radius: 10px; + background: var(--dashboard-card-background); + color: var(--dashboard-text-primary); + font-size: 13px; + font-weight: 600; + font-family: inherit; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} + +.dashboard-dialog-btn-primary { + border-color: var(--dashboard-link); + background: var(--dashboard-link); + color: #fff; +} + +.dashboard-dialog-btn-danger { + border-color: #f00808; + background: #f00808; + color: #fff; +} + +@media (hover: hover) { + .dashboard-dialog-btn-quiet:hover { + background: var(--dashboard-sidebar-background); + } + + .dashboard-dialog-btn-primary:hover { + background: var(--dashboard-link-hover); + border-color: var(--dashboard-link-hover); + } + + .dashboard-dialog-btn-danger:hover { + background: #d90707; + border-color: #d90707; + } +} + +.dashboard-dialog-btn:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--select-ring); +} + +@media (pointer: coarse) { + .dashboard-dialog-btn { + min-height: 44px; + height: auto; + padding: 10px 18px; + } +} + +/* Mobile: dock to the bottom as a slide-up sheet, like the other modals. */ +@media (max-width: 600px) { + .dashboard-dialog-overlay { + align-items: flex-end; + padding: 0; + } + + .dashboard-dialog { + width: 100%; + max-width: none; + max-height: 92vh; + border-radius: 16px 16px 0 0; + border-bottom: none; + transform: translateY(24px); + } + + .dashboard-dialog-overlay.dashboard-dialog-show .dashboard-dialog { + transform: translateY(0); + } + + .dashboard-dialog-footer { + flex-direction: column-reverse; + padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px)); + } + + .dashboard-dialog-btn { + width: 100%; + } +} + +@media (prefers-reduced-motion: reduce) { + .dashboard-dialog-overlay, + .dashboard-dialog { + transition: none; + } +}