mirror of
https://github.com/php-flasher/php-flasher.git
synced 2026-03-31 15:07:47 +01:00
555 lines
20 KiB
JavaScript
555 lines
20 KiB
JavaScript
/**
|
|
* @package PHPFlasher
|
|
* @author Younes ENNAJI
|
|
* @license MIT
|
|
*/
|
|
function __awaiter(thisArg, _arguments, P, generator) {
|
|
function adopt(value) {
|
|
return value instanceof P ? value : new P(function (resolve) {
|
|
resolve(value);
|
|
});
|
|
}
|
|
return new (P || (P = Promise))(function (resolve, reject) {
|
|
function fulfilled(value) {
|
|
try {
|
|
step(generator.next(value));
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
}
|
|
function rejected(value) {
|
|
try {
|
|
step(generator["throw"](value));
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
}
|
|
function step(result) {
|
|
result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected);
|
|
}
|
|
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
});
|
|
}
|
|
typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
|
|
var e = new Error(message);
|
|
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
};
|
|
|
|
class AbstractPlugin {
|
|
success(message, title, options) {
|
|
this.flash('success', message, title, options);
|
|
}
|
|
error(message, title, options) {
|
|
this.flash('error', message, title, options);
|
|
}
|
|
info(message, title, options) {
|
|
this.flash('info', message, title, options);
|
|
}
|
|
warning(message, title, options) {
|
|
this.flash('warning', message, title, options);
|
|
}
|
|
flash(type, message, title, options) {
|
|
let normalizedType;
|
|
let normalizedMessage;
|
|
let normalizedTitle;
|
|
let normalizedOptions = {};
|
|
if (typeof type === 'object') {
|
|
normalizedOptions = Object.assign({}, type);
|
|
normalizedType = normalizedOptions.type;
|
|
normalizedMessage = normalizedOptions.message;
|
|
normalizedTitle = normalizedOptions.title;
|
|
delete normalizedOptions.type;
|
|
delete normalizedOptions.message;
|
|
delete normalizedOptions.title;
|
|
}
|
|
else if (typeof message === 'object') {
|
|
normalizedOptions = Object.assign({}, message);
|
|
normalizedType = type;
|
|
normalizedMessage = normalizedOptions.message;
|
|
normalizedTitle = normalizedOptions.title;
|
|
delete normalizedOptions.message;
|
|
delete normalizedOptions.title;
|
|
}
|
|
else {
|
|
normalizedType = type;
|
|
normalizedMessage = message;
|
|
if (title === undefined || title === null) {
|
|
normalizedTitle = undefined;
|
|
normalizedOptions = options || {};
|
|
}
|
|
else if (typeof title === 'string') {
|
|
normalizedTitle = title;
|
|
normalizedOptions = options || {};
|
|
}
|
|
else if (typeof title === 'object') {
|
|
normalizedOptions = Object.assign({}, title);
|
|
if ('title' in normalizedOptions) {
|
|
normalizedTitle = normalizedOptions.title;
|
|
delete normalizedOptions.title;
|
|
}
|
|
else {
|
|
normalizedTitle = undefined;
|
|
}
|
|
if (options && typeof options === 'object') {
|
|
normalizedOptions = Object.assign(Object.assign({}, normalizedOptions), options);
|
|
}
|
|
}
|
|
}
|
|
if (!normalizedType) {
|
|
throw new Error('Type is required for notifications');
|
|
}
|
|
if (normalizedMessage === undefined || normalizedMessage === null) {
|
|
throw new Error('Message is required for notifications');
|
|
}
|
|
if (normalizedTitle === undefined || normalizedTitle === null) {
|
|
normalizedTitle = normalizedType.charAt(0).toUpperCase() + normalizedType.slice(1);
|
|
}
|
|
const envelope = {
|
|
type: normalizedType,
|
|
message: normalizedMessage,
|
|
title: normalizedTitle,
|
|
options: normalizedOptions,
|
|
metadata: {
|
|
plugin: '',
|
|
},
|
|
};
|
|
this.renderOptions(normalizedOptions);
|
|
this.renderEnvelopes([envelope]);
|
|
}
|
|
}
|
|
|
|
class FlasherPlugin extends AbstractPlugin {
|
|
constructor(theme) {
|
|
super();
|
|
this.options = {
|
|
timeout: null,
|
|
timeouts: {
|
|
success: 10000,
|
|
info: 10000,
|
|
error: 10000,
|
|
warning: 10000,
|
|
},
|
|
fps: 30,
|
|
position: 'top-right',
|
|
direction: 'top',
|
|
rtl: false,
|
|
style: {},
|
|
escapeHtml: false,
|
|
};
|
|
if (!theme) {
|
|
throw new Error('Theme is required');
|
|
}
|
|
if (typeof theme.render !== 'function') {
|
|
throw new TypeError('Theme must have a render function');
|
|
}
|
|
this.theme = theme;
|
|
}
|
|
renderEnvelopes(envelopes) {
|
|
if (!(envelopes === null || envelopes === void 0 ? void 0 : envelopes.length)) {
|
|
return;
|
|
}
|
|
const render = () => {
|
|
envelopes.forEach((envelope) => {
|
|
var _a, _b, _c, _d;
|
|
try {
|
|
const typeTimeout = (_b = (_a = this.options.timeout) !== null && _a !== void 0 ? _a : this.options.timeouts[envelope.type]) !== null && _b !== void 0 ? _b : 10000;
|
|
const mergedOptions = Object.assign(Object.assign(Object.assign({}, this.options), envelope.options), { timeout: (_c = envelope.options.timeout) !== null && _c !== void 0 ? _c : typeTimeout, escapeHtml: ((_d = envelope.options.escapeHtml) !== null && _d !== void 0 ? _d : this.options.escapeHtml) });
|
|
const container = this.createContainer(mergedOptions);
|
|
const containerOptions = {
|
|
direction: mergedOptions.direction,
|
|
timeout: Number(mergedOptions.timeout || 0),
|
|
fps: mergedOptions.fps,
|
|
rtl: mergedOptions.rtl,
|
|
escapeHtml: mergedOptions.escapeHtml,
|
|
};
|
|
this.addToContainer(container, envelope, containerOptions);
|
|
}
|
|
catch (error) {
|
|
console.error('PHPFlasher: Error rendering envelope', error, envelope);
|
|
}
|
|
});
|
|
};
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', render);
|
|
}
|
|
else {
|
|
render();
|
|
}
|
|
}
|
|
renderOptions(options) {
|
|
if (!options) {
|
|
return;
|
|
}
|
|
this.options = Object.assign(Object.assign({}, this.options), options);
|
|
}
|
|
createContainer(options) {
|
|
let container = document.querySelector(`.fl-wrapper[data-position="${options.position}"]`);
|
|
if (!container) {
|
|
container = document.createElement('div');
|
|
container.className = 'fl-wrapper';
|
|
container.dataset.position = options.position;
|
|
Object.entries(options.style).forEach(([key, value]) => {
|
|
if (value !== undefined && value !== null) {
|
|
container.style.setProperty(key, String(value));
|
|
}
|
|
});
|
|
document.body.appendChild(container);
|
|
}
|
|
container.dataset.turboTemporary = '';
|
|
return container;
|
|
}
|
|
addToContainer(container, envelope, options) {
|
|
if (options.escapeHtml) {
|
|
envelope.title = this.escapeHtml(envelope.title);
|
|
envelope.message = this.escapeHtml(envelope.message);
|
|
}
|
|
const notification = this.stringToHTML(this.theme.render(envelope));
|
|
notification.classList.add('fl-container');
|
|
if (options.rtl) {
|
|
notification.classList.add('fl-rtl');
|
|
}
|
|
if (options.direction === 'bottom') {
|
|
container.append(notification);
|
|
}
|
|
else {
|
|
container.prepend(notification);
|
|
}
|
|
requestAnimationFrame(() => notification.classList.add('fl-show'));
|
|
const closeButton = notification.querySelector('.fl-close');
|
|
if (closeButton) {
|
|
closeButton.addEventListener('click', (event) => {
|
|
event.stopPropagation();
|
|
this.removeNotification(notification);
|
|
});
|
|
}
|
|
if (options.timeout > 0) {
|
|
this.addTimer(notification, options);
|
|
}
|
|
}
|
|
addTimer(notification, { timeout, fps }) {
|
|
if (timeout <= 0) {
|
|
return;
|
|
}
|
|
const lapse = 1000 / fps;
|
|
let elapsed = 0;
|
|
let intervalId;
|
|
const updateTimer = () => {
|
|
elapsed += lapse;
|
|
const progressBarContainer = notification.querySelector('.fl-progress-bar');
|
|
if (progressBarContainer) {
|
|
let progressBar = progressBarContainer.querySelector('.fl-progress');
|
|
if (!progressBar) {
|
|
progressBar = document.createElement('span');
|
|
progressBar.classList.add('fl-progress');
|
|
progressBarContainer.append(progressBar);
|
|
}
|
|
const percent = (1 - elapsed / timeout) * 100;
|
|
progressBar.style.width = `${Math.max(0, percent)}%`;
|
|
}
|
|
if (elapsed >= timeout) {
|
|
clearInterval(intervalId);
|
|
this.removeNotification(notification);
|
|
}
|
|
};
|
|
intervalId = window.setInterval(updateTimer, lapse);
|
|
notification.addEventListener('mouseout', () => {
|
|
clearInterval(intervalId);
|
|
intervalId = window.setInterval(updateTimer, lapse);
|
|
});
|
|
notification.addEventListener('mouseover', () => clearInterval(intervalId));
|
|
}
|
|
removeNotification(notification) {
|
|
if (!notification) {
|
|
return;
|
|
}
|
|
notification.classList.remove('fl-show');
|
|
notification.ontransitionend = () => {
|
|
const parent = notification.parentElement;
|
|
notification.remove();
|
|
if (parent && !parent.hasChildNodes()) {
|
|
parent.remove();
|
|
}
|
|
};
|
|
}
|
|
stringToHTML(str) {
|
|
const template = document.createElement('template');
|
|
template.innerHTML = str.trim();
|
|
return template.content.firstElementChild;
|
|
}
|
|
escapeHtml(str) {
|
|
if (str == null) {
|
|
return '';
|
|
}
|
|
const htmlEscapes = {
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"',
|
|
'\'': ''',
|
|
'`': '`',
|
|
'=': '=',
|
|
'/': '/',
|
|
};
|
|
return str.replace(/[&<>"'`=/]/g, (char) => htmlEscapes[char] || char);
|
|
}
|
|
}
|
|
|
|
class Flasher extends AbstractPlugin {
|
|
constructor() {
|
|
super(...arguments);
|
|
this.defaultPlugin = 'flasher';
|
|
this.plugins = new Map();
|
|
this.themes = new Map();
|
|
this.loadedAssets = new Set();
|
|
}
|
|
render(response) {
|
|
return __awaiter(this, void 0, void 0, function* () {
|
|
const resolved = this.resolveResponse(response);
|
|
try {
|
|
yield this.addAssets([
|
|
{
|
|
urls: resolved.styles,
|
|
nonce: resolved.context.csp_style_nonce,
|
|
type: 'style',
|
|
},
|
|
{
|
|
urls: resolved.scripts,
|
|
nonce: resolved.context.csp_script_nonce,
|
|
type: 'script',
|
|
},
|
|
]);
|
|
this.renderOptions(resolved.options);
|
|
this.renderEnvelopes(resolved.envelopes);
|
|
}
|
|
catch (error) {
|
|
console.error('PHPFlasher: Error rendering notifications', error);
|
|
}
|
|
});
|
|
}
|
|
renderEnvelopes(envelopes) {
|
|
if (!(envelopes === null || envelopes === void 0 ? void 0 : envelopes.length)) {
|
|
return;
|
|
}
|
|
const groupedByPlugin = {};
|
|
envelopes.forEach((envelope) => {
|
|
const plugin = this.resolvePluginAlias(envelope.metadata.plugin);
|
|
groupedByPlugin[plugin] = groupedByPlugin[plugin] || [];
|
|
groupedByPlugin[plugin].push(envelope);
|
|
});
|
|
Object.entries(groupedByPlugin).forEach(([pluginName, pluginEnvelopes]) => {
|
|
try {
|
|
this.use(pluginName).renderEnvelopes(pluginEnvelopes);
|
|
}
|
|
catch (error) {
|
|
console.error(`PHPFlasher: Error rendering envelopes for plugin "${pluginName}"`, error);
|
|
}
|
|
});
|
|
}
|
|
renderOptions(options) {
|
|
if (!options) {
|
|
return;
|
|
}
|
|
Object.entries(options).forEach(([plugin, option]) => {
|
|
try {
|
|
this.use(plugin).renderOptions(option);
|
|
}
|
|
catch (error) {
|
|
console.error(`PHPFlasher: Error applying options for plugin "${plugin}"`, error);
|
|
}
|
|
});
|
|
}
|
|
addPlugin(name, plugin) {
|
|
if (!name || !plugin) {
|
|
throw new Error('Both plugin name and instance are required');
|
|
}
|
|
this.plugins.set(name, plugin);
|
|
}
|
|
addTheme(name, theme) {
|
|
if (!name || !theme) {
|
|
throw new Error('Both theme name and definition are required');
|
|
}
|
|
this.themes.set(name, theme);
|
|
}
|
|
use(name) {
|
|
const resolvedName = this.resolvePluginAlias(name);
|
|
this.resolvePlugin(resolvedName);
|
|
const plugin = this.plugins.get(resolvedName);
|
|
if (!plugin) {
|
|
throw new Error(`Unable to resolve "${resolvedName}" plugin, did you forget to register it?`);
|
|
}
|
|
return plugin;
|
|
}
|
|
create(name) {
|
|
return this.use(name);
|
|
}
|
|
resolveResponse(response) {
|
|
const resolved = Object.assign({ envelopes: [], options: {}, scripts: [], styles: [], context: {} }, response);
|
|
Object.entries(resolved.options).forEach(([plugin, options]) => {
|
|
resolved.options[plugin] = this.resolveOptions(options);
|
|
});
|
|
resolved.context.csp_style_nonce = resolved.context.csp_style_nonce || '';
|
|
resolved.context.csp_script_nonce = resolved.context.csp_script_nonce || '';
|
|
resolved.envelopes.forEach((envelope) => {
|
|
envelope.metadata = envelope.metadata || {};
|
|
envelope.metadata.plugin = this.resolvePluginAlias(envelope.metadata.plugin);
|
|
this.addThemeStyles(resolved, envelope.metadata.plugin);
|
|
envelope.options = this.resolveOptions(envelope.options);
|
|
envelope.context = response.context;
|
|
});
|
|
return resolved;
|
|
}
|
|
resolveOptions(options) {
|
|
if (!options) {
|
|
return {};
|
|
}
|
|
const resolved = Object.assign({}, options);
|
|
Object.entries(resolved).forEach(([key, value]) => {
|
|
resolved[key] = this.resolveFunction(value);
|
|
});
|
|
return resolved;
|
|
}
|
|
resolveFunction(func) {
|
|
var _a, _b;
|
|
if (typeof func !== 'string') {
|
|
return func;
|
|
}
|
|
const functionRegex = /^function\s*(\w*)\s*\(([^)]*)\)\s*\{([\s\S]*)\}$/;
|
|
const arrowFunctionRegex = /^\s*(\(([^)]*)\)|[^=]+)\s*=>\s*([\s\S]+)$/;
|
|
const match = func.match(functionRegex) || func.match(arrowFunctionRegex);
|
|
if (!match) {
|
|
return func;
|
|
}
|
|
const args = (_b = (_a = match[2]) === null || _a === void 0 ? void 0 : _a.split(',').map((arg) => arg.trim())) !== null && _b !== void 0 ? _b : [];
|
|
let body = match[3].trim();
|
|
if (!body.startsWith('{')) {
|
|
body = `{ return ${body}; }`;
|
|
}
|
|
try {
|
|
return new Function(...args, body);
|
|
}
|
|
catch (e) {
|
|
console.error('PHPFlasher: Error converting string to function:', e);
|
|
return func;
|
|
}
|
|
}
|
|
resolvePlugin(alias) {
|
|
const factory = this.plugins.get(alias);
|
|
if (factory || !alias.includes('theme.')) {
|
|
return;
|
|
}
|
|
const themeName = alias.replace('theme.', '');
|
|
const theme = this.themes.get(themeName);
|
|
if (!theme) {
|
|
return;
|
|
}
|
|
this.addPlugin(alias, new FlasherPlugin(theme));
|
|
}
|
|
resolvePluginAlias(alias) {
|
|
alias = alias || this.defaultPlugin;
|
|
return alias === 'flasher' ? 'theme.flasher' : alias;
|
|
}
|
|
addAssets(assets) {
|
|
return __awaiter(this, void 0, void 0, function* () {
|
|
try {
|
|
const styleAssets = assets.filter((asset) => asset.type === 'style');
|
|
const stylePromises = [];
|
|
for (const { urls, nonce, type } of styleAssets) {
|
|
if (!(urls === null || urls === void 0 ? void 0 : urls.length)) {
|
|
continue;
|
|
}
|
|
for (const url of urls) {
|
|
if (!url || this.loadedAssets.has(url)) {
|
|
continue;
|
|
}
|
|
stylePromises.push(this.loadAsset(url, nonce, type));
|
|
this.loadedAssets.add(url);
|
|
}
|
|
}
|
|
yield Promise.all(stylePromises);
|
|
const scriptAssets = assets.filter((asset) => asset.type === 'script');
|
|
for (const { urls, nonce, type } of scriptAssets) {
|
|
if (!(urls === null || urls === void 0 ? void 0 : urls.length)) {
|
|
continue;
|
|
}
|
|
for (const url of urls) {
|
|
if (!url || this.loadedAssets.has(url)) {
|
|
continue;
|
|
}
|
|
yield this.loadAsset(url, nonce, type);
|
|
this.loadedAssets.add(url);
|
|
}
|
|
}
|
|
}
|
|
catch (error) {
|
|
console.error('PHPFlasher: Error loading assets', error);
|
|
}
|
|
});
|
|
}
|
|
loadAsset(url, nonce, type) {
|
|
if (document.querySelector(`${type === 'style' ? 'link' : 'script'}[src="${url}"]`)) {
|
|
return Promise.resolve();
|
|
}
|
|
return new Promise((resolve, reject) => {
|
|
const element = document.createElement(type === 'style' ? 'link' : 'script');
|
|
if (type === 'style') {
|
|
element.rel = 'stylesheet';
|
|
element.href = url;
|
|
}
|
|
else {
|
|
element.type = 'text/javascript';
|
|
element.src = url;
|
|
}
|
|
if (nonce) {
|
|
element.setAttribute('nonce', nonce);
|
|
}
|
|
element.onload = () => resolve();
|
|
element.onerror = () => reject(new Error(`Failed to load ${url}`));
|
|
document.head.appendChild(element);
|
|
});
|
|
}
|
|
addThemeStyles(response, plugin) {
|
|
if (plugin !== 'flasher' && !plugin.includes('theme.')) {
|
|
return;
|
|
}
|
|
const themeName = plugin.replace('theme.', '');
|
|
const theme = this.themes.get(themeName);
|
|
if (!(theme === null || theme === void 0 ? void 0 : theme.styles)) {
|
|
return;
|
|
}
|
|
const themeStyles = Array.isArray(theme.styles) ? theme.styles : [theme.styles];
|
|
response.styles = Array.from(new Set([...response.styles, ...themeStyles]));
|
|
}
|
|
}
|
|
|
|
const flasherTheme = {
|
|
render: (envelope) => {
|
|
const { type, title, message } = envelope;
|
|
const isAlert = type === 'error' || type === 'warning';
|
|
const role = isAlert ? 'alert' : 'status';
|
|
const ariaLive = isAlert ? 'assertive' : 'polite';
|
|
const displayTitle = title || type.charAt(0).toUpperCase() + type.slice(1);
|
|
return `
|
|
<div class="fl-flasher fl-${type}" role="${role}" aria-live="${ariaLive}" aria-atomic="true">
|
|
<div class="fl-content">
|
|
<div class="fl-icon"></div>
|
|
<div>
|
|
<strong class="fl-title">${displayTitle}</strong>
|
|
<span class="fl-message">${message}</span>
|
|
</div>
|
|
<button class="fl-close" aria-label="Close ${type} message">×</button>
|
|
</div>
|
|
<span class="fl-progress-bar">
|
|
<span class="fl-progress"></span>
|
|
</span>
|
|
</div>`;
|
|
},
|
|
};
|
|
|
|
const flasher = new Flasher();
|
|
flasher.addTheme('flasher', flasherTheme);
|
|
if (typeof window !== 'undefined') {
|
|
window.flasher = flasher;
|
|
}
|
|
|
|
export { flasher as default };
|