diff --git a/templates/devtest/gitea-ui.tmpl b/templates/devtest/gitea-ui.tmpl
index ea293fd3b41..06d0e36569a 100644
--- a/templates/devtest/gitea-ui.tmpl
+++ b/templates/devtest/gitea-ui.tmpl
@@ -182,15 +182,6 @@
-
-
Toast
-
-
-
-
-
-
-
ComboMarkdownEditor
ps: no JS code attached, so just a layout
@@ -201,7 +192,5 @@
-
-
{{template "base/footer" .}}
diff --git a/templates/devtest/toast.tmpl b/templates/devtest/toast.tmpl
new file mode 100644
index 00000000000..412f23964a2
--- /dev/null
+++ b/templates/devtest/toast.tmpl
@@ -0,0 +1,15 @@
+{{template "base/head" .}}
+
+
+
Toast
+
+
+
+
+
+
+
+
+
+
+{{template "base/footer" .}}
diff --git a/web_src/css/modules/animations.css b/web_src/css/modules/animations.css
index a86c9234aa3..481e997d4fc 100644
--- a/web_src/css/modules/animations.css
+++ b/web_src/css/modules/animations.css
@@ -92,20 +92,22 @@ code.language-math.is-loading::after {
}
}
-@keyframes pulse {
+/* 1p5 means 1-point-5. it can't use "pulse" here, otherwise the animation is not right (maybe due to some conflicts */
+@keyframes pulse-1p5 {
0% {
transform: scale(1);
}
50% {
- transform: scale(1.8);
+ transform: scale(1.5);
}
100% {
transform: scale(1);
}
}
-.pulse {
- animation: pulse 2s linear;
+/* pulse animation for scale(1.5) in 200ms */
+.pulse-1p5-200 {
+ animation: pulse-1p5 200ms linear;
}
.ui.modal,
diff --git a/web_src/css/modules/toast.css b/web_src/css/modules/toast.css
index 2a9f78e0175..1145f3b1b58 100644
--- a/web_src/css/modules/toast.css
+++ b/web_src/css/modules/toast.css
@@ -22,17 +22,31 @@
overflow-wrap: anywhere;
}
-.toast-close,
-.toast-icon {
- color: currentcolor;
+.toast-close {
border-radius: var(--border-radius);
- background: transparent;
- border: none;
- display: flex;
width: 30px;
height: 30px;
justify-content: center;
+}
+
+.toast-icon {
+ display: inline-flex;
+ width: 30px;
+ height: 30px;
align-items: center;
+ justify-content: center;
+}
+
+.toast-duplicate-number::before {
+ content: "(";
+}
+.toast-duplicate-number {
+ display: inline-block;
+ margin-right: 5px;
+ user-select: none;
+}
+.toast-duplicate-number::after {
+ content: ")";
}
.toast-close:hover {
diff --git a/web_src/js/features/repo-diff.js b/web_src/js/features/repo-diff.js
index cd01232a7e5..279f6da7572 100644
--- a/web_src/js/features/repo-diff.js
+++ b/web_src/js/features/repo-diff.js
@@ -7,7 +7,7 @@ import {validateTextareaNonEmpty} from './comp/ComboMarkdownEditor.js';
import {initViewedCheckboxListenerFor, countAndUpdateViewedFiles, initExpandAndCollapseFilesButton} from './pull-view-file.js';
import {initImageDiff} from './imagediff.js';
import {showErrorToast} from '../modules/toast.js';
-import {submitEventSubmitter, queryElemSiblings, hideElem, showElem} from '../utils/dom.js';
+import {submitEventSubmitter, queryElemSiblings, hideElem, showElem, animateOnce} from '../utils/dom.js';
import {POST, GET} from '../modules/fetch.js';
const {pageData, i18n} = window.config;
@@ -26,11 +26,7 @@ function initRepoDiffReviewButton() {
const num = parseInt(counter.getAttribute('data-pending-comment-number')) + 1 || 1;
counter.setAttribute('data-pending-comment-number', num);
counter.textContent = num;
-
- reviewBox.classList.remove('pulse');
- requestAnimationFrame(() => {
- reviewBox.classList.add('pulse');
- });
+ animateOnce(reviewBox, 'pulse-1p5-200');
});
});
}
diff --git a/web_src/js/modules/toast.js b/web_src/js/modules/toast.js
index d12d203718b..627e24a1ff9 100644
--- a/web_src/js/modules/toast.js
+++ b/web_src/js/modules/toast.js
@@ -1,5 +1,6 @@
import {htmlEscape} from 'escape-goat';
import {svg} from '../svg.js';
+import {animateOnce, showElem} from '../utils/dom.js';
import Toastify from 'toastify-js'; // don't use "async import", because when network error occurs, the "async import" also fails and nothing is shown
const levels = {
@@ -21,13 +22,28 @@ const levels = {
};
// See https://github.com/apvarun/toastify-js#api for options
-function showToast(message, level, {gravity, position, duration, useHtmlBody, ...other} = {}) {
+function showToast(message, level, {gravity, position, duration, useHtmlBody, preventDuplicates = true, ...other} = {}) {
+ const body = useHtmlBody ? String(message) : htmlEscape(message);
+ const key = `${level}-${body}`;
+
+ // prevent showing duplicate toasts with same level and message, and give a visual feedback for end users
+ if (preventDuplicates) {
+ const toastEl = document.querySelector(`.toastify[data-toast-unique-key="${CSS.escape(key)}"]`);
+ if (toastEl) {
+ const toastDupNumEl = toastEl.querySelector('.toast-duplicate-number');
+ showElem(toastDupNumEl);
+ toastDupNumEl.textContent = String(Number(toastDupNumEl.textContent) + 1);
+ animateOnce(toastDupNumEl, 'pulse-1p5-200');
+ return;
+ }
+ }
+
const {icon, background, duration: levelDuration} = levels[level ?? 'info'];
const toast = Toastify({
text: `
${svg(icon)}
- ${useHtmlBody ? message : htmlEscape(message)}
-
+ 1${body}
+
`,
escapeMarkup: false,
gravity: gravity ?? 'top',
@@ -39,6 +55,7 @@ function showToast(message, level, {gravity, position, duration, useHtmlBody, ..
toast.showToast();
toast.toastElement.querySelector('.toast-close').addEventListener('click', () => toast.hideToast());
+ toast.toastElement.setAttribute('data-toast-unique-key', key);
return toast;
}
diff --git a/web_src/js/standalone/devtest.js b/web_src/js/standalone/devtest.js
index 8dbba554acb..d3e3e13a879 100644
--- a/web_src/js/standalone/devtest.js
+++ b/web_src/js/standalone/devtest.js
@@ -1,11 +1,14 @@
import {showInfoToast, showWarningToast, showErrorToast} from '../modules/toast.js';
-document.querySelector('#info-toast').addEventListener('click', () => {
- showInfoToast('success 😀');
-});
-document.querySelector('#warning-toast').addEventListener('click', () => {
- showWarningToast('warning 😐');
-});
-document.querySelector('#error-toast').addEventListener('click', () => {
- showErrorToast('error 🙁');
-});
+function initDevtestToast() {
+ const levelMap = {info: showInfoToast, warning: showWarningToast, error: showErrorToast};
+ for (const el of document.querySelectorAll('.toast-test-button')) {
+ el.addEventListener('click', () => {
+ const level = el.getAttribute('data-toast-level');
+ const message = el.getAttribute('data-toast-message');
+ levelMap[level](message);
+ });
+ }
+}
+
+initDevtestToast();
diff --git a/web_src/js/utils/dom.js b/web_src/js/utils/dom.js
index 6a38968899f..9bdb2332362 100644
--- a/web_src/js/utils/dom.js
+++ b/web_src/js/utils/dom.js
@@ -306,3 +306,14 @@ export function createElementFromAttrs(tagName, attrs) {
}
return el;
}
+
+export function animateOnce(el, animationClassName) {
+ return new Promise((resolve) => {
+ el.addEventListener('animationend', function onAnimationEnd() {
+ el.classList.remove(animationClassName);
+ el.removeEventListener('animationend', onAnimationEnd);
+ resolve();
+ }, {once: true});
+ el.classList.add(animationClassName);
+ });
+}