546 lines
21 KiB
JavaScript
546 lines
21 KiB
JavaScript
/**
|
|
* AKSRT Runtime Infrastructure
|
|
*
|
|
* Unified core primitives for the entire frontend:
|
|
* aksrt.tbquire(deps, cb) — lazy script/CSS loader
|
|
* aksrt.confirm(msg, opts) — i18n confirm dialog
|
|
* aksrt.scrollTopTo(target, off) — smart scroll with header offset
|
|
* aksrt.loading(el, show) — element-level loading overlay
|
|
* aksrt.route(url, title) — push/replace URL + title
|
|
*
|
|
* Declarative interactions (event delegation on body):
|
|
* [data-close] — close target element
|
|
* [data-onclick] — trigger click on another element
|
|
* [data-confirm] — confirm before action
|
|
* [data-action] — universal AJAX action button (like/follow/checkin)
|
|
* [ajax-action] — general AJAX button with modal response
|
|
*
|
|
* @package AKSRT
|
|
* @since 5.0.0
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
var aksrt = window.aksrt || {};
|
|
window.aksrt = aksrt;
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// §10.11 — tbquire: lazy script/CSS loader
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
var _loadedScripts = {};
|
|
var _loadingPromises = {};
|
|
|
|
function loadSingleScript(src) {
|
|
if (_loadedScripts[src]) return Promise.resolve();
|
|
if (_loadingPromises[src]) return _loadingPromises[src];
|
|
|
|
_loadingPromises[src] = new Promise(function (resolve, reject) {
|
|
var isCSS = /\.css(\?|$)/i.test(src);
|
|
var el;
|
|
|
|
if (isCSS) {
|
|
el = document.createElement('link');
|
|
el.rel = 'stylesheet';
|
|
el.href = src;
|
|
} else {
|
|
el = document.createElement('script');
|
|
el.src = src;
|
|
el.async = true;
|
|
}
|
|
|
|
el.onload = function () {
|
|
_loadedScripts[src] = true;
|
|
delete _loadingPromises[src];
|
|
resolve();
|
|
};
|
|
el.onerror = function () {
|
|
delete _loadingPromises[src];
|
|
reject(new Error('Failed to load: ' + src));
|
|
};
|
|
|
|
(document.head || document.documentElement).appendChild(el);
|
|
});
|
|
|
|
return _loadingPromises[src];
|
|
}
|
|
|
|
aksrt.tbquire = function (deps, callback) {
|
|
if (typeof deps === 'string') deps = [deps];
|
|
if (!Array.isArray(deps) || !deps.length) {
|
|
if (typeof callback === 'function') callback();
|
|
return Promise.resolve();
|
|
}
|
|
|
|
var resolvedDeps = deps.map(function (dep) {
|
|
if (/^https?:\/\/|^\//.test(dep)) return dep;
|
|
var map = (window.aksrtData && aksrtData.tbquireMap) || {};
|
|
return map[dep] || dep;
|
|
});
|
|
|
|
var p = Promise.all(resolvedDeps.map(loadSingleScript));
|
|
if (typeof callback === 'function') {
|
|
p.then(callback).catch(function (err) {
|
|
console.error('[AKSRT tbquire]', err);
|
|
});
|
|
}
|
|
return p;
|
|
};
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// §10.10 — confirm: i18n confirm dialog + [data-confirm]
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
var _i18n = (window.aksrtData && aksrtData.i18n) || {};
|
|
|
|
function __(key, fallback) {
|
|
return _i18n[key] || fallback || key;
|
|
}
|
|
|
|
aksrt.__ = __;
|
|
|
|
aksrt.confirm = function (msg, opts) {
|
|
opts = opts || {};
|
|
return new Promise(function (resolve) {
|
|
var overlay = document.createElement('div');
|
|
overlay.className = 'aksrt-confirm-overlay';
|
|
|
|
var dialog = document.createElement('div');
|
|
dialog.className = 'aksrt-confirm-dialog';
|
|
dialog.setAttribute('role', 'alertdialog');
|
|
dialog.setAttribute('aria-modal', 'true');
|
|
|
|
dialog.innerHTML =
|
|
'<div class="aksrt-confirm__body">' + escapeHtml(msg) + '</div>' +
|
|
'<div class="aksrt-confirm__actions">' +
|
|
'<button type="button" class="aksrt-confirm__cancel btn-ghost">' +
|
|
escapeHtml(opts.cancelText || __('cancel', '取消')) +
|
|
'</button>' +
|
|
'<button type="button" class="aksrt-confirm__ok btn-primary">' +
|
|
escapeHtml(opts.okText || __('confirm', '确定')) +
|
|
'</button>' +
|
|
'</div>';
|
|
|
|
overlay.appendChild(dialog);
|
|
document.body.appendChild(overlay);
|
|
|
|
requestAnimationFrame(function () { overlay.classList.add('is-open'); });
|
|
|
|
function close(result) {
|
|
overlay.classList.remove('is-open');
|
|
setTimeout(function () { overlay.remove(); }, 200);
|
|
resolve(result);
|
|
}
|
|
|
|
dialog.querySelector('.aksrt-confirm__cancel').addEventListener('click', function () { close(false); });
|
|
dialog.querySelector('.aksrt-confirm__ok').addEventListener('click', function () { close(true); });
|
|
overlay.addEventListener('click', function (e) { if (e.target === overlay) close(false); });
|
|
|
|
dialog.querySelector('.aksrt-confirm__ok').focus();
|
|
});
|
|
};
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// §10.10 — scrollTopTo: smart scroll with header offset
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
aksrt.scrollTopTo = function (target, extraOffset) {
|
|
var top = 0;
|
|
if (typeof target === 'number') {
|
|
top = target;
|
|
} else if (target && target.getBoundingClientRect) {
|
|
top = target.getBoundingClientRect().top + window.pageYOffset;
|
|
}
|
|
|
|
var navFixed = document.querySelector('body.nav-fixed .header, .navbar.navbar-top');
|
|
var headerOffset = navFixed ? (navFixed.offsetHeight + 20) : 0;
|
|
top = Math.max(0, top - headerOffset - (extraOffset || 0));
|
|
|
|
var current = window.pageYOffset;
|
|
if (Math.abs(top - current) < 80) return;
|
|
|
|
window.scrollTo({ top: top, behavior: 'smooth' });
|
|
};
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// §10.10 — loading: element-level loading overlay
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
aksrt.loading = function (el, show) {
|
|
if (!el) return;
|
|
if (show === false) {
|
|
var mask = el.querySelector('.aksrt-loading-mask');
|
|
if (mask) {
|
|
mask.classList.add('aksrt-loading-mask--out');
|
|
setTimeout(function () { mask.remove(); }, 300);
|
|
}
|
|
return;
|
|
}
|
|
if (el.querySelector('.aksrt-loading-mask')) return;
|
|
var pos = getComputedStyle(el).position;
|
|
if (pos === 'static') el.style.position = 'relative';
|
|
|
|
var mask = document.createElement('div');
|
|
mask.className = 'aksrt-loading-mask';
|
|
mask.innerHTML = '<svg class="aksrt-loading-mask__spinner" width="36" height="36" viewBox="0 0 36 36">' +
|
|
'<circle cx="18" cy="18" r="15" fill="none" stroke="currentColor" stroke-width="3" stroke-dasharray="80 40" stroke-linecap="round">' +
|
|
'<animateTransform attributeName="transform" type="rotate" from="0 18 18" to="360 18 18" dur="0.8s" repeatCount="indefinite"/>' +
|
|
'</circle></svg>';
|
|
el.appendChild(mask);
|
|
};
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// §10.11 — route: tab route + popstate
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
aksrt.route = function (url, title) {
|
|
if (!window.history || !history.pushState) return;
|
|
history.pushState({ aksrt: true }, title || '', url);
|
|
if (title) document.title = title;
|
|
};
|
|
|
|
aksrt.routeReplace = function (url, title) {
|
|
if (!window.history || !history.replaceState) return;
|
|
history.replaceState({ aksrt: true }, title || '', url);
|
|
if (title) document.title = title;
|
|
};
|
|
|
|
// Tab route: elements with [data-route] push URL on activation
|
|
window.addEventListener('popstate', function (e) {
|
|
var routeTabs = document.querySelectorAll('[data-route]');
|
|
if (!routeTabs.length) return;
|
|
|
|
var path = window.location.pathname + window.location.search;
|
|
routeTabs.forEach(function (tab) {
|
|
var tabRoute = tab.getAttribute('data-route');
|
|
var isMatch = path === tabRoute || path.indexOf(tabRoute) === 0;
|
|
tab.classList.toggle('is-active', isMatch);
|
|
var target = tab.getAttribute('data-route-target');
|
|
if (target) {
|
|
var panel = document.querySelector(target);
|
|
if (panel) panel.classList.toggle('is-active', isMatch);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// §10.11 — Declarative interactions (event delegation)
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
document.addEventListener('click', function (e) {
|
|
var target = e.target.closest('[data-close]');
|
|
if (target) {
|
|
e.preventDefault();
|
|
var sel2 = target.getAttribute('data-close');
|
|
var el2 = sel2 ? document.querySelector(sel2) : target.parentElement;
|
|
if (el2) {
|
|
el2.classList.remove('is-open', 'is-active', 'is-visible');
|
|
el2.hidden = true;
|
|
}
|
|
return;
|
|
}
|
|
|
|
target = e.target.closest('[data-onclick]');
|
|
if (target) {
|
|
e.preventDefault();
|
|
var sel3 = target.getAttribute('data-onclick');
|
|
var el3 = document.querySelector(sel3);
|
|
if (el3) el3.click();
|
|
return;
|
|
}
|
|
});
|
|
|
|
// [data-confirm] — intercept submission
|
|
document.addEventListener('click', function (e) {
|
|
var target = e.target.closest('[data-confirm]');
|
|
if (!target) return;
|
|
|
|
var msg = target.getAttribute('data-confirm');
|
|
if (!msg) return;
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
aksrt.confirm(msg).then(function (ok) {
|
|
if (!ok) return;
|
|
target.removeAttribute('data-confirm');
|
|
target.click();
|
|
target.setAttribute('data-confirm', msg);
|
|
});
|
|
}, true);
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// §10.11 — [data-action] / [ajax-action] universal buttons
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
// Only these data-action values are network AJAX. UI-only actions
|
|
// (toggle-theme, editor toolbar, download, etc.) must not hit admin-ajax.
|
|
var DATA_ACTION_AJAX = {
|
|
like: true,
|
|
favorite: true,
|
|
follow: true,
|
|
checkin: true
|
|
};
|
|
|
|
document.addEventListener('click', function (e) {
|
|
// [data-action] — like/follow/checkin with localStorage de-dup
|
|
var btn = e.target.closest('[data-action]');
|
|
if (btn) {
|
|
var dataAction = btn.getAttribute('data-action') || '';
|
|
if (!DATA_ACTION_AJAX[dataAction]) {
|
|
// Leave non-AJAX data-action handlers (theme toggle, toolbar, download) alone.
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
handleDataAction(btn);
|
|
return;
|
|
}
|
|
|
|
// [ajax-action] — general AJAX button
|
|
btn = e.target.closest('[ajax-action]');
|
|
if (btn) {
|
|
e.preventDefault();
|
|
handleAjaxAction(btn);
|
|
return;
|
|
}
|
|
});
|
|
|
|
function handleDataAction(btn) {
|
|
if (btn.classList.contains('is-loading')) return;
|
|
|
|
var action = btn.getAttribute('data-action');
|
|
if (!action || !DATA_ACTION_AJAX[action]) return;
|
|
|
|
var id = btn.getAttribute('data-id') || '';
|
|
var storageKey = 'aksrt_action_' + action + '_' + id;
|
|
|
|
if (action === 'like' || action === 'favorite') {
|
|
try {
|
|
if (localStorage.getItem(storageKey) && !btn.classList.contains('is-active')) {
|
|
notyf(__('already_done', '已操作'), 'warning');
|
|
return;
|
|
}
|
|
} catch (ex) { /* storage unavailable */ }
|
|
}
|
|
|
|
btn.classList.add('is-loading');
|
|
|
|
var ajaxUrl = (window.aksrtData && aksrtData.ajaxUrl) || '/wp-admin/admin-ajax.php';
|
|
if (!(window.aksrtData && aksrtData.nonce)) {
|
|
btn.classList.remove('is-loading');
|
|
notyf(__('action_failed', '操作失败'), 'danger');
|
|
return;
|
|
}
|
|
var params = new URLSearchParams();
|
|
params.set('action', 'aksrt_' + action);
|
|
params.set('nonce', aksrtData.nonce);
|
|
params.set('id', id);
|
|
|
|
var extraData = btn.getAttribute('data-action-data');
|
|
if (extraData) {
|
|
try {
|
|
var ed = JSON.parse(extraData);
|
|
Object.keys(ed).forEach(function (k) { params.set(k, ed[k]); });
|
|
} catch (ex) { /* ignore */ }
|
|
}
|
|
|
|
fetch(ajaxUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
|
|
credentials: 'same-origin',
|
|
body: params.toString()
|
|
})
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (json) {
|
|
btn.classList.remove('is-loading');
|
|
if (json && json.success) {
|
|
var isActive = !!json.data && !!json.data.active;
|
|
btn.classList.toggle('is-active', isActive);
|
|
|
|
var countEl = btn.querySelector('[data-count]') || btn.querySelector('.action-count');
|
|
if (countEl && json.data && json.data.count !== undefined) {
|
|
countEl.textContent = String(json.data.count);
|
|
}
|
|
|
|
try {
|
|
if (isActive) localStorage.setItem(storageKey, '1');
|
|
else localStorage.removeItem(storageKey);
|
|
} catch (ex) { /* */ }
|
|
|
|
if (json.data && json.data.message) {
|
|
notyf(json.data.message, 'success');
|
|
}
|
|
|
|
if (json.data && json.data.goto) {
|
|
window.location.href = json.data.goto;
|
|
}
|
|
} else {
|
|
notyf((json.data && json.data.message) || __('action_failed', '操作失败'), 'danger');
|
|
}
|
|
})
|
|
.catch(function (err) {
|
|
btn.classList.remove('is-loading');
|
|
notyf(err.message || __('network_error', '网络错误'), 'danger');
|
|
});
|
|
}
|
|
|
|
function handleAjaxAction(btn) {
|
|
if (btn.classList.contains('is-loading')) return;
|
|
btn.classList.add('is-loading');
|
|
|
|
var action = btn.getAttribute('ajax-action');
|
|
var ajaxUrl = (window.aksrtData && aksrtData.ajaxUrl) || '/wp-admin/admin-ajax.php';
|
|
var params = new URLSearchParams();
|
|
params.set('action', action);
|
|
if (window.aksrtData && aksrtData.nonce) params.set('nonce', aksrtData.nonce);
|
|
|
|
var form = btn.closest('form');
|
|
if (form) {
|
|
new FormData(form).forEach(function (v, k) { params.set(k, v); });
|
|
}
|
|
|
|
fetch(ajaxUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
|
|
credentials: 'same-origin',
|
|
body: params.toString()
|
|
})
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (json) {
|
|
btn.classList.remove('is-loading');
|
|
if (json && json.success) {
|
|
if (json.data && json.data.show_modal) {
|
|
window.AksrtModalRuntime.open({
|
|
html: json.data.show_modal,
|
|
bottom: true,
|
|
mobileBottom: true,
|
|
kind: 'generic',
|
|
});
|
|
} else if (json.data && json.data.message) {
|
|
notyf(json.data.message, 'success');
|
|
}
|
|
if (json.data && json.data.goto) {
|
|
window.location.href = json.data.goto;
|
|
}
|
|
if (json.data && json.data.reload) {
|
|
window.location.reload();
|
|
}
|
|
} else {
|
|
notyf((json.data && json.data.message) || __('action_failed', '操作失败'), 'danger');
|
|
}
|
|
})
|
|
.catch(function (err) {
|
|
btn.classList.remove('is-loading');
|
|
notyf(err.message || __('network_error', '网络错误'), 'danger');
|
|
});
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// §10.11 — Ripple (broad coverage)
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
var RIPPLE_SELECTORS = '.toggle-radius,.but-ripple,.but,.btn,.btn-primary,.btn-ghost,.btn-outline,.item-thumbnail,.menu-item>a,.aksrt-ripple,.yiyan-box,.aksrt-card';
|
|
|
|
document.addEventListener('click', function (e) {
|
|
var target = e.target.closest(RIPPLE_SELECTORS);
|
|
if (!target) return;
|
|
// Markup opt-out (header publish uses class="nowave") + nav CTA chips
|
|
if (target.classList.contains('nowave') || target.closest('.nowave')) return;
|
|
if (target.classList.contains('navbar-cta') || target.closest('.navbar-cta')) return;
|
|
if (target.closest('.navbar-top .navbar-but, .navbar-top .navbar-vip, .navbar-but-cluster')) return;
|
|
if (target.querySelector('.aksrt-wave')) return;
|
|
|
|
var rect = target.getBoundingClientRect();
|
|
var size = Math.max(rect.width, rect.height) * 2;
|
|
var x = e.clientX - rect.left - size / 2;
|
|
var y = e.clientY - rect.top - size / 2;
|
|
|
|
var wave = document.createElement('span');
|
|
wave.className = 'aksrt-wave';
|
|
// Must be absolute — without it the huge span expands the button box ("变长").
|
|
wave.style.cssText = 'position:absolute;pointer-events:none;border-radius:50%;background:rgba(255,255,255,0.35);width:'
|
|
+ size + 'px;height:' + size + 'px;left:' + x + 'px;top:' + y + 'px;transform:scale(0);animation:aksrtWaveRipple .6s ease-out forwards;';
|
|
|
|
var cs = getComputedStyle(target);
|
|
if (cs.position === 'static') target.style.position = 'relative';
|
|
if (cs.overflow === 'visible') target.style.overflow = 'hidden';
|
|
|
|
target.appendChild(wave);
|
|
setTimeout(function () {
|
|
if (wave.parentNode) wave.parentNode.removeChild(wave);
|
|
}, 600);
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// §10.11 — auto_fun: extended rehydration coverage
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
aksrt.autoFun = function (root) {
|
|
root = root || document;
|
|
if (window.AksrtFrontend) {
|
|
AksrtFrontend.rehydrate(root, 'ajax');
|
|
}
|
|
|
|
// Tooltips
|
|
root.querySelectorAll('[data-tooltip]').forEach(function (el) {
|
|
if (el.dataset.tooltipBound) return;
|
|
el.dataset.tooltipBound = '1';
|
|
el.addEventListener('mouseenter', function () {
|
|
var tip = document.createElement('div');
|
|
tip.className = 'aksrt-tooltip';
|
|
tip.textContent = el.getAttribute('data-tooltip');
|
|
document.body.appendChild(tip);
|
|
var rect = el.getBoundingClientRect();
|
|
tip.style.left = (rect.left + rect.width / 2 - tip.offsetWidth / 2) + 'px';
|
|
tip.style.top = (rect.top - tip.offsetHeight - 8 + window.pageYOffset) + 'px';
|
|
el._aksrtTooltip = tip;
|
|
});
|
|
el.addEventListener('mouseleave', function () {
|
|
if (el._aksrtTooltip) { el._aksrtTooltip.remove(); el._aksrtTooltip = null; }
|
|
});
|
|
});
|
|
|
|
// Countdowns
|
|
root.querySelectorAll('[data-countdown]').forEach(function (el) {
|
|
if (el.dataset.countdownBound) return;
|
|
el.dataset.countdownBound = '1';
|
|
var end = parseInt(el.getAttribute('data-countdown'), 10);
|
|
if (!end) return;
|
|
var iv = setInterval(function () {
|
|
var left = Math.max(0, end - Math.floor(Date.now() / 1000));
|
|
if (left <= 0) { clearInterval(iv); el.textContent = '00:00'; return; }
|
|
var m = Math.floor(left / 60);
|
|
var s = left % 60;
|
|
el.textContent = (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
|
|
}, 1000);
|
|
});
|
|
};
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// Helpers
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
function escapeHtml(str) {
|
|
if (!str) return '';
|
|
return String(str)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
// ── DOMContentLoaded ──
|
|
|
|
function initRuntime() {
|
|
aksrt.autoFun(document);
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', initRuntime);
|
|
} else {
|
|
initRuntime();
|
|
}
|
|
|
|
})();
|
|
|