💋 Sync 2026-10-04 04:16:09

This commit is contained in:
github-actions[bot] committed 2026-10-04 04:16:09 +08:00
1 parent f6b2df73ec
commit f62992fd80
745 files changed
+212416 -1173

No files matched your search

@@ -0,0 +1,514 @@
'use strict';
'require baseclass';
'require ui';
/**
* Native JavaScript slide animation utilities
* Replaces jQuery slideUp/slideDown functionality with better performance
*/
const SlideAnimations = {
/**
* Animation durations in milliseconds.
* When prefers-reduced-motion is active, all animations use 0ms.
*/
durations: {
fast: 200,
normal: 400,
slow: 600
},
/**
* Returns true if the user has requested reduced motion via OS/browser setting.
*/
prefersReducedMotion: function() {
return window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
},
/**
* Map to track running animations and their cleanup functions
*/
runningAnimations: new WeakMap(),
/**
* Slide element down (show) with animation
* @param {Element} element - DOM element to animate
* @param {string|number} duration - Animation duration ('fast', 'normal', 'slow' or milliseconds)
* @param {function} callback - Optional callback function when animation completes
*/
slideDown: function(element, duration, callback) {
if (!element) {
console.warn('SlideAnimations.slideDown: No element provided');
return;
}
// Stop any existing animation on this element
this.stop(element);
// Convert duration string to milliseconds; skip animation for reduced-motion users
const animDuration = this.prefersReducedMotion() ? 0 :
(typeof duration === 'string' ?
this.durations[duration] || this.durations.normal :
(duration || this.durations.normal));
// Store original styles
const originalStyles = {
display: element.style.display,
overflow: element.style.overflow,
height: element.style.height,
transition: element.style.transition
};
// Set initial state for animation
element.style.display = 'block';
element.style.overflow = 'hidden';
element.style.height = '0px';
element.style.transition = `height ${animDuration}ms ease-out`;
// Force reflow to ensure initial state is applied
element.offsetHeight;
// Get the target height
const targetHeight = element.scrollHeight;
// Animate to full height
element.style.height = targetHeight + 'px';
// Set up cleanup function
const cleanup = () => {
element.style.height = originalStyles.height || '';
element.style.overflow = originalStyles.overflow || '';
element.style.transition = originalStyles.transition || '';
// Remove from running animations map
this.runningAnimations.delete(element);
if (callback && typeof callback === 'function') {
try {
callback.call(element);
} catch (e) {
console.error('SlideAnimations callback error:', e);
}
}
};
// Store cleanup function for potential cancellation
const timeoutId = setTimeout(cleanup, animDuration);
this.runningAnimations.set(element, { timeoutId, cleanup });
},
/**
* Slide element up (hide) with animation
* @param {Element} element - DOM element to animate
* @param {string|number} duration - Animation duration ('fast', 'normal', 'slow' or milliseconds)
* @param {function} callback - Optional callback function when animation completes
*/
slideUp: function(element, duration, callback) {
if (!element) {
console.warn('SlideAnimations.slideUp: No element provided');
return;
}
// Stop any existing animation on this element
this.stop(element);
// Convert duration string to milliseconds; skip animation for reduced-motion users
const animDuration = this.prefersReducedMotion() ? 0 :
(typeof duration === 'string' ?
this.durations[duration] || this.durations.normal :
(duration || this.durations.normal));
// Store original styles
const originalStyles = {
display: element.style.display,
overflow: element.style.overflow,
height: element.style.height,
transition: element.style.transition
};
// Get current height before hiding
const currentHeight = element.scrollHeight;
// Set initial state for animation
element.style.overflow = 'hidden';
element.style.height = currentHeight + 'px';
element.style.transition = `height ${animDuration}ms ease-out`;
// Force reflow to ensure initial state is applied
element.offsetHeight;
// Animate to zero height
element.style.height = '0px';
// Set up cleanup function
const cleanup = () => {
element.style.display = 'none';
element.style.height = originalStyles.height || '';
element.style.overflow = originalStyles.overflow || '';
element.style.transition = originalStyles.transition || '';
// Remove from running animations map
this.runningAnimations.delete(element);
if (callback && typeof callback === 'function') {
try {
callback.call(element);
} catch (e) {
console.error('SlideAnimations callback error:', e);
}
}
};
// Store cleanup function for potential cancellation
const timeoutId = setTimeout(cleanup, animDuration);
this.runningAnimations.set(element, { timeoutId, cleanup });
},
/**
* Stop all running animations on an element
* @param {Element} element - DOM element to stop animations on
*/
stop: function(element) {
if (!element) return;
const animationData = this.runningAnimations.get(element);
if (animationData) {
// Clear the timeout
clearTimeout(animationData.timeoutId);
// Run cleanup immediately
animationData.cleanup();
}
// Clear transition to immediately stop any CSS animation
element.style.transition = '';
// Force reflow to apply changes immediately
element.offsetHeight;
},
/**
* Check if element has running animation
* @param {Element} element - DOM element to check
* @returns {boolean} - True if element has running animation
*/
isAnimating: function(element) {
return this.runningAnimations.has(element);
}
};
/**
* NoobWrt Theme Menu Module
* Handles rendering and interaction of the main navigation menu and sidebar
*/
return baseclass.extend({
/**
* Initialize the menu module
* Load menu data and trigger rendering
*/
__init__: function () {
ui.menu.load().then(L.bind(this.render, this));
},
/**
* Main render function for the menu system
* @param {Object} tree - Menu tree structure from LuCI
*/
render: function (tree) {
var node = tree;
var url = '';
this.renderModeMenu(node);
// Render tab menu if we're deep enough in the navigation hierarchy
if (L.env.dispatchpath.length >= 3) {
for (var i = 0; i < 3 && node; i++) {
node = node.children[L.env.dispatchpath[i]];
url = url + (url ? '/' : '') + L.env.dispatchpath[i];
}
if (node) {
this.renderTabMenu(node, url);
}
}
// Attach event listeners for sidebar toggle functionality
var sidebarToggle = document.querySelector('a.showSide');
var darkMask = document.querySelector('.darkMask');
if (sidebarToggle) {
sidebarToggle.addEventListener('click', ui.createHandlerFn(this, 'handleSidebarToggle'));
}
if (darkMask) {
darkMask.addEventListener('click', ui.createHandlerFn(this, 'handleSidebarToggle'));
}
},
/**
* Handle menu expand/collapse functionality
* Manages the sliding animation and active states of menu items
* @param {Event} ev - Click event from menu item
*/
handleMenuExpand: function (ev) {
var target = ev.target;
var slide = target.parentNode;
var slideMenu = target.nextElementSibling;
var shouldCollapse = false;
// Close all currently active submenus
var activeMenus = document.querySelectorAll('.main .main-left .nav > li > ul.active');
activeMenus.forEach(function (ul) {
// Stop any running animations and slide up
SlideAnimations.stop(ul);
// Remove active classes immediately when starting slideUp animation
ul.classList.remove('active');
ul.previousElementSibling.classList.remove('active');
SlideAnimations.slideUp(ul, 'fast');
// Check if we're clicking on an already open menu (should collapse it)
if (!shouldCollapse && ul === slideMenu) {
shouldCollapse = true;
}
});
// Persist open section: clear since we just closed everything
try {
localStorage.setItem('noobwrt.sidenav.open', '[]');
} catch (e) {}
// Exit if there's no submenu to show
if (!slideMenu) {
return;
}
// Open the submenu if it's not already open
if (!shouldCollapse) {
// Find the slide menu within the slide element
var slideMenuElement = slide.querySelector(".slide-menu");
if (slideMenuElement) {
// Add active classes immediately when starting slideDown animation
slideMenu.classList.add('active');
target.classList.add('active');
SlideAnimations.slideDown(slideMenuElement, 'fast');
// Persist which section is open
try {
var openTitle = target.getAttribute('data-title');
if (openTitle) {
localStorage.setItem('noobwrt.sidenav.open', JSON.stringify([openTitle]));
}
} catch (e) {}
}
target.blur(); // Remove focus from the clicked element
}
// Prevent default link behavior and event bubbling
ev.preventDefault();
ev.stopPropagation();
},
/**
* Render the main navigation menu
* Creates hierarchical menu structure with active states and click handlers
* @param {Object} tree - Menu tree node to render
* @param {string} url - Base URL for menu items
* @param {number} level - Current nesting level (0-based)
* @returns {Element} - Generated menu element
*/
renderMainMenu: function (tree, url, level) {
var currentLevel = (level || 0) + 1;
var menuContainer = E('ul', { 'class': level ? 'slide-menu' : 'nav' });
var children = ui.menu.getChildren(tree);
// Don't render empty menus or menus deeper than 2 levels
if (children.length === 0 || currentLevel > 2) {
return E([]);
}
// Generate menu items for each child
for (var i = 0; i < children.length; i++) {
var child = children[i];
var isActive = (
(L.env.dispatchpath[currentLevel] === child.name) &&
(L.env.dispatchpath[currentLevel - 1] === tree.name)
);
// Recursively render submenu
var submenu = this.renderMainMenu(child, url + '/' + child.name, currentLevel);
var hasChildren = submenu.children.length > 0;
// Determine CSS classes based on state
var slideClass = hasChildren ? 'slide' : null;
var menuClass = hasChildren ? 'menu' : 'food';
if (isActive) {
menuContainer.classList.add('active');
slideClass += " active";
menuClass += " active";
}
// Create menu item with link and submenu
var menuItem = E('li', { 'class': slideClass }, [
E('a', {
'href': L.url(url, child.name),
'click': (currentLevel === 1) ? ui.createHandlerFn(this, 'handleMenuExpand') : null,
'class': menuClass,
'data-title': child.title.replace(/ /g, "_"), // More robust space replacement
}, [_(child.title)]),
submenu
]);
menuContainer.appendChild(menuItem);
}
// Append to main menu container if this is the top level
if (currentLevel === 1) {
var mainMenuElement = document.querySelector('#mainmenu');
if (mainMenuElement) {
mainMenuElement.appendChild(menuContainer);
mainMenuElement.style.display = '';
// Restore previously opened submenus from localStorage
var openSections = [];
try {
openSections = JSON.parse(localStorage.getItem('noobwrt.sidenav.open') || '[]');
} catch (e) {}
menuContainer.querySelectorAll('.nav > li.slide > a.menu').forEach(function(anchor) {
var title = anchor.getAttribute('data-title');
var slideMenu = anchor.nextElementSibling;
if (!slideMenu) return;
var isActive = anchor.classList.contains('active');
var wasOpen = openSections.indexOf(title) !== -1;
if (isActive || wasOpen) {
anchor.classList.add('active');
slideMenu.classList.add('active');
slideMenu.style.display = 'block';
}
});
// Scroll the active menu item into view smoothly
var activeItem = mainMenuElement.querySelector('.nav > li > a.active, .nav > li > a.menu.active');
if (activeItem) {
setTimeout(function() {
activeItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 150);
}
}
}
return menuContainer;
},
renderModeMenu: function (tree) {
var menu = document.querySelector('#modemenu');
var children = ui.menu.getChildren(tree);
for (var i = 0; i < children.length; i++) {
var isActive = (L.env.requestpath.length ? children[i].name == L.env.requestpath[0] : i == 0);
if (i > 0)
menu.appendChild(E([], ['\u00a0|\u00a0']));
menu.appendChild(E('li', {}, [
E('a', {
'href': L.url(children[i].name),
'class': isActive ? 'active' : null
}, [_(children[i].title)])
]));
if (isActive)
this.renderMainMenu(children[i], children[i].name);
}
if (menu.children.length > 1)
menu.style.display = '';
},
/**
* Render tab navigation menu
* Creates horizontal tab menu for deeper navigation levels
* @param {Object} tree - Menu tree node to render
* @param {string} url - Base URL for tab items
* @param {number} level - Current nesting level (0-based)
* @returns {Element} - Generated tab menu element
*/
renderTabMenu: function (tree, url, level) {
var container = document.querySelector('#tabmenu');
var currentLevel = (level || 0) + 1;
var tabContainer = E('ul', { 'class': 'tabs' });
var children = ui.menu.getChildren(tree);
var activeNode = null;
// Don't render empty tab menus
if (children.length === 0) {
return E([]);
}
// Generate tab items for each child
for (var i = 0; i < children.length; i++) {
var child = children[i];
var isActive = (L.env.dispatchpath[currentLevel + 2] === child.name);
var activeClass = isActive ? ' active' : '';
var className = 'tabmenu-item-%s %s'.format(child.name, activeClass);
var tabItem = E('li', { 'class': className }, [
E('a', { 'href': L.url(url, child.name) }, [_(child.title)])
]);
tabContainer.appendChild(tabItem);
// Store reference to active node for recursive rendering
if (isActive) {
activeNode = child;
}
}
// Append tab container to main tab menu element
if (container) {
container.appendChild(tabContainer);
container.style.display = '';
// Recursively render nested tab menus if there's an active node
if (activeNode) {
var nestedTabs = this.renderTabMenu(activeNode, url + '/' + activeNode.name, currentLevel);
if (nestedTabs.children.length > 0) {
container.appendChild(nestedTabs);
}
}
}
return tabContainer;
},
/**
* Handle sidebar toggle functionality
* Toggles the mobile/responsive sidebar menu visibility
* @param {Event} ev - Click event from sidebar toggle button or dark mask
*/
handleSidebarToggle: function (ev) {
var showSideButton = document.querySelector('a.showSide');
var sidebar = document.querySelector('#mainmenu');
var darkMask = document.querySelector('.darkMask');
var scrollbarArea = document.querySelector('.main-right');
// Check if any required elements are missing
if (!showSideButton || !sidebar || !darkMask || !scrollbarArea) {
console.warn('Sidebar toggle elements not found');
return;
}
// Toggle sidebar visibility and related states
if (showSideButton.classList.contains('active')) {
// Close sidebar
showSideButton.classList.remove('active');
sidebar.classList.remove('active');
scrollbarArea.classList.remove('active');
darkMask.classList.remove('active');
} else {
// Open sidebar
showSideButton.classList.add('active');
sidebar.classList.add('active');
scrollbarArea.classList.add('active');
darkMask.classList.add('active');
}
}
});
@@ -0,0 +1,306 @@
'use strict';
'require view';
'require form';
'require uci';
'require rpc';
'require ui';
var callSetCustomWallpaper = rpc.declare({
object: 'luci.noobwrt_wallpaper',
method: 'set_custom',
expect: { result: -1 }
});
var callRemoveCustomWallpaper = rpc.declare({
object: 'luci.noobwrt_wallpaper',
method: 'remove_custom',
expect: { result: -1 }
});
return view.extend({
load: function () {
return uci.load('noobwrt').catch(function () {
/* /etc/config/noobwrt is absent (fresh install or missing defaults).
* Seed an in-memory named section 'global' so form.NamedSection
* can render without firing uci/get RPCs that return code 4.
* uci.add() returns the internal sid; setting .name promotes it
* to a named section that form.NamedSection('global') will find. */
var sid = uci.add('noobwrt', 'global');
uci.set('noobwrt', sid, '.name', 'global');
uci.set('noobwrt', sid, 'mode', 'normal');
uci.set('noobwrt', sid, 'primary', '#5e72e4');
uci.set('noobwrt', sid, 'dark_primary', '#7c8ff5');
uci.set('noobwrt', sid, 'blur', '10');
uci.set('noobwrt', sid, 'blur_dark', '10');
uci.set('noobwrt', sid, 'transparency', '0.8');
uci.set('noobwrt', sid, 'transparency_dark', '0.8');
uci.set('noobwrt', sid, 'online_wallpaper', 'none');
uci.set('noobwrt', sid, 'auto_dark_mode', '0');
});
},
render: function () {
var m, s, o;
m = new form.Map('noobwrt', _('NoobWrt Theme Settings'),
_('Customize the appearance, wallpaper, background effects, and toolbar panel of the NoobWrt LuCI theme.'));
/* ============================================================
* SECTION: Global – tabbed (Appearance / Wallpaper / Effects / Toolbar / About)
* ============================================================ */
s = m.section(form.NamedSection, 'global', 'global');
s.addremove = false;
s.anonymous = true;
s.tab('appearance', _('Appearance'));
s.tab('wallpaper', _('Wallpaper'));
s.tab('effects', _('Background Effects'));
s.tab('toolbar', _('Toolbar Panel'));
s.tab('about', _('About'));
/* ---- Appearance ---- */
o = s.taboption('appearance', form.Flag, 'auto_dark_mode',
_('Automatic Light / Dark Mode'),
_('Automatically switch to Dark mode from 6 PM to 6 AM and Light mode from 6 AM to 6 PM. When enabled, the schedule overrides the manual toolbar toggle.'));
o.default = '1';
o = s.taboption('appearance', form.ListValue, 'mode',
_('Default Theme Mode'),
_('Theme applied on page load when Automatic mode is disabled. Users can also toggle it manually using the toolbar sun/moon button.'));
o.value('normal', _('Light'));
o.value('dark', _('Dark'));
o = s.taboption('appearance', form.Value, 'primary',
_('Accent Color — Light Mode'),
_('Primary accent color used throughout the light theme.'));
o.placeholder = '#5e72e4';
o.rmempty = false;
o.renderWidget = function(section_id, option_index, cfgvalue) {
var node = form.Value.prototype.renderWidget.apply(this, arguments);
var input = node.querySelector('input');
if (input) {
input.style.cssText = 'width:140px;display:inline-block;vertical-align:middle;margin-right:6px';
var picker = document.createElement('input');
picker.type = 'color';
picker.value = (cfgvalue && cfgvalue !== this.placeholder) ? cfgvalue : this.placeholder;
picker.style.cssText = 'width:40px;height:32px;padding:2px;border:1px solid #ccc;border-radius:4px;cursor:pointer;vertical-align:middle';
picker.addEventListener('input', function() { input.value = picker.value; input.dispatchEvent(new Event('input', { bubbles: true })); });
input.addEventListener('input', function() { if (/^#[0-9a-fA-F]{6}$/.test(input.value)) picker.value = input.value; });
node.appendChild(picker);
}
return node;
};
o = s.taboption('appearance', form.Value, 'dark_primary',
_('Accent Color — Dark Mode'),
_('Primary accent color used throughout the dark theme.'));
o.placeholder = '#7c8ff5';
o.rmempty = false;
o.renderWidget = function(section_id, option_index, cfgvalue) {
var node = form.Value.prototype.renderWidget.apply(this, arguments);
var input = node.querySelector('input');
if (input) {
input.style.cssText = 'width:140px;display:inline-block;vertical-align:middle;margin-right:6px';
var picker = document.createElement('input');
picker.type = 'color';
picker.value = (cfgvalue && cfgvalue !== this.placeholder) ? cfgvalue : this.placeholder;
picker.style.cssText = 'width:40px;height:32px;padding:2px;border:1px solid #ccc;border-radius:4px;cursor:pointer;vertical-align:middle';
picker.addEventListener('input', function() { input.value = picker.value; input.dispatchEvent(new Event('input', { bubbles: true })); });
input.addEventListener('input', function() { if (/^#[0-9a-fA-F]{6}$/.test(input.value)) picker.value = input.value; });
node.appendChild(picker);
}
return node;
};
/* ---- Wallpaper ---- */
var BGURL = '/luci-static/noobwrt/background/custom.jpg';
var BGTMP = '/tmp/noobwrt_wallpaper.tmp';
o = s.taboption('wallpaper', form.ListValue, 'online_wallpaper',
_('Online Wallpaper Source'),
_('Automatically fetch a wallpaper from an online source for the login page background. Set to None to use a custom uploaded image or the built-in default.'));
o.value('none', _('None (use custom / default)'));
o.value('bing', _('Bing Daily Photo'));
o.value('picsum', _('Lorem Picsum (Random)'));
o.value('wikimedia', _('Wikimedia Picture of the Day'));
o.default = 'none';
o = s.taboption('wallpaper', form.DummyValue, '_wallpaper_ui', '');
o.rawhtml = true;
o.default = [
'<div style="max-width:520px">',
'<p style="color:#888;font-size:13px;margin:0 0 12px">',
_('Upload a custom image to use as the login page background. Supported formats: JPG, PNG, WebP (max 10 MB).'),
'</p>',
'<div id="noobwrt-wp-preview" style="margin-bottom:12px;border-radius:10px;overflow:hidden;background:#f0f0f0;height:160px;display:flex;align-items:center;justify-content:center">',
'<img id="noobwrt-wp-img" src="" style="width:100%;height:100%;object-fit:cover;display:none" />',
'<span id="noobwrt-wp-placeholder" style="color:#aaa;font-size:13px">' + _('No custom wallpaper — using default') + '</span>',
'</div>',
'<p id="noobwrt-wp-status" style="font-size:12px;color:#888;min-height:18px;margin:0 0 12px"></p>',
'<div style="display:flex;gap:10px">',
'<button id="noobwrt-wp-upload" type="button" class="btn cbi-button cbi-button-action">' + _('Upload Wallpaper...') + '</button>',
'<button id="noobwrt-wp-revert" type="button" class="btn cbi-button cbi-button-reset">' + _('Revert to Default') + '</button>',
'</div>',
'</div>'
].join('');
/* ---- Background Effects ---- */
o = s.taboption('effects', form.Value, 'blur',
_('Blur Radius — Light Mode'),
_('Login page background blur radius in pixels for light mode. Default: 10'));
o.placeholder = '10';
o.datatype = 'uinteger';
o = s.taboption('effects', form.Value, 'blur_dark',
_('Blur Radius — Dark Mode'),
_('Login page background blur radius in pixels for dark mode. Default: 10'));
o.placeholder = '10';
o.datatype = 'uinteger';
o = s.taboption('effects', form.Value, 'transparency',
_('Background Opacity — Light Mode'),
_('Overlay opacity on the login page background for light mode. Range: 0.0 (transparent) to 1.0 (opaque). Default: 0.8'));
o.placeholder = '0.8';
o = s.taboption('effects', form.Value, 'transparency_dark',
_('Background Opacity — Dark Mode'),
_('Overlay opacity on the login page background for dark mode. Default: 0.8'));
o.placeholder = '0.8';
/* ---- Toolbar tab – placeholder only, real table rendered below ---- */
o = s.taboption('toolbar', form.DummyValue, '_toolbar_placeholder', '');
o.rawhtml = true;
o.default = '<div id="noobwrt-toolbar-tab-content"></div>';
/* ---- About ---- */
o = s.taboption('about', form.DummyValue, '_about', '');
o.rawhtml = true;
o.default = [
'<div style="max-width:480px;background:var(--color-bg-1,#fff);border:1px solid var(--color-border,#e5e7eb);border-radius:12px;padding:20px 24px;box-shadow:0 2px 8px rgba(0,0,0,0.06)">',
' <h3 style="margin:0 0 6px;font-size:16px;font-weight:600">' + _('NoobWrt Theme') + '</h3>',
' <p style="margin:0 0 16px;color:#888;font-size:13px">' + _('A clean modern LuCI theme for OpenWrt routers.') + '</p>',
' <table style="width:100%;border-collapse:collapse;line-height:1.9;font-size:13px">',
' <tr><td style="width:130px;color:#888;padding:3px 0">' + _('Developer') + '</td>',
' <td><strong>NoobLk</strong></td></tr>',
' <tr><td style="color:#888;padding:3px 0">' + _('Repository') + '</td>',
' <td><a href="https://github.com/nooblk-98/luci-theme-noobwrt" target="_blank" rel="noopener" style="color:#5e72e4">github.com/nooblk-98/luci-theme-noobwrt</a></td></tr>',
' <tr><td style="color:#888;padding:3px 0">' + _('Contributors') + '</td>',
' <td><a href="https://github.com/nooblk-98/luci-theme-noobwrt/graphs/contributors" target="_blank" rel="noopener" style="color:#5e72e4">' + _('View on GitHub') + '</a></td></tr>',
' <tr><td style="color:#888;padding:3px 0">' + _('License') + '</td>',
' <td>MIT</td></tr>',
' <tr><td style="color:#888;padding:3px 0">' + _('Issues / Support') + '</td>',
' <td><a href="https://github.com/nooblk-98/luci-theme-noobwrt/issues" target="_blank" rel="noopener" style="color:#5e72e4">' + _('Open an issue') + '</a></td></tr>',
' </table>',
' <p style="margin:16px 0 0;color:#bbb;font-size:12px;text-align:center;border-top:1px solid var(--color-border,#e5e7eb);padding-top:12px">' + _('Made with \u2665 for the OpenWrt community') + '</p>',
'</div>'
].join('\n');
/* ============================================================
* SECTION: Toolbar Panel Items (TableSection)
* Rendered separately, then moved into the Toolbar tab via JS.
* ============================================================ */
var ts = m.section(form.TableSection, 'toolbar_item',
_('Toolbar Panel Items'),
_('Quick-access links displayed in the right-side toolbar panel on every page. Items are rendered in ascending Order number. Use the Add button below to create new entries.'));
ts.addremove = true;
ts.anonymous = true;
ts.sortable = false;
ts.nodescriptions = true;
o = ts.option(form.Flag, 'enabled', _('Enable'));
o.default = '1';
o.editable = true;
o = ts.option(form.Value, 'title', _('Label'));
o.placeholder = 'Home';
o.rmempty = false;
o.editable = true;
o = ts.option(form.Value, 'url', _('URL / Path'));
o.placeholder = '/cgi-bin/luci/admin/status/overview';
o.rmempty = false;
o.editable = true;
o = ts.option(form.ListValue, 'icon', _('Icon'));
[
['home.png', _('Home')],
['signal.png', _('Signal / Status')],
['cell.png', _('Cell / Modem')],
['sms.png', _('SMS')],
['network.png', _('Network / Data Usage')],
['nas.png', _('NAS / Storage')],
['wifi.png', _('WiFi / Wireless')],
['firewall.png', _('Firewall')],
['settings.png', _('Settings / System')],
['terminal.png', _('Terminal')],
['vpn.png', _('VPN')],
['files.png', _('Files')],
['info.png', _('Info / Log')]
].forEach(function (v) { o.value(v[0], v[1]); });
o.editable = true;
o = ts.option(form.Value, 'order',
_('Order'),
_('Lower numbers appear first in the toolbar.'));
o.datatype = 'uinteger';
o.placeholder = '10';
o.editable = true;
return m.render().then(function (node) {
/* ---- Wire wallpaper buttons ---- */
var uploadBtn = node.querySelector('#noobwrt-wp-upload');
var revertBtn = node.querySelector('#noobwrt-wp-revert');
var wpImg = node.querySelector('#noobwrt-wp-img');
var wpPh = node.querySelector('#noobwrt-wp-placeholder');
var wpSt = node.querySelector('#noobwrt-wp-status');
function setStatus(msg, ok) {
wpSt.textContent = msg;
wpSt.style.color = ok === true ? '#2dce89' : ok === false ? '#f5365c' : '#888';
}
fetch(BGURL, { method: 'HEAD' })
.then(function (r) {
if (r.ok) { wpImg.src = BGURL + '?_=' + Date.now(); wpImg.style.display = ''; wpPh.style.display = 'none'; }
}).catch(function () {});
uploadBtn.addEventListener('click', function (ev) {
ui.uploadFile(BGTMP, ev.target)
.then(function () { return callSetCustomWallpaper(); })
.then(function (result) {
if (result === 0) {
wpImg.src = BGURL + '?_=' + Date.now(); wpImg.style.display = ''; wpPh.style.display = 'none';
setStatus(_('Wallpaper uploaded! Refresh the login page to see it.'), true);
} else {
setStatus(_('Failed to save wallpaper.'), false);
}
})
.catch(function (e) { setStatus(_('Upload error: ') + e.message, false); });
});
revertBtn.addEventListener('click', function () {
if (!window.confirm(_('Remove custom wallpaper and revert to default?'))) return;
callRemoveCustomWallpaper()
.then(function () {
wpImg.src = ''; wpImg.style.display = 'none'; wpPh.style.display = '';
setStatus(_('Reverted to default wallpaper.'), true);
})
.catch(function (e) { setStatus(_('Could not remove: ') + e.message, false); });
});
/* ---- Move TableSection into Toolbar tab ---- */
var tabContent = node.querySelector('#noobwrt-toolbar-tab-content');
var sections = node.querySelectorAll('.cbi-map > .cbi-section');
var tsNode = sections[sections.length - 1];
if (tabContent && tsNode) {
var heading = tsNode.querySelector('h3, .cbi-section-legend');
if (heading) heading.style.display = 'none';
tabContent.appendChild(tsNode);
}
return node;
});
}
});