diff --git a/ui/analyse/src/ctrl.ts b/ui/analyse/src/ctrl.ts index 1b23f0ec1b881..8e348b2e3a478 100644 --- a/ui/analyse/src/ctrl.ts +++ b/ui/analyse/src/ctrl.ts @@ -54,6 +54,7 @@ import ForecastCtrl from './forecast/forecastCtrl'; import { ArrowKey, KeyboardMove, ctrl as makeKeyboardMove } from 'keyboardMove'; import * as control from './control'; import { PgnError } from 'chessops/pgn'; +import { confirm } from 'common/dialog'; export default class AnalyseCtrl { data: AnalyseData; @@ -611,18 +612,18 @@ export default class AnalyseCtrl { this.withCg(cg => cg.playPremove()); } - deleteNode(path: Tree.Path): void { + async deleteNode(path: Tree.Path): Promise { const node = this.tree.nodeAtPath(path); if (!node) return; const count = treeOps.countChildrenAndComments(node); if ( (count.nodes >= 10 || count.comments > 0) && - !confirm( + !(await confirm( 'Delete ' + plural('move', count.nodes) + (count.comments ? ' and ' + plural('comment', count.comments) : '') + '?', - ) + )) ) return; this.tree.deleteNodeAt(path); diff --git a/ui/analyse/src/serverSideUnderboard.ts b/ui/analyse/src/serverSideUnderboard.ts index 83e7029de700a..2399ded3d3916 100644 --- a/ui/analyse/src/serverSideUnderboard.ts +++ b/ui/analyse/src/serverSideUnderboard.ts @@ -5,7 +5,7 @@ import { url as xhrUrl, textRaw as xhrTextRaw } from 'common/xhr'; import { AnalyseData } from './interfaces'; import { ChartGame, AcplChart } from 'chart'; import { stockfishName, spinnerHtml } from 'common/spinner'; -import { domDialog } from 'common/dialog'; +import { alert, confirm, domDialog } from 'common/dialog'; import { FEN } from 'chessground/types'; import { escapeHtml } from 'common'; import { storage } from 'common/storage'; @@ -120,15 +120,16 @@ export default function (element: HTMLElement, ctrl: AnalyseCtrl) { if (!data.analysis) { $panels.find('form.future-game-analysis').on('submit', function (this: HTMLFormElement) { if ($(this).hasClass('must-login')) { - if (confirm(i18n.site.youNeedAnAccountToDoThat)) - location.href = '/login?referrer=' + window.location.pathname; + confirm(i18n.site.youNeedAnAccountToDoThat, i18n.site.signIn, i18n.site.cancel).then(yes => { + if (yes) location.href = '/login?referrer=' + window.location.pathname; + }); return false; } xhrTextRaw(this.action, { method: this.method }).then(res => { if (res.ok) startAdvantageChart(); else - res.text().then(t => { - if (t && !t.startsWith('')) alert(t); + res.text().then(async t => { + if (t && !t.startsWith('')) await alert(t); site.reload(); }); }); diff --git a/ui/analyse/src/study/chapterEditForm.ts b/ui/analyse/src/study/chapterEditForm.ts index 46e116995d7ce..8411b3c86ce7f 100644 --- a/ui/analyse/src/study/chapterEditForm.ts +++ b/ui/analyse/src/study/chapterEditForm.ts @@ -4,7 +4,7 @@ import { spinnerVdom as spinner } from 'common/spinner'; import { option, emptyRedButton } from '../view/util'; import { ChapterMode, EditChapterData, Orientation, StudyChapterConfig, ChapterPreview } from './interfaces'; import { defined, prop } from 'common'; -import { snabDialog } from 'common/dialog'; +import { confirm, snabDialog } from 'common/dialog'; import { h, VNode } from 'snabbdom'; import { Redraw } from '../interfaces'; import { StudySocketSend } from '../socket'; @@ -142,8 +142,8 @@ function viewLoaded(ctrl: StudyChapterEditForm, data: StudyChapterConfig): VNode { hook: bind( 'click', - () => { - if (confirm(i18n.study.clearAllCommentsInThisChapter)) ctrl.clearAnnotations(data.id); + async () => { + if (await confirm(i18n.study.clearAllCommentsInThisChapter)) ctrl.clearAnnotations(data.id); }, ctrl.redraw, ), @@ -156,8 +156,8 @@ function viewLoaded(ctrl: StudyChapterEditForm, data: StudyChapterConfig): VNode { hook: bind( 'click', - () => { - if (confirm(i18n.study.clearVariations)) ctrl.clearVariations(data.id); + async () => { + if (await confirm(i18n.study.clearVariations)) ctrl.clearVariations(data.id); }, ctrl.redraw, ), @@ -172,8 +172,8 @@ function viewLoaded(ctrl: StudyChapterEditForm, data: StudyChapterConfig): VNode { hook: bind( 'click', - () => { - if (confirm(i18n.study.deleteThisChapter)) ctrl.delete(data.id); + async () => { + if (await confirm(i18n.study.deleteThisChapter)) ctrl.delete(data.id); }, ctrl.redraw, ), diff --git a/ui/analyse/src/study/description.ts b/ui/analyse/src/study/description.ts index 34e4021ae9720..d70c33a477c60 100644 --- a/ui/analyse/src/study/description.ts +++ b/ui/analyse/src/study/description.ts @@ -3,6 +3,7 @@ import * as licon from 'common/licon'; import { bind, onInsert, looseH as h } from 'common/snabbdom'; import { richHTML } from 'common/richText'; import StudyCtrl from './studyCtrl'; +import { confirm } from 'common/dialog'; export type Save = (t: string) => void; @@ -46,8 +47,8 @@ export function view(study: StudyCtrl, chapter: boolean): VNode | undefined { }), h('a', { attrs: { 'data-icon': licon.Trash, title: 'Delete' }, - hook: bind('click', () => { - if (confirm('Delete permanent description?')) desc.save(''); + hook: bind('click', async () => { + if (await confirm('Delete permanent description?')) desc.save(''); }), }), ]), diff --git a/ui/analyse/src/study/studyChapters.ts b/ui/analyse/src/study/studyChapters.ts index 4e2f3abc61cd2..00eab59eaa395 100644 --- a/ui/analyse/src/study/studyChapters.ts +++ b/ui/analyse/src/study/studyChapters.ts @@ -27,6 +27,7 @@ import { fenColor } from 'common/miniBoard'; import { initialFen } from 'chess'; import type Sortable from 'sortablejs'; import { pubsub } from 'common/pubsub'; +import { alert } from 'common/dialog'; /* read-only interface for external use */ export class StudyChapters { diff --git a/ui/analyse/src/study/studyComments.ts b/ui/analyse/src/study/studyComments.ts index d0c37481d1866..ba38f5fbf5d2e 100644 --- a/ui/analyse/src/study/studyComments.ts +++ b/ui/analyse/src/study/studyComments.ts @@ -5,6 +5,7 @@ import { richHTML } from 'common/richText'; import AnalyseCtrl from '../ctrl'; import { nodeFullName } from '../view/util'; import StudyCtrl from './studyCtrl'; +import { confirm } from 'common/dialog'; export type AuthorObj = { id: string; @@ -40,14 +41,12 @@ export function currentComments(ctrl: AnalyseCtrl, includingMine: boolean): VNod study.members.canContribute() && study.vm.mode.write ? h('a.edit', { attrs: { 'data-icon': licon.Trash, title: 'Delete' }, - hook: bind( - 'click', - () => { - if (confirm('Delete ' + authorText(by) + "'s comment?")) - study.commentForm.delete(chapter.id, ctrl.path, comment.id); - }, - ctrl.redraw, - ), + hook: bind('click', async () => { + if (await confirm('Delete ' + authorText(by) + "'s comment?")) { + study.commentForm.delete(chapter.id, ctrl.path, comment.id); + ctrl.redraw(); + } + }), }) : null, authorDom(by), diff --git a/ui/analyse/src/study/studyCtrl.ts b/ui/analyse/src/study/studyCtrl.ts index d791c700cb024..e0624a91649a0 100644 --- a/ui/analyse/src/study/studyCtrl.ts +++ b/ui/analyse/src/study/studyCtrl.ts @@ -51,6 +51,7 @@ import { GamebookOverride } from './gamebook/interfaces'; import { EvalHitMulti, EvalHitMultiArray } from '../interfaces'; import { MultiCloudEval } from './multiCloudEval'; import { pubsub } from 'common/pubsub'; +import { alert } from 'common/dialog'; interface Handlers { path(d: WithWhoAndPos): void; diff --git a/ui/analyse/src/study/studyForm.ts b/ui/analyse/src/study/studyForm.ts index 1e493690690d4..62a94c28808e2 100644 --- a/ui/analyse/src/study/studyForm.ts +++ b/ui/analyse/src/study/studyForm.ts @@ -1,7 +1,7 @@ import { VNode } from 'snabbdom'; import * as licon from 'common/licon'; import { prop } from 'common'; -import { snabDialog } from 'common/dialog'; +import { confirm, prompt, snabDialog } from 'common/dialog'; import flairPickerLoader from 'bits/flairPicker'; import { bindSubmit, bindNonPassive, onInsert, looseH as h } from 'common/snabbdom'; import { emptyRedButton } from '../view/util'; @@ -254,11 +254,14 @@ export function view(ctrl: StudyForm): VNode { 'form', { attrs: { action: '/study/' + data.id + '/delete', method: 'post' }, - hook: bindNonPassive( - 'submit', - _ => - isNew || prompt(i18n.study.confirmDeleteStudy(data.name))?.trim() === data.name.trim(), - ), + hook: bindNonPassive('submit', e => { + if (isNew) return; + + e.preventDefault(); + prompt(i18n.study.confirmDeleteStudy(data.name)).then(userInput => { + if (userInput?.trim() === data.name.trim()) (e.target as HTMLFormElement).submit(); + }); + }), }, [h(emptyRedButton, isNew ? i18n.site.cancel : i18n.study.deleteStudy)], ), @@ -267,7 +270,12 @@ export function view(ctrl: StudyForm): VNode { 'form', { attrs: { action: '/study/' + data.id + '/clear-chat', method: 'post' }, - hook: bindNonPassive('submit', _ => confirm(i18n.study.deleteTheStudyChatHistory)), + hook: bindNonPassive('submit', e => { + e.preventDefault(); + confirm(i18n.study.deleteTheStudyChatHistory).then(yes => { + if (yes) (e.target as HTMLFormElement).submit(); + }); + }), }, [h(emptyRedButton, i18n.study.clearChat)], ), diff --git a/ui/bits/src/bits.account.ts b/ui/bits/src/bits.account.ts index fdfea85c721ee..bdd6c5ffbbea9 100644 --- a/ui/bits/src/bits.account.ts +++ b/ui/bits/src/bits.account.ts @@ -3,6 +3,8 @@ import * as xhr from 'common/xhr'; import { storage } from 'common/storage'; import { addPasswordVisibilityToggleListener } from 'common/password'; import flairPickerLoader from './exports/flairPicker'; +import { confirm } from 'common/dialog'; +import { $as } from 'common'; site.load.then(() => { $('.emoji-details').each(function (this: HTMLElement) { @@ -60,8 +62,12 @@ site.load.then(() => { }; checkDanger(); form.find('input').on('change', checkDanger); - submit.on('click', function (this: HTMLElement) { - return !isDanger || confirm(this.title); + submit.on('click', function (this: HTMLElement, e: Event) { + if (!isDanger) return true; + e.preventDefault(); + confirm(this.title).then(yes => { + if (yes) $as(form).submit(); + }); }); }); @@ -78,7 +84,10 @@ site.load.then(() => { clean = serialize(); }); window.addEventListener('beforeunload', e => { - if (clean != serialize() && !confirm('You have unsaved changes. Are you sure you want to leave?')) + if ( + clean != serialize() && + !window.confirm('You have unsaved changes. Are you sure you want to leave?') + ) e.preventDefault(); }); }); diff --git a/ui/bits/src/bits.checkout.ts b/ui/bits/src/bits.checkout.ts index 232a58ce5b2d3..328f98e70bb79 100644 --- a/ui/bits/src/bits.checkout.ts +++ b/ui/bits/src/bits.checkout.ts @@ -1,6 +1,7 @@ import * as xhr from 'common/xhr'; import { spinnerHtml } from 'common/spinner'; import { contactEmail } from './bits'; +import { alert } from 'common/dialog'; export interface Pricing { currency: string; @@ -13,10 +14,7 @@ export interface Pricing { const $checkout = $('div.plan_checkout'); const getFreq = () => $checkout.find('group.freq input:checked').val(); const getDest = () => $checkout.find('group.dest input:checked').val(); -const showErrorThenReload = (error: string) => { - alert(error); - location.assign('/patron'); -}; +const showErrorThenReload = (error: string) => alert(error).then(() => location.assign('/patron')); export function initModule({ stripePublicKey, pricing }: { stripePublicKey: string; pricing: any }): void { contactEmail(); diff --git a/ui/bits/src/bits.login.ts b/ui/bits/src/bits.login.ts index c64afff9e0d32..96c564ae4bea4 100644 --- a/ui/bits/src/bits.login.ts +++ b/ui/bits/src/bits.login.ts @@ -3,6 +3,7 @@ import { debounce } from 'common/timing'; import { addPasswordVisibilityToggleListener } from 'common/password'; import { storedJsonProp } from 'common/storage'; import { spinnerHtml } from 'common/spinner'; +import { alert } from 'common/dialog'; export function initModule(mode: 'login' | 'signup' | 'reset'): void { mode === 'login' ? loginStart() : mode === 'signup' ? signupStart() : resetStart(); @@ -73,8 +74,9 @@ function loginStart() { addPasswordVisibilityToggleListener(); load(); } else { - alert(text || res.statusText + '. Please wait some time before trying again.'); - toggleSubmit($f.find('.submit'), true); + alert( + (text || res.statusText).slice(0, 300) + '. Please wait some time before trying again.', + ).then(() => toggleSubmit($f.find('.submit'), true)); } } catch (e) { console.warn(e); diff --git a/ui/bits/src/bits.plan.ts b/ui/bits/src/bits.plan.ts index a590cbad8751b..fb7fde8febe95 100644 --- a/ui/bits/src/bits.plan.ts +++ b/ui/bits/src/bits.plan.ts @@ -1,4 +1,5 @@ import * as xhr from 'common/xhr'; +import { alert } from 'common/dialog'; const showError = (error: string) => alert(error); diff --git a/ui/bits/src/bits.ts b/ui/bits/src/bits.ts index fa3378e27d6f4..f99835512c452 100644 --- a/ui/bits/src/bits.ts +++ b/ui/bits/src/bits.ts @@ -2,6 +2,7 @@ import { text, formToXhr } from 'common/xhr'; import flairPickerLoader from './exports/flairPicker'; import { spinnerHtml } from 'common/spinner'; import { wireCropDialog } from './exports/crop'; +import { confirm } from 'common/dialog'; // avoid node_modules and pay attention to imports here. we don't want to force people // to download the entire toastui editor library just to do some light form processing. @@ -191,8 +192,8 @@ function pmAll() { function practiceNag() { const el = document.querySelector('.do-reset'); if (!(el instanceof HTMLAnchorElement)) return; - el.addEventListener('click', () => { - if (confirm('You will lose your practice progress!')) (el.parentNode as HTMLFormElement).submit(); + el.addEventListener('click', async () => { + if (await confirm('You will lose your practice progress!')) (el.parentNode as HTMLFormElement).submit(); }); } diff --git a/ui/bits/src/bits.user.ts b/ui/bits/src/bits.user.ts index d4e0612f91f7c..23eb48225cd2c 100644 --- a/ui/bits/src/bits.user.ts +++ b/ui/bits/src/bits.user.ts @@ -1,6 +1,7 @@ import * as xhr from 'common/xhr'; import { makeLinkPopups } from 'common/linkPopup'; import { pubsub } from 'common/pubsub'; +import { alert } from 'common/dialog'; export function initModule(): void { makeLinkPopups($('.social_links')); diff --git a/ui/ceval/src/view/settings.ts b/ui/ceval/src/view/settings.ts index 48e2458195a35..6db320b25651b 100644 --- a/ui/ceval/src/view/settings.ts +++ b/ui/ceval/src/view/settings.ts @@ -8,6 +8,7 @@ import { onInsert, bind, dataIcon, looseH as h } from 'common/snabbdom'; import * as Licon from 'common/licon'; import { onClickAway } from 'common'; import { clamp } from 'common/algo'; +import { confirm } from 'common/dialog'; const allSearchTicks: [number, string][] = [ [4000, '4s'], @@ -216,9 +217,9 @@ function engineSelection(ctrl: ParentCtrl) { external && h('button.delete', { attrs: { ...dataIcon(Licon.X), title: 'Delete external engine' }, - hook: bind('click', e => { + hook: bind('click', async e => { (e.currentTarget as HTMLElement).blur(); - if (confirm('Remove external engine?')) + if (await confirm('Remove external engine?')) ceval.engines.deleteExternal(external.id).then(ok => ok && ctrl.redraw?.()); }), }), diff --git a/ui/chat/src/ctrl.ts b/ui/chat/src/ctrl.ts index 53594b144830b..d0ffb85a13b02 100644 --- a/ui/chat/src/ctrl.ts +++ b/ui/chat/src/ctrl.ts @@ -17,6 +17,7 @@ import { moderationCtrl } from './moderation'; import { prop } from 'common'; import { storage, type LichessStorage } from 'common/storage'; import { pubsub, PubsubEvent, PubsubCallback } from 'common/pubsub'; +import { alert } from 'common/dialog'; export default class ChatCtrl { data: ChatData; diff --git a/ui/chat/src/discussion.ts b/ui/chat/src/discussion.ts index 520cdd1741cbf..538c04f287dc6 100644 --- a/ui/chat/src/discussion.ts +++ b/ui/chat/src/discussion.ts @@ -9,6 +9,7 @@ import { presetView } from './preset'; import ChatCtrl from './ctrl'; import { tempStorage } from 'common/storage'; import { pubsub } from 'common/pubsub'; +import { alert } from 'common/dialog'; const whisperRegex = /^\/[wW](?:hisper)?\s/; diff --git a/ui/chat/src/moderation.ts b/ui/chat/src/moderation.ts index 26c4c93a25df7..4d90bf72fc6d1 100644 --- a/ui/chat/src/moderation.ts +++ b/ui/chat/src/moderation.ts @@ -7,6 +7,7 @@ import { numberFormat } from 'common/number'; import { userModInfo, flag, timeout } from './xhr'; import ChatCtrl from './ctrl'; import { pubsub } from 'common/pubsub'; +import { confirm } from 'common/dialog'; export function moderationCtrl(opts: ModerationOpts): ModerationCtrl { let data: ModerationData | undefined; @@ -60,8 +61,8 @@ export function report(ctrl: ChatCtrl, line: HTMLElement): void { const text = (line.querySelector('t') as HTMLElement).innerText; if (userA) reportUserText(ctrl.data.resourceId, userA.href.split('/')[4], text); } -function reportUserText(resourceId: string, username: string, text: string) { - if (confirm(`Report "${text}" to moderators?`)) flag(resourceId, username, text); +async function reportUserText(resourceId: string, username: string, text: string) { + if (await confirm(`Report "${text}" to moderators?`)) flag(resourceId, username, text); } export const lineAction = (): VNode => h('action.mod', { attrs: { 'data-icon': licon.Agent } }); @@ -137,8 +138,8 @@ export function moderationView(ctrl?: ModerationCtrl): VNode[] | undefined { 'a.text', { attrs: { 'data-icon': licon.Clock }, - hook: bind('click', () => { - reportUserText(ctrl.opts.resourceId, data.name, data.text); + hook: bind('click', async () => { + await reportUserText(ctrl.opts.resourceId, data.name, data.text); ctrl.timeout(ctrl.opts.reasons[0], data.text); }), }, diff --git a/ui/cli/src/cli.ts b/ui/cli/src/cli.ts index cec4d2583c03e..6efa138d6fe88 100644 --- a/ui/cli/src/cli.ts +++ b/ui/cli/src/cli.ts @@ -1,5 +1,5 @@ import { load as loadDasher } from 'dasher'; -import { domDialog } from 'common/dialog'; +import { alert, domDialog } from 'common/dialog'; import { escapeHtml } from 'common'; import { userComplete } from 'common/userComplete'; diff --git a/ui/common/css/component/_dialog.scss b/ui/common/css/component/_dialog.scss index b36150d3004ce..6a991e78b4ef2 100644 --- a/ui/common/css/component/_dialog.scss +++ b/ui/common/css/component/_dialog.scss @@ -81,17 +81,27 @@ dialog { padding: 2em; color: $c-font; - &.alert { + &.alert, + &.debug { @extend %flex-column; gap: 2em; - padding: 2em; width: unset; height: unset; - - span { - display: flex; - justify-content: end; - gap: 2em; - } + border-radius: 6px; + border: 3px solid $c-primary; + } + &.alert { + width: 480px; + max-width: 100%; + font-size: 16px; + } + span { + display: flex; + justify-content: end; + gap: 2em; + } + input { + align-self: center; + width: 40ch; } } diff --git a/ui/common/src/dialog.ts b/ui/common/src/dialog.ts index 02990926ef5e0..a3726f1f8914c 100644 --- a/ui/common/src/dialog.ts +++ b/ui/common/src/dialog.ts @@ -73,13 +73,23 @@ export async function alert(msg: string): Promise { }); } +export async function alerts(msgs: string[]): Promise { + for (const msg of msgs) await alert(msg); +} + // non-blocking window.confirm-alike -export async function confirm(msg: string): Promise { +export async function confirm( + msg: string, + yes: string = i18n.site.yes, + no: string = i18n.site.no, +): Promise { return ( ( await domDialog({ - htmlText: `
${escapeHtml(msg)}
- `, + htmlText: + `
${escapeHtml(msg)}
` + + `` + + ``, class: 'alert', noCloseButton: true, noClickAway: true, @@ -93,6 +103,42 @@ export async function confirm(msg: string): Promise { ); } +// non-blocking window.prompt-alike +export async function prompt( + msg: string, + def: string = '', + ok: string = 'OK', + cancel: string = i18n.site.cancel, +): Promise { + const res = await domDialog({ + htmlText: + `
${escapeHtml(msg)}
` + + `` + + `` + + ``, + class: 'alert', + noCloseButton: true, + noClickAway: true, + show: 'modal', + focus: 'input', + actions: [ + { selector: '.ok', result: 'ok' }, + { selector: '.cancel', result: 'cancel' }, + { + selector: 'input', + event: 'keydown', + listener: (e: KeyboardEvent, dlg) => { + if (e.key !== 'Enter' && e.key !== 'Escape') return; + e.preventDefault(); + if (e.key === 'Enter') dlg.close('ok'); + else if (e.key === 'Escape') dlg.close('cancel'); + }, + }, + ], + }); + return res.returnValue === 'ok' ? (res.view.querySelector('input') as HTMLInputElement).value : null; +} + // when opts contains 'show', this promise resolves as show/showModal (on dialog close) so check returnValue // otherwise, this promise resolves once assets are loaded and things are fully constructed but not shown export async function domDialog(o: DomDialogOpts): Promise { diff --git a/ui/common/src/permalog.ts b/ui/common/src/permalog.ts index 491b9c408ae86..9ddca4515ddf2 100644 --- a/ui/common/src/permalog.ts +++ b/ui/common/src/permalog.ts @@ -1,5 +1,6 @@ import { objectStorage, ObjectStorage, DbInfo } from './objectStorage'; -import { alert } from './dialog'; +import { domDialog } from './dialog'; +import { escapeHtml } from './common'; export const log: LichessLog = makeLog(); @@ -88,11 +89,21 @@ function makeLog(): LichessLog { window.addEventListener('error', async e => { const loc = e.filename ? ` - (${e.filename}:${e.lineno}:${e.colno})` : ''; log(`${terseHref()} - ${e.message}${loc}\n${e.error?.stack ?? ''}`.trim()); - if (site.debug) alert(`${e.message}${loc}\n${e.error?.stack ?? ''}`); + if (site.debug) + domDialog({ + htmlText: escapeHtml(`${e.message}${loc}\n${e.error?.stack ?? ''}`), + class: 'debug', + show: true, + }); }); window.addEventListener('unhandledrejection', async e => { log(`${terseHref()} - ${e.reason}`); - if (site.debug) alert(`${e.reason}`); + if (site.debug) + domDialog({ + htmlText: escapeHtml(e.reason), + class: 'debug', + show: true, + }); }); return log; diff --git a/ui/learn/src/mapSideView.ts b/ui/learn/src/mapSideView.ts index 03c751dfb0624..9e180e048e117 100644 --- a/ui/learn/src/mapSideView.ts +++ b/ui/learn/src/mapSideView.ts @@ -5,6 +5,7 @@ import { h } from 'snabbdom'; import { bind } from 'common/snabbdom'; import { BASE_LEARN_PATH, hashHref } from './hashRouting'; import { LearnCtrl } from './ctrl'; +import { confirm } from 'common/dialog'; export function mapSideView(ctrl: LearnCtrl) { if (ctrl.inStage()) return renderInStage(ctrl.sideCtrl); @@ -69,7 +70,9 @@ function renderHome(ctrl: SideCtrl) { ? h( 'a.confirm', { - hook: bind('click', () => confirm(i18n.learn.youWillLoseAllYourProgress) && ctrl.reset()), + hook: bind('click', async () => { + if (await confirm(i18n.learn.youWillLoseAllYourProgress)) ctrl.reset(); + }), }, i18n.learn.resetMyProgress, ) diff --git a/ui/lobby/src/ctrl.ts b/ui/lobby/src/ctrl.ts index 1dbe52eac93c5..37fd7162f5804 100644 --- a/ui/lobby/src/ctrl.ts +++ b/ui/lobby/src/ctrl.ts @@ -209,16 +209,18 @@ export default class LobbyController { if (this.tab !== 'real_time') this.redraw(); }; - clickHook = (id: string) => { + clickHook = async (id: string) => { const hook = hookRepo.find(this, id); if (!hook || hook.disabled || this.stepping || this.redirecting) return; - if (hook.action === 'cancel' || variantConfirm(hook.variant)) this.socket.send(hook.action, hook.id); + if (hook.action === 'cancel' || (await variantConfirm(hook.variant))) + this.socket.send(hook.action, hook.id); }; - clickSeek = (id: string) => { + clickSeek = async (id: string) => { const seek = seekRepo.find(this, id); if (!seek || this.redirecting) return; - if (seek.action === 'cancelSeek' || variantConfirm(seek.variant)) this.socket.send(seek.action, seek.id); + if (seek.action === 'cancelSeek' || (await variantConfirm(seek.variant?.key))) + this.socket.send(seek.action, seek.id); }; setSeeks = (seeks: Seek[]) => { diff --git a/ui/lobby/src/interfaces.ts b/ui/lobby/src/interfaces.ts index f174a4a71dd20..7ff959e2bfd56 100644 --- a/ui/lobby/src/interfaces.ts +++ b/ui/lobby/src/interfaces.ts @@ -46,7 +46,7 @@ export interface Seek { key: Exclude; }; provisional?: boolean; - variant?: string; + variant?: { key: VariantKey }; action: 'joinSeek' | 'cancelSeek'; } diff --git a/ui/lobby/src/setupCtrl.ts b/ui/lobby/src/setupCtrl.ts index 350fc115a3e0f..3540cdd5e1e1f 100644 --- a/ui/lobby/src/setupCtrl.ts +++ b/ui/lobby/src/setupCtrl.ts @@ -23,6 +23,7 @@ import { timeVToTime, variants, } from './options'; +import { alert } from 'common/dialog'; const getPerf = (variant: VariantKey, timeMode: TimeMode, time: RealValue, increment: RealValue): Perf => variant != 'standard' && variant != 'fromPosition' @@ -319,7 +320,7 @@ export default class SetupController { if (!ok) { const errs: { [key: string]: string } = await response.json(); - alert( + await alert( errs ? Object.keys(errs) .map(k => `${k}: ${errs[k]}`) diff --git a/ui/lobby/src/variant.ts b/ui/lobby/src/variant.ts index 9f417c07cd298..8178375c09251 100644 --- a/ui/lobby/src/variant.ts +++ b/ui/lobby/src/variant.ts @@ -1,6 +1,7 @@ import { storage } from 'common/storage'; +import { confirm } from 'common/dialog'; -const variantConfirms = { +const variantConfirms: Record = { chess960: "This is a Chess960 game!\n\nThe starting position of the pieces on the players' home ranks is randomized.", kingOfTheHill: @@ -20,15 +21,9 @@ const variantConfirms = { const storageKey = (key: string) => 'lobby.variant.' + key; -export default function (variant?: string) { - return ( - !variant || - Object.keys(variantConfirms).every(function (key: keyof typeof variantConfirms) { - if (variant === key && !storage.get(storageKey(key))) { - const c = confirm(variantConfirms[key]); - if (c) storage.set(storageKey(key), '1'); - return c; - } else return true; - }) - ); +export default async function (variant: string | undefined) { + if (!variant || !variantConfirms[variant] || storage.get(storageKey(variant))) return true; + const confirmed = await confirm(variantConfirms[variant]); + if (confirmed) storage.set(storageKey(variant), '1'); + return confirmed; } diff --git a/ui/lobby/src/view/correspondence.ts b/ui/lobby/src/view/correspondence.ts index f35fb7b762858..6c8d70246d81e 100644 --- a/ui/lobby/src/view/correspondence.ts +++ b/ui/lobby/src/view/correspondence.ts @@ -4,6 +4,7 @@ import { tds, perfNames } from './util'; import LobbyController from '../ctrl'; import { Seek } from '../interfaces'; import perfIcons from 'common/perfIcons'; +import { confirm } from 'common/dialog'; function renderSeek(ctrl: LobbyController, seek: Seek): VNode { const klass = seek.action === 'joinSeek' ? 'join' : 'cancel'; @@ -66,13 +67,14 @@ export default function (ctrl: LobbyController): MaybeVNodes { h( 'tbody', { - hook: bind('click', e => { + hook: bind('click', async e => { let el = e.target as HTMLElement; do { el = el.parentNode as HTMLElement; if (el.nodeName === 'TR') { if (!ctrl.me) { - if (confirm(i18n.site.youNeedAnAccountToDoThat)) location.href = '/signup'; + if (await confirm(i18n.site.youNeedAnAccountToDoThat, i18n.site.signUp, i18n.site.cancel)) + location.href = '/signup'; return; } return ctrl.clickSeek(el.dataset['id']!); diff --git a/ui/lobby/src/view/realTime/list.ts b/ui/lobby/src/view/realTime/list.ts index 78157b6a81fa1..644a7f9ca9c3e 100644 --- a/ui/lobby/src/view/realTime/list.ts +++ b/ui/lobby/src/view/realTime/list.ts @@ -99,7 +99,7 @@ export const render = (ctrl: LobbyController, allHooks: Hook[]) => { class: { stepping: ctrl.stepping }, hook: bind( 'click', - e => { + async e => { let el = e.target as HTMLElement; do { el = el.parentNode as HTMLElement; diff --git a/ui/mod/src/mod.games.ts b/ui/mod/src/mod.games.ts index 497fa9155e41d..8017e54cec260 100644 --- a/ui/mod/src/mod.games.ts +++ b/ui/mod/src/mod.games.ts @@ -3,6 +3,7 @@ import tablesort from 'tablesort'; import { debounce } from 'common/timing'; import { formToXhr } from 'common/xhr'; import { checkBoxAll, expandCheckboxZone, shiftClickCheckboxRange } from './checkBoxes'; +import { confirm } from 'common/dialog'; site.load.then(() => { setupTable(); @@ -35,23 +36,20 @@ const setupActionForm = () => { const form = document.querySelector('.mod-games__analysis-form') as HTMLFormElement; const debouncedSubmit = debounce( () => - formToXhr(form).then(() => { - const reload = confirm('Analysis completed. Reload the page?'); - if (reload) site.reload(); + formToXhr(form).then(async () => { + if (await confirm('Analysis completed. Reload the page?')) site.reload(); }), 1000, ); - $(form).on('click', 'button', (e: Event) => { + $(form).on('click', 'button', async (e: Event) => { const button = e.target as HTMLButtonElement; const action = button.getAttribute('value'); const nbSelected = form.querySelectorAll('input:checked').length; - if (nbSelected < 1) return false; - if (action == 'analyse') { - if (nbSelected >= 20 && !confirm(`Analyse ${nbSelected} games?`)) return; - $(form).find('button[value="analyse"]').text('Sent').prop('disabled', true); - debouncedSubmit(); - return false; - } - return; + if (action !== 'analyse') return; + e.preventDefault(); + if (nbSelected < 1) return; + if (nbSelected >= 20 && !(await confirm(`Analyse ${nbSelected} games?`))) return; + $(form).find('button[value="analyse"]').text('Sent').prop('disabled', true); + debouncedSubmit(); }); }; diff --git a/ui/mod/src/mod.inquiry.ts b/ui/mod/src/mod.inquiry.ts index fc7bb4b1eba40..36864d3df559b 100644 --- a/ui/mod/src/mod.inquiry.ts +++ b/ui/mod/src/mod.inquiry.ts @@ -2,6 +2,7 @@ import * as xhr from 'common/xhr'; import { expandMentions } from 'common/richText'; import { storage } from 'common/storage'; +import { alert } from 'common/dialog'; site.load.then(() => { const noteStore = storage.make('inquiry-note'); diff --git a/ui/mod/src/mod.search.ts b/ui/mod/src/mod.search.ts index c887da3280df0..626401e57ee3c 100644 --- a/ui/mod/src/mod.search.ts +++ b/ui/mod/src/mod.search.ts @@ -2,12 +2,13 @@ import * as xhr from 'common/xhr'; import extendTablesortNumber from 'common/tablesortNumber'; import tablesort from 'tablesort'; import { checkBoxAll, expandCheckboxZone, selector, shiftClickCheckboxRange } from './checkBoxes'; +import { confirm } from 'common/dialog'; site.load.then(() => { $('.slist, slist-pad') .find('.mark-alt') - .on('click', function (this: HTMLAnchorElement) { - if (confirm('Close alt account?')) { + .on('click', async function (this: HTMLAnchorElement) { + if (await confirm('Close alt account?')) { xhr.text(this.getAttribute('href')!, { method: 'post' }); $(this).remove(); } @@ -31,7 +32,7 @@ site.load.then(() => { .find('td:last-child input:checked') .map((_, input) => $(input).parents('tr').find('td:first-child').data('sort')), ); - if (usernames.length > 0 && confirm(`Close ${usernames.length} alt accounts?`)) { + if (usernames.length > 0 && (await confirm(`Close ${usernames.length} alt accounts?`))) { console.log(usernames); await xhr.text('/mod/alt-many', { method: 'post', body: usernames.join(' ') }); location.reload(); diff --git a/ui/mod/src/mod.user.ts b/ui/mod/src/mod.user.ts index 0fba7755cad80..b34df805f0e3b 100644 --- a/ui/mod/src/mod.user.ts +++ b/ui/mod/src/mod.user.ts @@ -6,6 +6,7 @@ import tablesort from 'tablesort'; import { expandCheckboxZone, shiftClickCheckboxRange, selector } from './checkBoxes'; import { spinnerHtml } from 'common/spinner'; import { pubsub } from 'common/pubsub'; +import { confirm } from 'common/dialog'; site.load.then(() => { const $toggle = $('.mod-zone-toggle'), @@ -74,8 +75,9 @@ site.load.then(() => { const confirmButton = (el: HTMLElement) => $(el) .find('input.confirm, button.confirm') - .on('click', function (this: HTMLElement) { - return confirm(this.title || 'Confirm this action?'); + .on('click', async function (this: HTMLElement, e: Event) { + e.preventDefault(); + if (await confirm(this.title || 'Confirm this action?')) this.closest('form')?.submit(); }); $('.mz-section--menu > a:not(.available)').each(function (this: HTMLAnchorElement) { @@ -136,7 +138,7 @@ site.load.then(() => { .find('td:last-child input:checked') .map((_, input) => $(input).parents('tr').find('td:first-child').data('sort')), ); - if (usernames.length > 0 && confirm(`Close ${usernames.length} alt accounts?`)) { + if (usernames.length > 0 && (await confirm(`Close ${usernames.length} alt accounts?`))) { await xhr.text('/mod/alt-many', { method: 'post', body: usernames.join(' ') }); reloadZone(); } diff --git a/ui/msg/src/view/interact.ts b/ui/msg/src/view/interact.ts index 7a448b061a049..9104277f5c67e 100644 --- a/ui/msg/src/view/interact.ts +++ b/ui/msg/src/view/interact.ts @@ -4,6 +4,7 @@ import { bindSubmit } from 'common/snabbdom'; import { User } from '../interfaces'; import MsgCtrl from '../ctrl'; import { throttle } from 'common/timing'; +import { alert } from 'common/dialog'; export default function renderInteract(ctrl: MsgCtrl, user: User): VNode { const connected = ctrl.connected(); @@ -49,7 +50,10 @@ function setupTextarea(area: HTMLTextAreaElement, contact: string, ctrl: MsgCtrl if (prev > now - 1000 || !ctrl.connected()) return; prev = now; const txt = area.value.trim(); - if (txt.length > 8000) return alert('The message is too long.'); + if (txt.length > 8000) { + alert('The message is too long.'); + return; + } if (txt) ctrl.post(txt); area.value = ''; area.dispatchEvent(new Event('input')); // resize the textarea diff --git a/ui/msg/src/view/msgs.ts b/ui/msg/src/view/msgs.ts index 15fe6d7c9660a..e15c6f3eaa276 100644 --- a/ui/msg/src/view/msgs.ts +++ b/ui/msg/src/view/msgs.ts @@ -6,6 +6,7 @@ import * as enhance from './enhance'; import { makeLinkPopups } from 'common/linkPopup'; import { scroller } from './scroller'; import MsgCtrl from '../ctrl'; +import { alert, confirm } from 'common/dialog'; export default function renderMsgs(ctrl: MsgCtrl, convo: Convo): VNode { return h('div.msg-app__convo__msgs', { hook: { insert: setupMsgs(true), postpatch: setupMsgs(false) } }, [ @@ -120,8 +121,8 @@ const setupMsgs = (insert: boolean) => (vnode: VNode) => { scroller.toMarker() || scroller.auto(); }; -const teamUnsub = (form: HTMLFormElement) => { - if (confirm('Unsubscribe?')) +const teamUnsub = async (form: HTMLFormElement) => { + if (await confirm('Unsubscribe?')) xhr .json(form.action, { method: 'post', diff --git a/ui/palantir/src/palantir.ts b/ui/palantir/src/palantir.ts index 8ae7e246d2c71..3dcb652e6bad5 100644 --- a/ui/palantir/src/palantir.ts +++ b/ui/palantir/src/palantir.ts @@ -2,6 +2,7 @@ import type * as snabbdom from 'snabbdom'; import * as licon from 'common/licon'; import Peer from 'peerjs'; import { pubsub } from 'common/pubsub'; +import { alert } from 'common/dialog'; type State = | 'off' diff --git a/ui/puzzle/src/ctrl.ts b/ui/puzzle/src/ctrl.ts index ef05b4b5470d0..b97aaba0731bb 100755 --- a/ui/puzzle/src/ctrl.ts +++ b/ui/puzzle/src/ctrl.ts @@ -39,6 +39,7 @@ import { uciToMove } from 'chessground/util'; import { Redraw } from 'common/snabbdom'; import { ParentCtrl } from 'ceval/src/types'; import { pubsub } from 'common/pubsub'; +import { alert } from 'common/dialog'; export default class PuzzleCtrl implements ParentCtrl { data: PuzzleData; @@ -454,11 +455,9 @@ export default class PuzzleCtrl implements ParentCtrl { if (this.streak && win) this.streak.onComplete(true, res.next); } this.redraw(); - if (!next) { - if (!this.data.replay) { - alert('No more puzzles available! Try another theme.'); - site.redirect('/training/themes'); - } + if (!next && !this.data.replay) { + await alert('No more puzzles available! Try another theme.'); + site.redirect('/training/themes'); } }; diff --git a/ui/simul/src/view/created.ts b/ui/simul/src/view/created.ts index 6d7bd335cfffa..02098e22abbd9 100644 --- a/ui/simul/src/view/created.ts +++ b/ui/simul/src/view/created.ts @@ -1,6 +1,6 @@ import { VNode } from 'snabbdom'; import * as licon from 'common/licon'; -import { domDialog } from 'common/dialog'; +import { confirm, domDialog } from 'common/dialog'; import { bind, looseH as h } from 'common/snabbdom'; import SimulCtrl from '../ctrl'; import { Applicant } from '../interfaces'; @@ -190,8 +190,8 @@ const startOrCancel = (ctrl: SimulCtrl, accepted: Applicant[]) => 'a.button.button-red.text', { attrs: { 'data-icon': licon.X }, - hook: bind('click', () => { - if (confirm('Delete this simul?')) xhr.abort(ctrl.data.id); + hook: bind('click', async () => { + if (await confirm('Delete this simul?')) xhr.abort(ctrl.data.id); }), }, i18n.site.cancel, diff --git a/ui/site/src/boot.ts b/ui/site/src/boot.ts index a3690e24cece3..7a98281f985d3 100644 --- a/ui/site/src/boot.ts +++ b/ui/site/src/boot.ts @@ -16,6 +16,7 @@ import { userComplete } from 'common/userComplete'; import { updateTimeAgo, renderTimeAgo } from './renderTimeAgo'; import { pubsub } from 'common/pubsub'; import { toggleBoxInit } from 'common/controls'; +import { confirm } from 'common/dialog'; export function boot() { $('#user_tag').removeAttr('href'); @@ -90,8 +91,10 @@ export function boot() { else $(this).one('focus', start); }); - $('input.confirm, button.confirm').on('click', function (this: HTMLElement) { - return confirm(this.title || 'Confirm this action?'); + $('input.confirm, button.confirm').on('click', async function (this: HTMLElement, e: Event) { + if (!e.isTrusted) return; + e.preventDefault(); + if (await confirm(this.title || 'Confirm this action?')) (e.target as HTMLElement)?.click(); }); $('#main-wrap').on('click', 'a.bookmark', function (this: HTMLAnchorElement) { diff --git a/ui/tournament/src/ctrl.ts b/ui/tournament/src/ctrl.ts index 882915bb3bb0b..4c0b4724b4ad4 100644 --- a/ui/tournament/src/ctrl.ts +++ b/ui/tournament/src/ctrl.ts @@ -5,6 +5,7 @@ import * as sound from './sound'; import { TournamentData, TournamentOpts, Pages, PlayerInfo, TeamInfo, Standing, Player } from './interfaces'; import { storage } from 'common/storage'; import { pubsub } from 'common/pubsub'; +import { alerts } from 'common/dialog'; interface CtrlTeamInfo { requested?: string; @@ -140,12 +141,10 @@ export default class TournamentController { this.focusOnMe = false; }; - join = (team?: string) => { + join = async (team?: string) => { this.joinWithTeamSelector = false; if (!this.data.verdicts.accepted) - return this.data.verdicts.list.forEach(v => { - if (v.verdict !== 'ok') alert(v.verdict); - }); + return await alerts(this.data.verdicts.list.map(v => v.verdict).filter(v => v != 'ok')); if (this.data.teamBattle && !team && !this.data.me) { this.joinWithTeamSelector = true; } else { @@ -160,6 +159,7 @@ export default class TournamentController { this.joinSpinner = true; this.focusOnMe = true; } + return; }; scrollToMe = () => this.setPage(myPage(this)); diff --git a/ui/tournament/src/xhr.ts b/ui/tournament/src/xhr.ts index f16799f3e62b2..90d3b198ee2c7 100644 --- a/ui/tournament/src/xhr.ts +++ b/ui/tournament/src/xhr.ts @@ -1,6 +1,7 @@ import { finallyDelay, throttlePromiseDelay } from 'common/timing'; import * as xhr from 'common/xhr'; import TournamentController from './ctrl'; +import { alert } from 'common/dialog'; // when the tournament no longer exists // randomly delay reloads in case of massive tournament to avoid ddos