Files
MinePanel/src/main/resources/web/theme.js
T
2026-05-01 18:46:17 +02:00

487 lines
16 KiB
JavaScript

(function () {
const STORAGE_KEY = 'minepanel.customTheme';
const CURRENT_USER_CACHE_KEY = 'minepanel.me.cache';
const CURRENT_USER_CACHE_TTL_MS = 5000;
const EXT_NAV_CACHE_KEY = 'minepanel.extNav.cache';
const EXT_NAV_CACHE_TTL_MS = 30000;
function applyTheme(theme) {
if (!theme || typeof theme !== 'object') {
return;
}
const root = document.documentElement;
Object.entries(theme).forEach(([name, value]) => {
if (!name.startsWith('--')) {
return;
}
if (typeof value !== 'string' || value.trim() === '') {
return;
}
root.style.setProperty(name, value);
});
}
function loadTheme() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
return;
}
const parsed = JSON.parse(raw);
applyTheme(parsed);
} catch (ignored) {
// Ignore malformed localStorage data.
}
}
window.MinePanelTheme = {
storageKey: STORAGE_KEY,
applyTheme,
loadTheme
};
async function loadExtensionNavigationTabs() {
const sideNav = document.querySelector('.side-nav');
if (!sideNav) {
return;
}
const extensionManagedPaths = new Set([
'/dashboard/world-backups',
'/dashboard/maintenance',
'/dashboard/whitelist',
'/dashboard/announcements',
'/dashboard/reports',
'/dashboard/tickets'
]);
ensurePanelExtensionsLink(sideNav);
ensurePanelAccountLink(sideNav);
ensurePanelExtensionConfigLink(sideNav);
enforceServerCategoryOrder(sideNav);
const cachedTabs = readCachedExtensionTabs();
if (cachedTabs.length > 0) {
applyExtensionTabs(sideNav, cachedTabs, extensionManagedPaths);
ensurePanelAccountLink(sideNav);
enforceServerCategoryOrder(sideNav);
await applySidebarPermissionVisibility(sideNav);
}
try {
const response = await fetch('/api/extensions/navigation', { credentials: 'same-origin', cache: 'no-store' });
if (!response.ok) {
return;
}
const payload = await response.json();
const tabs = Array.isArray(payload.tabs) ? payload.tabs : [];
applyExtensionTabs(sideNav, tabs, extensionManagedPaths);
writeCachedExtensionTabs(tabs);
if (tabs.length === 0) {
ensurePanelAccountLink(sideNav);
enforceServerCategoryOrder(sideNav);
await applySidebarPermissionVisibility(sideNav);
return;
}
ensurePanelAccountLink(sideNav);
enforceServerCategoryOrder(sideNav);
await applySidebarPermissionVisibility(sideNav);
} catch (ignored) {
// Ignore extension tab loading issues on login/setup pages.
ensurePanelAccountLink(sideNav);
enforceServerCategoryOrder(sideNav);
await applySidebarPermissionVisibility(sideNav);
}
}
function applyExtensionTabs(sideNav, tabs, extensionManagedPaths) {
const sanitizedTabs = (Array.isArray(tabs) ? tabs : [])
.filter(tab => tab && typeof tab.path === 'string' && typeof tab.label === 'string' && typeof tab.category === 'string')
.map(tab => ({
path: tab.path.trim(),
label: tab.label.trim(),
category: tab.category.trim().toLowerCase()
}))
.filter(tab => tab.path && tab.category);
const runtimeTabPaths = new Set(sanitizedTabs.map(tab => tab.path));
// Remove extension links that are not part of the currently loaded extension set.
sideNav.querySelectorAll('a.side-link').forEach(link => {
const href = (link.getAttribute('href') || '').trim();
if (!extensionManagedPaths.has(href)) {
return;
}
if (!runtimeTabPaths.has(href)) {
link.remove();
}
});
for (const tab of sanitizedTabs) {
const container = ensureCategoryContainer(sideNav, tab.category);
if (!container) {
continue;
}
let link = sideNav.querySelector(`a.side-link[href="${cssEscape(tab.path)}"]`);
if (!link) {
link = document.createElement('a');
link.className = 'side-link';
link.href = tab.path;
container.appendChild(link);
} else if (link.parentElement !== container) {
container.appendChild(link);
}
link.textContent = tab.label || tab.path;
link.classList.toggle('active', window.location.pathname === tab.path);
}
}
function readCachedExtensionTabs() {
const now = Date.now();
try {
const cachedRaw = sessionStorage.getItem(EXT_NAV_CACHE_KEY);
if (!cachedRaw) {
return [];
}
const cached = JSON.parse(cachedRaw);
if (!cached || typeof cached !== 'object' || Number(cached.expiresAt || 0) <= now) {
return [];
}
return Array.isArray(cached.tabs) ? cached.tabs : [];
} catch (ignored) {
return [];
}
}
function writeCachedExtensionTabs(tabs) {
try {
sessionStorage.setItem(EXT_NAV_CACHE_KEY, JSON.stringify({
tabs: Array.isArray(tabs) ? tabs : [],
expiresAt: Date.now() + EXT_NAV_CACHE_TTL_MS
}));
} catch (ignored) {
// Ignore unavailable session storage.
}
}
function cssEscape(value) {
if (typeof window.CSS !== 'undefined' && typeof window.CSS.escape === 'function') {
return window.CSS.escape(value);
}
return String(value).replace(/"/g, '\\"');
}
async function applySidebarPermissionVisibility(sideNav) {
const me = await fetchCurrentUser();
if (!me) {
return;
}
if (!Array.isArray(me.permissions)) {
return;
}
if (me.isOwner === true) {
sideNav.querySelectorAll('a.side-link').forEach(link => {
link.style.display = '';
});
return;
}
const permissionSet = new Set(me.permissions);
const linkPermissions = new Map([
['/dashboard/overview', 'VIEW_OVERVIEW'],
['/console', 'VIEW_CONSOLE'],
['/dashboard/console', 'VIEW_CONSOLE'],
['/dashboard/resources', 'VIEW_RESOURCES'],
['/dashboard/players', 'VIEW_PLAYERS'],
['/dashboard/bans', 'VIEW_BANS'],
['/dashboard/plugins', 'VIEW_PLUGINS'],
['/dashboard/users', 'VIEW_USERS'],
['/dashboard/discord-webhook', 'VIEW_DISCORD_WEBHOOK'],
['/dashboard/themes', 'VIEW_THEMES'],
['/dashboard/extensions', 'VIEW_EXTENSIONS'],
['/dashboard/extension-config', 'VIEW_EXTENSIONS'],
['/dashboard/account', 'ACCESS_PANEL'],
['/dashboard/world-backups', 'VIEW_BACKUPS'],
['/dashboard/maintenance', 'VIEW_MAINTENANCE'],
['/dashboard/whitelist', 'VIEW_WHITELIST'],
['/dashboard/announcements', 'VIEW_ANNOUNCEMENTS'],
['/dashboard/reports', 'VIEW_REPORTS'],
['/dashboard/tickets', 'VIEW_TICKETS']
]);
sideNav.querySelectorAll('a.side-link').forEach(link => {
const href = link.getAttribute('href') || '';
const required = linkPermissions.get(href);
if (!required) {
return;
}
const allowed = permissionSet.has(required);
link.style.display = allowed ? '' : 'none';
});
}
async function fetchCurrentUser() {
const now = Date.now();
try {
const cachedRaw = sessionStorage.getItem(CURRENT_USER_CACHE_KEY);
if (cachedRaw) {
const cached = JSON.parse(cachedRaw);
if (cached && typeof cached === 'object' && Number(cached.expiresAt || 0) > now) {
return cached.user || null;
}
}
} catch (ignored) {
// Ignore malformed cache state.
}
try {
const response = await fetch('/api/me', { credentials: 'same-origin', cache: 'no-store' });
if (!response.ok) {
return null;
}
const payload = await response.json();
const user = payload && payload.user ? payload.user : null;
try {
sessionStorage.setItem(CURRENT_USER_CACHE_KEY, JSON.stringify({
user,
expiresAt: now + CURRENT_USER_CACHE_TTL_MS
}));
} catch (ignored) {
// Ignore unavailable sessionStorage.
}
return user;
} catch (ignored) {
return null;
}
}
function enforceServerCategoryOrder(sideNav) {
const serverContainer = sideNav.querySelector('.side-category-items[data-category-items="server"]');
if (!serverContainer) {
return;
}
const preferredOrder = [
'/console',
'/dashboard/console',
'/dashboard/resources',
'/dashboard/world-backups',
'/dashboard/maintenance',
'/dashboard/whitelist',
'/dashboard/announcements',
'/dashboard/players',
'/dashboard/bans',
'/dashboard/plugins',
'/dashboard/reports',
'/dashboard/tickets'
];
const links = Array.from(serverContainer.querySelectorAll('a.side-link'));
if (links.length <= 1) {
return;
}
links.sort((left, right) => {
const leftHref = left.getAttribute('href') || '';
const rightHref = right.getAttribute('href') || '';
const leftIndex = preferredOrder.indexOf(leftHref);
const rightIndex = preferredOrder.indexOf(rightHref);
if (leftIndex >= 0 && rightIndex >= 0) {
return leftIndex - rightIndex;
}
if (leftIndex >= 0) {
return -1;
}
if (rightIndex >= 0) {
return 1;
}
return leftHref.localeCompare(rightHref);
});
for (const link of links) {
serverContainer.appendChild(link);
}
}
function ensureCategoryContainer(sideNav, category) {
let container = sideNav.querySelector(`.side-category-items[data-category-items="${category}"]`);
if (container) {
return container;
}
// Allow extensions to define additional categories without touching every page template.
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'side-category-toggle';
toggle.dataset.category = category;
toggle.textContent = category.charAt(0).toUpperCase() + category.slice(1);
container = document.createElement('div');
container.className = 'side-category-items';
container.dataset.categoryItems = category;
sideNav.appendChild(toggle);
sideNav.appendChild(container);
// Keep behavior consistent with per-page sidebar category script.
toggle.addEventListener('click', () => {
const expanded = !container.classList.contains('expanded');
container.classList.toggle('expanded', expanded);
toggle.classList.toggle('expanded', expanded);
});
return container;
}
function ensurePanelExtensionsLink(sideNav) {
const panelContainer = ensureCategoryContainer(sideNav, 'panel');
if (!panelContainer) {
return;
}
const existing = panelContainer.querySelector('a.side-link[href="/dashboard/extensions"]');
if (existing) {
if (window.location.pathname === '/dashboard/extensions') {
existing.classList.add('active');
}
return;
}
const link = document.createElement('a');
link.className = 'side-link';
if (window.location.pathname === '/dashboard/extensions') {
link.classList.add('active');
}
link.href = '/dashboard/extensions';
link.textContent = 'Extensions';
panelContainer.appendChild(link);
}
function ensurePanelAccountLink(sideNav) {
if (!sideNav) {
return;
}
sideNav.querySelectorAll('a.side-link[href="/dashboard/account"]').forEach(link => {
if (link.dataset.accountBottomLink === 'true') {
return;
}
link.remove();
});
let link = sideNav.querySelector('a.side-link[data-account-bottom-link="true"]');
if (!link) {
link = document.createElement('a');
link.className = 'side-link';
link.dataset.accountBottomLink = 'true';
link.href = '/dashboard/account';
link.textContent = 'Account';
}
link.classList.remove('active');
if (window.location.pathname === '/dashboard/account') {
link.classList.add('active');
}
// Keep Account as the bottom nav item below panel links/categories.
sideNav.appendChild(link);
}
function ensurePanelExtensionConfigLink(sideNav) {
const panelContainer = ensureCategoryContainer(sideNav, 'panel');
if (!panelContainer) {
return;
}
const existing = panelContainer.querySelector('a.side-link[href="/dashboard/extension-config"]');
if (existing) {
if (window.location.pathname === '/dashboard/extension-config') {
existing.classList.add('active');
}
return;
}
const link = document.createElement('a');
link.className = 'side-link';
if (window.location.pathname === '/dashboard/extension-config') {
link.classList.add('active');
}
link.href = '/dashboard/extension-config';
link.textContent = 'Extension Config';
panelContainer.appendChild(link);
}
function bootstrapExtensionNavigationTabs() {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
loadExtensionNavigationTabs();
});
return;
}
loadExtensionNavigationTabs();
}
function startLiveAssetRefresh() {
let lastVersion = null;
async function checkVersion() {
try {
const response = await fetch('/api/web/live-version', {
credentials: 'same-origin',
cache: 'no-store'
});
if (!response.ok) {
return;
}
const payload = await response.json();
const nextVersion = Number(payload.version || 0);
if (!Number.isFinite(nextVersion) || nextVersion <= 0) {
return;
}
if (lastVersion === null) {
lastVersion = nextVersion;
return;
}
if (nextVersion > lastVersion) {
window.location.reload();
return;
}
lastVersion = nextVersion;
} catch (ignored) {
// Ignore transient connectivity issues and try again on next interval.
}
}
checkVersion();
window.setInterval(() => {
if (document.hidden) {
return;
}
checkVersion();
}, 5000);
}
loadTheme();
bootstrapExtensionNavigationTabs();
startLiveAssetRefresh();
})();