The first version of this drawer built its own rows in JS. This one renders core's `partials/notifications_list` partial with entries the support plugin now returns in core's notification shape, so the markup is literally the notification drawer's: avatar block, stretched link, muted timeago, per-entry read dot, All/Unread filter row, and the same two footer buttons. The handlers mirror core's too — only the API paths differ, because the list is plugin-owned (core's unread count is one aggregate with no hook to carve a type out of it). Opening the box no longer marks everything read: like the bell, an entry is read when you click it or use "Mark all read", and the dot toggles both ways. Deletes the bespoke row builder and its hand-rolled relative-time helper; `$.timeago` is what core uses for JS-inserted rows. The dropdown reuses the `.notifications-dropdown` class this theme already styles, so it needs no CSS of its own. Hardcoded English is gone (the code-review finding on the first version): every string is a translation key. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
488 lines
16 KiB
JavaScript
488 lines
16 KiB
JavaScript
'use strict';
|
|
|
|
/*
|
|
Hey there!
|
|
|
|
This is the client file for your theme. If you need to do any client-side work in javascript,
|
|
this is where it needs to go.
|
|
|
|
You can listen for page changes by writing something like this:
|
|
|
|
$(window).on('action:ajaxify.end', function(ev, data) {
|
|
var url = data.url;
|
|
console.log('I am now at: ' + url);
|
|
});
|
|
*/
|
|
|
|
(function () {
|
|
const WG_TABLE_SCROLL_CLASS = 'wg-mobile-table-scroll';
|
|
const WIKI_TABLE_SELECTOR = [
|
|
'.westgate-wiki .wiki-page-content.wiki-article-prose > .card-body table',
|
|
'li[component="post"] .content table',
|
|
].join(', ');
|
|
const WIKI_TABLE_MIN_COLUMN_WIDTH = 40;
|
|
const WIKI_TABLE_DESKTOP_MIN_WIDTH = 720;
|
|
const SKIP_TABLE_SELECTOR = [
|
|
`.${WG_TABLE_SCROLL_CLASS}`,
|
|
'.tableWrapper',
|
|
'.table-responsive',
|
|
'.table-responsive-sm',
|
|
'.table-responsive-md',
|
|
'figure.table',
|
|
'.wiki-infobox',
|
|
'.wiki-alignment-table',
|
|
].join(', ');
|
|
|
|
function toArray(list) {
|
|
return Array.prototype.slice.call(list || []);
|
|
}
|
|
|
|
function parsePixelLength(value) {
|
|
if (!value) {
|
|
return 0;
|
|
}
|
|
|
|
const match = String(value).trim().match(/^([0-9]+(?:\.[0-9]+)?)px$/i);
|
|
return match ? parseFloat(match[1]) : 0;
|
|
}
|
|
|
|
function getInlinePixelWidth(element) {
|
|
const styledWidth = element && element.style ? parsePixelLength(element.style.width) : 0;
|
|
if (styledWidth) {
|
|
return styledWidth;
|
|
}
|
|
|
|
if (!element || typeof element.getAttribute !== 'function') {
|
|
return 0;
|
|
}
|
|
|
|
const styleAttribute = element.getAttribute('style') || '';
|
|
const widthMatch = styleAttribute.match(/(?:^|;)\s*width\s*:\s*([0-9]+(?:\.[0-9]+)?)px(?:\s*;|$)/i);
|
|
return widthMatch ? parseFloat(widthMatch[1]) : 0;
|
|
}
|
|
|
|
function tableUsesFluidWidth(table) {
|
|
const styledWidth = table && table.style ? String(table.style.width || '') : '';
|
|
if (/%$/.test(styledWidth.trim())) {
|
|
return true;
|
|
}
|
|
|
|
if (!table || typeof table.getAttribute !== 'function') {
|
|
return false;
|
|
}
|
|
|
|
const styleAttribute = table.getAttribute('style') || '';
|
|
return /(?:^|;)\s*width\s*:\s*[0-9]+(?:\.[0-9]+)?%(?:\s*;|$)/i.test(styleAttribute);
|
|
}
|
|
|
|
function getTableColumnCount(table) {
|
|
let columnCount = 0;
|
|
|
|
if (table && typeof table.querySelectorAll === 'function') {
|
|
columnCount = Math.max(columnCount, table.querySelectorAll('colgroup col').length);
|
|
|
|
toArray(table.querySelectorAll('tr')).forEach((row) => {
|
|
const rowColumnCount = toArray(row.children).reduce((count, cell) => {
|
|
const span = parseInt(cell.getAttribute && cell.getAttribute('colspan'), 10) || cell.colSpan || 1;
|
|
return count + Math.max(1, span);
|
|
}, 0);
|
|
columnCount = Math.max(columnCount, rowColumnCount);
|
|
});
|
|
}
|
|
|
|
return columnCount;
|
|
}
|
|
|
|
function getTableColumnWidthFloor(table, columnCount) {
|
|
if (!table || typeof table.querySelectorAll !== 'function' || !columnCount) {
|
|
return 0;
|
|
}
|
|
|
|
const explicitColumns = toArray(table.querySelectorAll('colgroup col'))
|
|
.map(getInlinePixelWidth)
|
|
.filter(width => width > 0);
|
|
const explicitWidth = explicitColumns.reduce((total, width) => total + width, 0);
|
|
const inferredColumns = Math.max(0, columnCount - explicitColumns.length);
|
|
|
|
return explicitWidth + (inferredColumns * WIKI_TABLE_MIN_COLUMN_WIDTH);
|
|
}
|
|
|
|
function getRenderedWidth(element) {
|
|
if (element && typeof element.getBoundingClientRect === 'function') {
|
|
return element.getBoundingClientRect().width || 0;
|
|
}
|
|
|
|
return element && element.offsetWidth ? element.offsetWidth : 0;
|
|
}
|
|
|
|
function measureMobileTableMinWidth(table) {
|
|
const columnCount = getTableColumnCount(table);
|
|
const measuredWidth = getRenderedWidth(table);
|
|
const columnWidthFloor = getTableColumnWidthFloor(table, columnCount);
|
|
const desktopWidthFloor = tableUsesFluidWidth(table) && columnCount >= 8 ? WIKI_TABLE_DESKTOP_MIN_WIDTH : 0;
|
|
return Math.ceil(Math.max(measuredWidth, columnWidthFloor, desktopWidthFloor));
|
|
}
|
|
|
|
function applyMobileTableMinWidth(wrapper, minWidth) {
|
|
if (minWidth > 0 && wrapper && wrapper.style && typeof wrapper.style.setProperty === 'function') {
|
|
wrapper.style.setProperty('--wg-mobile-table-min-width', `${minWidth}px`);
|
|
}
|
|
}
|
|
|
|
function wrapWestgateWikiTables(root) {
|
|
const scope = root && typeof root.querySelectorAll === 'function' ? root : document;
|
|
if (!scope || typeof scope.querySelectorAll !== 'function') {
|
|
return;
|
|
}
|
|
|
|
const wrapped = [];
|
|
toArray(scope.querySelectorAll(WIKI_TABLE_SELECTOR)).forEach((table) => {
|
|
if (!table || !table.parentNode || table.closest(SKIP_TABLE_SELECTOR)) {
|
|
return;
|
|
}
|
|
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = WG_TABLE_SCROLL_CLASS;
|
|
wrapper.setAttribute('tabindex', '0');
|
|
wrapper.setAttribute('aria-label', 'Scrollable table');
|
|
table.parentNode.insertBefore(wrapper, table);
|
|
wrapper.appendChild(table);
|
|
wrapped.push({ table, wrapper });
|
|
});
|
|
|
|
// batch: measure every table first, then write, so reads and writes
|
|
// don't interleave and force repeated reflows
|
|
wrapped
|
|
.map(({ table, wrapper }) => ({ wrapper, minWidth: measureMobileTableMinWidth(table) }))
|
|
.forEach(({ wrapper, minWidth }) => {
|
|
applyMobileTableMinWidth(wrapper, minWidth);
|
|
});
|
|
}
|
|
|
|
function renderUnreadMenu(menuEl, topics) {
|
|
if (!menuEl) {
|
|
return;
|
|
}
|
|
|
|
const relativePath = (window.config && window.config.relative_path) || '';
|
|
|
|
// remove the loading row and any previously injected topic rows;
|
|
// the empty-state and footer/divider rows are server-rendered and stay put
|
|
const loading = menuEl.querySelector('[data-wg-unread-loading]');
|
|
if (loading) {
|
|
loading.remove();
|
|
}
|
|
toArray(menuEl.querySelectorAll('.wg-unread-topic')).forEach((li) => {
|
|
li.remove();
|
|
});
|
|
|
|
const emptyEl = menuEl.querySelector('[data-wg-unread-empty]');
|
|
const footerEl = menuEl.querySelector('[data-wg-unread-footer]');
|
|
|
|
const list = (topics || []).slice(0, 10);
|
|
if (emptyEl) {
|
|
emptyEl.classList.toggle('hidden', list.length > 0);
|
|
}
|
|
|
|
list.forEach((topic) => {
|
|
const link = document.createElement('a');
|
|
link.className = 'dropdown-item wg-unread-item text-truncate';
|
|
link.href = `${relativePath}/topic/${topic.slug}`;
|
|
link.textContent = topic.title;
|
|
if (topic.category && topic.category.name) {
|
|
const category = document.createElement('small');
|
|
category.className = 'wg-unread-item__category';
|
|
category.textContent = topic.category.name;
|
|
link.appendChild(category);
|
|
}
|
|
|
|
const li = document.createElement('li');
|
|
li.className = 'wg-unread-topic';
|
|
li.appendChild(link);
|
|
|
|
if (footerEl) {
|
|
menuEl.insertBefore(li, footerEl);
|
|
} else {
|
|
menuEl.appendChild(li);
|
|
}
|
|
});
|
|
}
|
|
|
|
/*
|
|
* Support inbox drawer. The support plugin owns its own notification list
|
|
* (core's unread count is one aggregate with no hook to carve a type out
|
|
* of it), but everything the user sees is core's: the rows are rendered
|
|
* with core's own `partials/notifications_list` partial and driven by the
|
|
* same handlers as the bell — filter buttons, per-entry read dot, mark all
|
|
* read. Only the API paths differ.
|
|
*/
|
|
|
|
function setSupportBadge(count) {
|
|
toArray(document.querySelectorAll('[component="support/count"]')).forEach((badge) => {
|
|
badge.textContent = count > 99 ? '99+' : String(count);
|
|
badge.classList.toggle('hidden', count <= 0);
|
|
});
|
|
}
|
|
|
|
function adjustSupportBadge(delta) {
|
|
const badge = document.querySelector('[component="support/count"]');
|
|
const current = parseInt(badge && badge.textContent, 10) || 0;
|
|
setSupportBadge(Math.max(0, current + delta));
|
|
}
|
|
|
|
// Same shape as core's markNotification: PUT marks read, DELETE marks unread.
|
|
function markSupportEntry(entryEl, read) {
|
|
const nid = encodeURIComponent(entryEl.attr('data-nid'));
|
|
const path = '/plugins/support/notifications/' + nid + '/read';
|
|
require(['api'], function (api) {
|
|
(read ? api.put(path) : api.del(path)).then(function () {
|
|
entryEl.toggleClass('unread', !read);
|
|
entryEl.find('.mark-read .unread').toggleClass('hidden', read);
|
|
entryEl.find('.mark-read .read').toggleClass('hidden', !read);
|
|
adjustSupportBadge(read ? -1 : 1);
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderSupportMenu(triggerEl) {
|
|
const dropdownEl = triggerEl.parent().find('.dropdown-menu');
|
|
const listEl = dropdownEl.find('[component="support/list"]');
|
|
dropdownEl.find('[data-filter="all"]').addClass('active');
|
|
dropdownEl.find('[data-filter="unread"]').removeClass('active');
|
|
|
|
require(['api', 'translator'], function (api, translator) {
|
|
api.get('/plugins/support/notifications', {}).then(function (data) {
|
|
const notifications = (data && data.notifications) || [];
|
|
const relativePath = (window.config && window.config.relative_path) || '';
|
|
notifications.forEach((notification) => {
|
|
notification.path = relativePath + notification.path;
|
|
notification.timeagoLong = $.timeago(new Date(notification.datetime));
|
|
});
|
|
app.parseAndTranslate('partials/notifications_list', { notifications }, function (html) {
|
|
listEl.html(html);
|
|
setSupportBadge((data && data.unreadCount) || 0);
|
|
// core's partial says "no notifications"; this box only ever holds
|
|
// support activity, so say that instead.
|
|
translator.translate('[[westgate:support-inbox.empty]]', function (text) {
|
|
listEl.find('.no-notifs .fw-semibold').text(text);
|
|
});
|
|
listEl.off('click').on('click', '[component="notifications/item/link"]', function () {
|
|
const entryEl = $(this).parents('[data-nid]');
|
|
if (entryEl.hasClass('unread')) {
|
|
markSupportEntry(entryEl, true);
|
|
}
|
|
triggerEl.dropdown('toggle');
|
|
});
|
|
listEl.on('click', '.mark-read', function () {
|
|
const entryEl = $(this).parents('[data-nid]');
|
|
markSupportEntry(entryEl, entryEl.hasClass('unread'));
|
|
});
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function getWestgateTopbar() {
|
|
if (!document || typeof document.querySelector !== 'function') {
|
|
return null;
|
|
}
|
|
|
|
return document.querySelector('[data-wg-topbar]');
|
|
}
|
|
|
|
function closeWestgateTopbarDrawer(topbar) {
|
|
if (!topbar) {
|
|
return;
|
|
}
|
|
|
|
topbar.classList.remove('is-drawer-open');
|
|
const burger = topbar.querySelector('[data-wg-burger]');
|
|
if (burger) {
|
|
burger.setAttribute('aria-expanded', 'false');
|
|
}
|
|
}
|
|
|
|
function initWestgateTopbar() {
|
|
const topbar = getWestgateTopbar();
|
|
if (!topbar) {
|
|
return;
|
|
}
|
|
|
|
if (!window.westgateTheme.topbarEventsBound) {
|
|
document.addEventListener('click', (event) => {
|
|
const currentTopbar = getWestgateTopbar();
|
|
if (!currentTopbar) {
|
|
return;
|
|
}
|
|
|
|
const burger = event.target.closest && event.target.closest('[data-wg-burger]');
|
|
if (burger && currentTopbar.contains(burger)) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
const isOpen = !currentTopbar.classList.contains('is-drawer-open');
|
|
currentTopbar.classList.toggle('is-drawer-open', isOpen);
|
|
burger.setAttribute('aria-expanded', String(isOpen));
|
|
return;
|
|
}
|
|
|
|
if (!currentTopbar.contains(event.target)) {
|
|
closeWestgateTopbarDrawer(currentTopbar);
|
|
}
|
|
});
|
|
|
|
document.addEventListener('keydown', (event) => {
|
|
if (event.key !== 'Escape') {
|
|
return;
|
|
}
|
|
|
|
const currentTopbar = getWestgateTopbar();
|
|
closeWestgateTopbarDrawer(currentTopbar);
|
|
});
|
|
|
|
window.westgateTheme.topbarEventsBound = true;
|
|
}
|
|
|
|
$(document)
|
|
.off('shown.bs.dropdown.westgateTopbar')
|
|
.on('shown.bs.dropdown.westgateTopbar', '.wg-topbar [component="sidebar/search"]', function () {
|
|
$(this).find('[component="search/fields"] input[name="query"]').trigger('focus');
|
|
});
|
|
|
|
$(document)
|
|
.off('show.bs.dropdown.westgateUnread')
|
|
.on('show.bs.dropdown.westgateUnread', '.wg-topbar [component="unread"]', function () {
|
|
const menuEl = this.querySelector('[data-wg-unread-menu]');
|
|
const relativePath = (window.config && window.config.relative_path) || '';
|
|
fetch(relativePath + '/api/unread', {
|
|
headers: { accept: 'application/json' },
|
|
}).then(function (res) {
|
|
if (!res.ok) {
|
|
throw new Error('unread fetch failed: ' + res.status);
|
|
}
|
|
return res.json();
|
|
}).then(function (data) {
|
|
renderUnreadMenu(menuEl, data && data.topics);
|
|
}).catch(function () {
|
|
renderUnreadMenu(menuEl, []);
|
|
});
|
|
});
|
|
|
|
// ponytail: unlike core's bell, this badge gets no socket push, so it
|
|
// only catches up when the drawer is opened or the page reloads. Add a
|
|
// socket event in the support plugin if that gap ever matters.
|
|
$(document)
|
|
.off('show.bs.dropdown.westgateSupport')
|
|
.on('show.bs.dropdown.westgateSupport', '.wg-topbar [component="support"] [data-bs-toggle="dropdown"]', function () {
|
|
renderSupportMenu($(this));
|
|
});
|
|
|
|
$(document)
|
|
.off('click.westgateSupport')
|
|
.on('click.westgateSupport', '.wg-topbar [component="support"] .mark-all-read', function (event) {
|
|
event.preventDefault();
|
|
const entries = $(this).closest('.dropdown-menu').find('[data-nid]');
|
|
require(['api'], function (api) {
|
|
api.post('/plugins/support/notifications/read', {}).then(function () {
|
|
setSupportBadge(0);
|
|
entries.removeClass('unread');
|
|
entries.find('.mark-read .unread').addClass('hidden');
|
|
entries.find('.mark-read .read').removeClass('hidden');
|
|
});
|
|
});
|
|
})
|
|
// Core's own filter behaviour: hide read rows client-side, nothing refetched.
|
|
.on('click.westgateSupport', '.wg-topbar [component="support"] [data-filter]', function () {
|
|
const dropdownEl = $(this).closest('.dropdown-menu');
|
|
dropdownEl.find('[data-filter]').removeClass('active');
|
|
this.classList.add('active');
|
|
const unreadOnly = this.getAttribute('data-filter') === 'unread';
|
|
dropdownEl.find('[data-nid]').each(function () {
|
|
this.classList.toggle('hidden', unreadOnly && !this.classList.contains('unread'));
|
|
});
|
|
dropdownEl.find('.no-notifs').toggleClass('hidden', dropdownEl.find('[data-nid]:not(.hidden)').length !== 0);
|
|
});
|
|
}
|
|
|
|
window.westgateTheme = window.westgateTheme || {};
|
|
window.westgateTheme.wrapWikiTables = wrapWestgateWikiTables;
|
|
window.westgateTheme.initTopbar = initWestgateTopbar;
|
|
window.westgateTheme.renderUnreadMenu = renderUnreadMenu;
|
|
window.westgateTheme.renderSupportMenu = renderSupportMenu;
|
|
|
|
$(document).ready(function () {
|
|
wrapWestgateWikiTables(document);
|
|
initWestgateTopbar();
|
|
|
|
// topic-select checkboxes are <i> elements; make them keyboard-operable
|
|
$(document).on('keydown', 'i[component="topic/select"]', function (ev) {
|
|
if (ev.key === 'Enter' || ev.key === ' ') {
|
|
ev.preventDefault();
|
|
$(this).trigger('click');
|
|
}
|
|
});
|
|
$(document).on('click', 'i[component="topic/select"]', function () {
|
|
// core toggles the icon class in its own click handler; sync after it runs
|
|
setTimeout(() => {
|
|
this.setAttribute('aria-checked', this.classList.contains('fa-check-square') ? 'true' : 'false');
|
|
}, 0);
|
|
});
|
|
$(window).on('action:ajaxify.end', function () {
|
|
wrapWestgateWikiTables(document);
|
|
initWestgateTopbar();
|
|
closeWestgateTopbarDrawer(getWestgateTopbar());
|
|
});
|
|
|
|
require(['api'], function (api) {
|
|
const originalGet = api.get.bind(api);
|
|
let categoryClassMapPromise;
|
|
|
|
function getCategoryClassMap() {
|
|
if (!categoryClassMapPromise) {
|
|
categoryClassMapPromise = new Promise((resolve) => {
|
|
originalGet('/categories', {}, function (err, data) {
|
|
if (err || !data || !Array.isArray(data.categories)) {
|
|
return resolve({});
|
|
}
|
|
|
|
const classMap = data.categories.reduce((memo, category) => {
|
|
if (category && category.cid !== undefined) {
|
|
memo[String(category.cid)] = category.class || '';
|
|
}
|
|
return memo;
|
|
}, {});
|
|
|
|
resolve(classMap);
|
|
});
|
|
});
|
|
}
|
|
|
|
return categoryClassMapPromise;
|
|
}
|
|
|
|
api.get = function (url, data, callback) {
|
|
if (url !== '/search/categories' || typeof callback !== 'function') {
|
|
return originalGet(url, data, callback);
|
|
}
|
|
|
|
return originalGet(url, data, function (err, payload) {
|
|
if (err || !payload || !Array.isArray(payload.categories)) {
|
|
return callback(err, payload);
|
|
}
|
|
|
|
getCategoryClassMap().then((classMap) => {
|
|
payload.categories = payload.categories.map(category => ({
|
|
...category,
|
|
class: category.class || classMap[String(category.cid)] || '',
|
|
}));
|
|
callback(null, payload);
|
|
}).catch(() => {
|
|
// class map is cosmetic; never block search results on it
|
|
callback(null, payload);
|
|
});
|
|
});
|
|
};
|
|
});
|
|
});
|
|
}());
|