💋 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

+64
View File
@@ -0,0 +1,64 @@
#
# Copyright (C) 2026 bsatou
#
# This is free software, licensed under the Apache License, Version 2.0 .
#
include $(TOPDIR)/rules.mk
LUCI_TITLE:=shadcn/ui Theme for LuCI
LUCI_DEPENDS:=
LUCI_PKGARCH:=all
PKG_NAME:=luci-theme-shadcnui
PKG_VERSION:=0.3.7
PKG_RELEASE:=1
PKG_LICENSE:=Apache-2.0
PKG_MAINTAINER:=bsatou
include $(TOPDIR)/feeds/luci/luci.mk
# Post-install: replace the vanilla sysauth.ut with ours so our login layout
# (sidebar-less centered card) is used instead of LuCI's default. Save the
# original alongside it so we can restore on uninstall.
#
# luci-base owns /usr/share/ucode/luci/template/sysauth.ut, so we cannot ship
# the override directly inside the package — apk would refuse with a file
# overwrite conflict. Instead we ship our copy under the theme's own dir and
# do the swap here at install time.
define Package/$(PKG_NAME)/postinst
#!/bin/sh
[ -n "$${IPKG_INSTROOT}" ] && exit 0 # skip when building image, only run on device
THEME_SYSAUTH=/usr/share/ucode/luci/template/themes/shadcnui/sysauth.ut
LIVE_SYSAUTH=/usr/share/ucode/luci/template/sysauth.ut
BACKUP=$${LIVE_SYSAUTH}.shadcnui-backup
if [ -f "$${THEME_SYSAUTH}" ]; then
if [ -f "$${LIVE_SYSAUTH}" ] && [ ! -f "$${BACKUP}" ]; then
cp -a "$${LIVE_SYSAUTH}" "$${BACKUP}"
fi
cp -af "$${THEME_SYSAUTH}" "$${LIVE_SYSAUTH}"
fi
# Drop LuCI caches so the new template is picked up immediately.
rm -f /tmp/luci-indexcache /tmp/luci-modulecache/* 2>/dev/null
exit 0
endef
define Package/$(PKG_NAME)/prerm
#!/bin/sh
[ -n "$${IPKG_INSTROOT}" ] && exit 0
LIVE_SYSAUTH=/usr/share/ucode/luci/template/sysauth.ut
BACKUP=$${LIVE_SYSAUTH}.shadcnui-backup
if [ -f "$${BACKUP}" ]; then
mv -f "$${BACKUP}" "$${LIVE_SYSAUTH}"
fi
rm -f /tmp/luci-indexcache /tmp/luci-modulecache/* 2>/dev/null
exit 0
endef
# call BuildPackage - OpenWrt buildroot signature
@@ -0,0 +1,335 @@
'use strict';
'require baseclass';
'require ui';
/*
* luci-theme-shadcnui — menu-shadcnui.js
*
* Drop-in replacement for menu-bootstrap / menu-argon. Loads the menu tree via
* ui.menu.load() and renders it into the sidebar shell that header.ut prepared:
*
* #shadcn-sidebar
* .sidebar-header — brand (rendered server-side)
* #shadcn-mainmenu — primary nav (we fill this)
* #shadcn-modemenu — secondary "mode" tabs (we fill this when there are >1)
* #shadcn-topbar
* #shadcn-breadcrumb — current path (we fill this)
* #tabmenu — page-local sub-tabs (we fill this when active)
*
* The menu has 3 conceptual layers:
* level 0 — modes (Status / System / Network / Services / ...)
* In bootstrap these go into #modemenu; in our layout we only
* switch the sidebar's main pane based on the active mode.
* level 1 — sections of the active mode (e.g. under "System": System /
* Administration / Software / Startup / ...)
* level 2 — leaves; clicking them dispatches a view
* level 3+ — page-local sub-tabs, rendered into #tabmenu by tabmenu code.
*
* Layout:
* - The first mode's children become collapsible sections in the sidebar.
* - Other modes are listed at the bottom of the sidebar (#shadcn-modemenu)
* as small chips so the user can switch.
*/
/* --------------- Lucide icon paths by section/page name --------------- */
var ICONS = {
/* Top-level modes (activeMode children = these section.names) */
'status': 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z',
'system': 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065zM15 12a3 3 0 11-6 0 3 3 0 016 0z',
'services': 'M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01',
'network': 'M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064',
'tools': 'M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z',
'logout': 'M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1',
/* Status submenu items */
'overview': 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6',
'routes': 'M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7',
'firewall': 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
'iptables': 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
'nftables': 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
'realtime': 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z',
'processes': 'M4 6h16M4 10h16M4 14h16M4 18h16',
'logs': 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z',
'syslog': 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z',
'bandwidth': 'M13 7h8m0 0v8m0-8l-8 8-4-4-6 6',
'mwan3': 'M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z',
/* System submenu */
'admin': 'M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z',
'software': 'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4',
'packages': 'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4',
'startup': 'M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664zM21 12a9 9 0 11-18 0 9 9 0 0118 0z',
'crontab': 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
'leds': 'M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z',
'flash': 'M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12',
'reboot': 'M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15',
'poweroff': 'M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636',
/* Network submenu */
'interfaces': 'M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z',
'wireless': 'M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0',
'dhcp': 'M9 3H5a2 2 0 00-2 2v4m6-6h10a2 2 0 012 2v4M9 3v18m0 0h10a2 2 0 002-2V9M9 21H5a2 2 0 01-2-2V9m0 0h18',
'hosts': 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10',
'diagnostics': 'M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z',
'switch': 'M8 7h12m0 0l-4-4m4 4l-4 4m0 5H4m0 0l4 4m-4-4l4-4',
/* Services submenu */
'upnp': 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
'vpn': 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z',
'ddns': 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
'sqm': 'M13 10V3L4 14h7v7l9-11h-7z',
'statistics': 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z',
'nlbw': 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z',
'amneziawg': 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z',
'wireguard': 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z',
'aria2': 'M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4',
'samba': 'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z',
'docker': 'M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z',
'ttyd': 'M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z',
'picoclaw': 'M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z',
/* Fallback */
'_chevron': 'M9 18l6-6-6-6',
'_default': 'M4 6h16M4 12h16M4 18h16'
};
function makeSvg(name, cls) {
var d = ICONS[name] || ICONS['_default'];
var NS = 'http://www.w3.org/2000/svg';
var svg = document.createElementNS(NS, 'svg');
svg.setAttribute('class', cls || 'icon');
svg.setAttribute('viewBox', '0 0 24 24'); /* camelCase — required for SVG */
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke', 'currentColor');
svg.setAttribute('stroke-width', '1.75');
svg.setAttribute('stroke-linecap', 'round');
svg.setAttribute('stroke-linejoin', 'round');
svg.setAttribute('aria-hidden', 'true');
var path = document.createElementNS(NS, 'path');
path.setAttribute('d', d);
svg.appendChild(path);
return svg;
}
return baseclass.extend({
__init__: function () {
ui.menu.load().then(L.bind(this.render, this));
},
render: function (tree) {
/* LuCI's handleSave/handleApply look for #maincontent to find forms.
Wrap the rendered #view inside a #maincontent div if not present. */
if (!document.getElementById('maincontent')) {
var view = document.getElementById('view');
if (view) {
var mc = document.createElement('div');
mc.id = 'maincontent';
view.parentNode.insertBefore(mc, view);
mc.appendChild(view);
}
}
this.renderModeSwitcher(tree);
this.renderSidebarForActiveMode(tree);
this.renderBreadcrumb(tree);
this.renderTabMenu(tree);
this.fixApplyDropdown();
},
/* Fix LuCI's apply dropdown */
fixApplyDropdown: function () {},
/* --------------- Mode switcher (top-level) --------------- */
renderModeSwitcher: function (tree) {
var container = document.querySelector('#shadcn-modemenu');
if (!container) return;
var children = ui.menu.getChildren(tree);
if (children.length <= 1) return;
var requestpath = L.env.requestpath || [];
var activeName = requestpath.length ? requestpath[0] : (children[0] && children[0].name);
container.innerHTML = '';
children.forEach(function (child) {
var isActive = (child.name === activeName);
var a = E('a', {
'class': 'mode-chip' + (isActive ? ' active' : ''),
'href': L.url(child.name)
}, [ _(child.title) ]);
container.appendChild(a);
});
},
/* --------------- Sidebar nav for the active mode --------------- */
renderSidebarForActiveMode: function (tree) {
var container = document.querySelector('#shadcn-mainmenu');
if (!container) return;
var topChildren = ui.menu.getChildren(tree);
if (topChildren.length === 0) return;
var requestpath = L.env.requestpath || [];
var dispatchpath = L.env.dispatchpath || [];
// Pick the active mode (or first if no path).
var activeMode = null;
if (requestpath.length) {
for (var i = 0; i < topChildren.length; i++) {
if (topChildren[i].name === requestpath[0]) {
activeMode = topChildren[i];
break;
}
}
}
if (!activeMode) activeMode = topChildren[0];
container.innerHTML = '';
var sections = ui.menu.getChildren(activeMode);
var self = this;
console.log('[shadcnui] activeMode.name:', activeMode.name);
sections.forEach(function (section) {
console.log('[shadcnui] section.name:', section.name, 'title:', section.title);
var sectionUrl = activeMode.name + '/' + section.name;
var leaves = ui.menu.getChildren(section);
var isActiveSection = (dispatchpath[1] === section.name);
if (leaves.length > 0) {
// Collapsible section
var div = E('div', {
'class': 'sidebar-section',
'data-open': isActiveSection ? 'true' : 'false'
});
var btn = E('button', {
'class': 'sidebar-toggle' + (isActiveSection ? ' active' : ''),
'type': 'button',
'click': ui.createHandlerFn(self, 'handleSectionToggle')
}, [
makeSvg(section.name, 'icon'),
E('span', {}, [ _(section.title) ]),
makeSvg('_chevron', 'chevron')
]);
var childrenWrap = E('div', { 'class': 'sidebar-children' });
leaves.forEach(function (leaf) {
var leafUrl = sectionUrl + '/' + leaf.name;
var isActiveLeaf = (dispatchpath[1] === section.name && dispatchpath[2] === leaf.name);
var a = E('a', {
'class': 'sidebar-link' + (isActiveLeaf ? ' active' : ''),
'href': L.url(leafUrl)
}, [ E('span', {}, [ _(leaf.title) ]) ]);
childrenWrap.appendChild(a);
});
div.appendChild(btn);
div.appendChild(childrenWrap);
container.appendChild(div);
}
else {
// Direct link
var a = E('a', {
'class': 'sidebar-link sidebar-link-top' + (isActiveSection ? ' active' : ''),
'href': L.url(sectionUrl)
}, [
makeSvg(section.name, 'icon'),
E('span', {}, [ _(section.title) ])
]);
container.appendChild(a);
}
});
},
handleSectionToggle: function (ev) {
var btn = ev.currentTarget;
var section = btn.closest('.sidebar-section');
if (!section) return;
var open = section.getAttribute('data-open') === 'true';
section.setAttribute('data-open', open ? 'false' : 'true');
ev.preventDefault();
ev.stopPropagation();
},
/* --------------- Breadcrumb in the topbar --------------- */
renderBreadcrumb: function (tree) {
var container = document.querySelector('#shadcn-breadcrumb');
if (!container) return;
var dispatchpath = L.env.dispatchpath || [];
if (dispatchpath.length === 0) return;
var node = tree;
var crumbs = [];
var url = '';
for (var i = 0; i < dispatchpath.length && node; i++) {
var seg = dispatchpath[i];
node = node.children && node.children[seg];
if (!node) break;
url = url + (url ? '/' : '') + seg;
crumbs.push({ title: node.title, url: url });
}
container.innerHTML = '';
crumbs.forEach(function (c, idx) {
if (idx > 0) container.appendChild(E('span', { 'class': 'crumb-sep' }, [ '/' ]));
if (idx === crumbs.length - 1) {
container.appendChild(E('span', { 'class': 'crumb-current' }, [ _(c.title) ]));
} else {
container.appendChild(E('a', { 'href': L.url(c.url) }, [ _(c.title) ]));
}
});
},
/* --------------- Page-local sub-tabs in #tabmenu --------------- */
renderTabMenu: function (tree) {
var container = document.querySelector('#tabmenu');
if (!container) return;
var dispatchpath = L.env.dispatchpath || [];
if (dispatchpath.length < 3) return;
var node = tree;
var url = '';
for (var i = 0; i < 3 && node; i++) {
node = node.children && node.children[dispatchpath[i]];
url = url + (url ? '/' : '') + dispatchpath[i];
}
if (!node) return;
this._renderTabLevel(node, url, 0, container);
},
_renderTabLevel: function (node, url, level, container) {
var children = ui.menu.getChildren(node);
if (children.length === 0) return;
var ul = E('ul', { 'class': 'cbi-tabmenu' });
var activeNode = null;
var dispatchpath = L.env.dispatchpath || [];
children.forEach(function (child) {
var isActive = (dispatchpath[3 + level] === child.name);
var li = E('li', { 'class': isActive ? 'cbi-tab' : 'cbi-tab-disabled' }, [
E('a', { 'href': L.url(url, child.name) }, [ _(child.title) ])
]);
ul.appendChild(li);
if (isActive) activeNode = child;
});
container.appendChild(ul);
container.style.display = '';
if (activeNode) {
this._renderTabLevel(activeNode, url + '/' + activeNode.name, level + 1, container);
}
}
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,276 @@
/*
* shadcn/ui design tokens for LuCI
* Values mirror ui.shadcn.com defaults (HSL, no commas inside hsl()).
*
* data-base on <html> picks neutral palette: zinc | slate | stone | gray | neutral
* data-theme on <html> picks light/dark
* --primary, --primary-foreground may be overridden inline by header.ut from UCI
*/
/* ============================================================
* BASE: ZINC (default)
* ============================================================ */
:root,
:root[data-base="zinc"] {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--radius: 0.5rem;
/* status / chart colors used by LuCI tables, network state, etc. */
--success: 142 71% 45%;
--warning: 38 92% 50%;
--info: 199 89% 48%;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
:root[data-theme="dark"][data-base="zinc"],
:root[data-base="zinc"][data-theme="dark"] {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
/* ============================================================
* BASE: SLATE
* ============================================================ */
:root[data-base="slate"] {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
}
:root[data-base="slate"][data-theme="dark"] {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
/* ============================================================
* BASE: STONE
* ============================================================ */
:root[data-base="stone"] {
--background: 0 0% 100%;
--foreground: 20 14.3% 4.1%;
--card: 0 0% 100%;
--card-foreground: 20 14.3% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 20 14.3% 4.1%;
--primary: 24 9.8% 10%;
--primary-foreground: 60 9.1% 97.8%;
--secondary: 60 4.8% 95.9%;
--secondary-foreground: 24 9.8% 10%;
--muted: 60 4.8% 95.9%;
--muted-foreground: 25 5.3% 44.7%;
--accent: 60 4.8% 95.9%;
--accent-foreground: 24 9.8% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 20 5.9% 90%;
--input: 20 5.9% 90%;
--ring: 20 14.3% 4.1%;
}
:root[data-base="stone"][data-theme="dark"] {
--background: 20 14.3% 4.1%;
--foreground: 60 9.1% 97.8%;
--card: 20 14.3% 4.1%;
--card-foreground: 60 9.1% 97.8%;
--popover: 20 14.3% 4.1%;
--popover-foreground: 60 9.1% 97.8%;
--primary: 60 9.1% 97.8%;
--primary-foreground: 24 9.8% 10%;
--secondary: 12 6.5% 15.1%;
--secondary-foreground: 60 9.1% 97.8%;
--muted: 12 6.5% 15.1%;
--muted-foreground: 24 5.4% 63.9%;
--accent: 12 6.5% 15.1%;
--accent-foreground: 60 9.1% 97.8%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 12 6.5% 15.1%;
--input: 12 6.5% 15.1%;
--ring: 24 5.7% 82.9%;
}
/* ============================================================
* BASE: GRAY
* ============================================================ */
:root[data-base="gray"] {
--background: 0 0% 100%;
--foreground: 224 71.4% 4.1%;
--card: 0 0% 100%;
--card-foreground: 224 71.4% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 224 71.4% 4.1%;
--primary: 220.9 39.3% 11%;
--primary-foreground: 210 20% 98%;
--secondary: 220 14.3% 95.9%;
--secondary-foreground: 220.9 39.3% 11%;
--muted: 220 14.3% 95.9%;
--muted-foreground: 220 8.9% 46.1%;
--accent: 220 14.3% 95.9%;
--accent-foreground: 220.9 39.3% 11%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 20% 98%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 224 71.4% 4.1%;
}
:root[data-base="gray"][data-theme="dark"] {
--background: 224 71.4% 4.1%;
--foreground: 210 20% 98%;
--card: 224 71.4% 4.1%;
--card-foreground: 210 20% 98%;
--popover: 224 71.4% 4.1%;
--popover-foreground: 210 20% 98%;
--primary: 210 20% 98%;
--primary-foreground: 220.9 39.3% 11%;
--secondary: 215 27.9% 16.9%;
--secondary-foreground: 210 20% 98%;
--muted: 215 27.9% 16.9%;
--muted-foreground: 217.9 10.6% 64.9%;
--accent: 215 27.9% 16.9%;
--accent-foreground: 210 20% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 20% 98%;
--border: 215 27.9% 16.9%;
--input: 215 27.9% 16.9%;
--ring: 216 12.2% 83.9%;
}
/* ============================================================
* BASE: NEUTRAL
* ============================================================ */
:root[data-base="neutral"] {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
}
:root[data-base="neutral"][data-theme="dark"] {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
}
/* status colors stay consistent across all bases (only tweaked in dark) */
:root[data-theme="dark"] {
--success: 142 64% 50%;
--warning: 35 91% 55%;
--info: 199 89% 60%;
}
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#18181b"/>
<text x="16" y="22" text-anchor="middle"
font-family="ui-sans-serif, system-ui, sans-serif"
font-size="18" font-weight="700" fill="#fafafa">S</text>
</svg>

After

Width:  |  Height:  |  Size: 290 B

@@ -0,0 +1,46 @@
/*
* luci-theme-shadcnui — sidebar.js
*
* Sidebar is always visible on desktop. On mobile (≤768px) the burger button
* in the topbar toggles a slide-in drawer; the dark overlay closes it.
*
* Section toggle and active-link highlighting are handled by menu-shadcnui.js
* because that module is responsible for rendering the menu DOM.
*/
(function () {
'use strict';
var app = document.getElementById('shadcn-app');
if (!app) return; // login page or other unrelated layout
function applyState(state) {
// state: 'expanded' (mobile closed / desktop default) | 'open' (mobile drawer)
app.setAttribute('data-sidebar', state);
}
applyState('expanded');
var toggle = document.getElementById('shadcn-sidebar-toggle');
if (toggle) {
toggle.addEventListener('click', function (e) {
e.preventDefault();
var current = app.getAttribute('data-sidebar') || 'expanded';
applyState(current === 'open' ? 'expanded' : 'open');
});
}
var overlay = document.getElementById('shadcn-overlay');
if (overlay) {
overlay.addEventListener('click', function () {
applyState('expanded');
});
}
// Close the drawer if the viewport grows past the breakpoint.
window.addEventListener('resize', function () {
if (window.matchMedia('(min-width: 769px)').matches &&
app.getAttribute('data-sidebar') === 'open') {
applyState('expanded');
}
});
})();
@@ -0,0 +1,97 @@
/*
* luci-theme-shadcnui — theme.js
* Reads UCI-derived <html data-*> attributes, applies system preference when
* needed, and persists user overrides to localStorage as a fast path before
* the next page load (the source of truth stays /etc/config/shadcnui).
*/
(function () {
'use strict';
var root = document.documentElement;
var LS_KEY = 'shadcnui:overrides';
function readOverrides() {
try { return JSON.parse(localStorage.getItem(LS_KEY) || '{}'); }
catch (e) { return {}; }
}
function writeOverrides(o) {
try { localStorage.setItem(LS_KEY, JSON.stringify(o || {})); }
catch (e) { /* ignore quota / private mode */ }
}
function applyMode(mode) {
// mode: 'light' | 'dark' | 'system'
if (mode === 'system') {
var prefersDark = window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches;
root.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
} else {
root.setAttribute('data-theme', mode === 'dark' ? 'dark' : 'light');
}
root.setAttribute('data-mode', mode);
}
function applyBase(base) {
var allowed = ['zinc', 'slate', 'stone', 'gray', 'neutral'];
if (allowed.indexOf(base) === -1) base = 'zinc';
root.setAttribute('data-base', base);
}
function applyAccent(hsl) {
// hsl: "240 5.9% 10%" or null to reset
if (!hsl) {
root.style.removeProperty('--primary');
root.style.removeProperty('--ring');
return;
}
root.style.setProperty('--primary', hsl);
root.style.setProperty('--ring', hsl);
}
function applyLoginBg(url) {
if (!url) {
root.style.removeProperty('--login-bg');
return;
}
root.style.setProperty('--login-bg', 'url("' + url.replace(/"/g, '\\"') + '")');
}
// Public API exposed so config page can preview live.
window.shadcnuiTheme = {
setMode: function (mode) {
var o = readOverrides(); o.mode = mode; writeOverrides(o);
applyMode(mode);
},
setBase: function (base) {
var o = readOverrides(); o.base = base; writeOverrides(o);
applyBase(base);
},
setAccent: function (hsl) {
var o = readOverrides(); o.accent = hsl; writeOverrides(o);
applyAccent(hsl);
},
setLoginBg: function (url) {
var o = readOverrides(); o.loginBg = url; writeOverrides(o);
applyLoginBg(url);
},
getMode: function () { return root.getAttribute('data-mode') || 'system'; },
getResolved: function () { return root.getAttribute('data-theme') || 'light'; }
};
// Boot: server-rendered attrs are authoritative on first paint.
// We only re-evaluate "system" against the live media query and listen for
// OS-level changes so the UI follows the OS without a page reload.
var initialMode = root.getAttribute('data-mode') || 'system';
if (initialMode === 'system') applyMode('system');
if (window.matchMedia) {
var mql = window.matchMedia('(prefers-color-scheme: dark)');
var listener = function () {
if ((root.getAttribute('data-mode') || 'system') === 'system') {
applyMode('system');
}
};
if (mql.addEventListener) mql.addEventListener('change', listener);
else if (mql.addListener) mql.addListener(listener);
}
})();
@@ -0,0 +1,25 @@
config shadcnui 'main'
option mode 'system'
option base 'zinc'
option accent ''
option radius '0.5rem'
option bg_source 'none'
option bg_url ''
config bing 'bing'
option enabled '0'
option market 'auto'
option cache_ttl '86400'
config unsplash 'unsplash'
option enabled '0'
option access_key ''
option query 'nature'
config wallhaven 'wallhaven'
option enabled '0'
option api_key ''
option query 'minimal'
option purity '100'
option categories '111'
@@ -0,0 +1,12 @@
#!/bin/sh
uci -q batch <<EOF >/dev/null
set luci.themes.shadcnui=/luci-static/shadcnui
set luci.main.mediaurlbase=/luci-static/shadcnui
commit luci
EOF
rm -f /tmp/luci-indexcache /tmp/luci-modulecache/* 2>/dev/null
killall -HUP rpcd 2>/dev/null
exit 0
@@ -0,0 +1,10 @@
{#
luci-theme-shadcnui — footer.ut
Closes the layout opened by header.ut.
-#}
</main>
</div>
<script>L.require('menu-shadcnui')</script>
</body>
</html>
@@ -0,0 +1,5 @@
{#
luci-theme-shadcnui — footer_login.ut
-#}
</body>
</html>
@@ -0,0 +1,111 @@
{#
luci-theme-shadcnui — header.ut
Used for every authenticated LuCI page. The login screen is rendered by
sysauth.ut (which overrides /usr/share/ucode/luci/template/sysauth.ut) and
includes header_login.ut / footer_login.ut instead, so this file does NOT
need to detect the login state.
-#}
{%
import { access } from 'fs';
import { cursor } from 'uci';
const boardinfo = ubus.call('system', 'board');
const hostname = striptags(boardinfo?.hostname ?? 'OpenWrt');
let cfg = cursor();
let mode = 'system';
let base = 'zinc';
let accent = '';
let radius = '0.5rem';
let bg_url = '';
if (access('/etc/config/shadcnui')) {
mode = cfg.get('shadcnui', 'main', 'mode') ?? mode;
base = cfg.get('shadcnui', 'main', 'base') ?? base;
accent = cfg.get('shadcnui', 'main', 'accent') ?? '';
radius = cfg.get('shadcnui', 'main', 'radius') ?? radius;
bg_url = cfg.get('shadcnui', 'main', 'bg_url') ?? '';
}
let resolved = (mode == 'dark') ? 'dark' : 'light';
let style_overrides = '';
if (accent) style_overrides += `--primary:${accent};--ring:${accent};`;
if (radius != '0.5rem') style_overrides += `--radius:${radius};`;
if (bg_url) style_overrides += `--login-bg:url('${replace(bg_url, "'", "\\'")}');`;
http.prepare_content('text/html; charset=UTF-8');
-%}
<!DOCTYPE html>
<html lang="{{ dispatcher.lang }}"
data-base="{{ base }}"
data-mode="{{ mode }}"
data-theme="{{ resolved }}"
style="{{ style_overrides }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<meta name="darkreader-lock">
<title>{{ hostname }}{{ node?.title ? ` — ${striptags(node.title)}` : '' }} — LuCI</title>
<link rel="stylesheet" href="{{ media }}/css/shadcnui.css">
<link rel="icon" type="image/svg+xml" href="{{ media }}/icons/favicon.svg">
{% if (node?.css): %}
<link rel="stylesheet" href="{{ resource }}/{{ node.css }}">
{% endif -%}
{% if (css): %}
<style type="text/css">{{ css }}</style>
{% endif -%}
<script src="{{ dispatcher.build_url('admin/translations', dispatcher.lang) }}?v={{ version.luciversion }}"></script>
<script src="{{ resource }}/cbi.js?v={{ version.luciversion }}"></script>
<script src="{{ resource }}/luci.js?v={{ version.luciversion }}"></script>
<script src="{{ media }}/js/theme.js" defer></script>
<script src="{{ media }}/js/sidebar.js" defer></script>
</head>
<body class="lang_{{ dispatcher.lang }}"
data-page="{{ entityencode(join('-', ctx.request_path), true) }}">
<div id="shadcn-app" data-sidebar="expanded">
<aside id="shadcn-sidebar" aria-label="Main navigation">
<div class="sidebar-header">
<a class="brand" href="/">
<div class="logo" aria-hidden="true">{{ substr(uc(hostname), 0, 2) }}</div>
<span>{{ hostname }}</span>
</a>
</div>
<nav class="sidebar-nav" id="shadcn-mainmenu" role="navigation"></nav>
<div class="sidebar-footer" id="shadcn-modemenu"></div>
</aside>
<div id="shadcn-overlay" class="darkMask"></div>
<header id="shadcn-topbar">
<button class="topbar-toggle showSide" id="shadcn-sidebar-toggle"
type="button" aria-label="Toggle sidebar">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
<div class="topbar-breadcrumb" id="shadcn-breadcrumb"></div>
<div id="indicators" class="topbar-actions"></div>
</header>
<main id="shadcn-main">
<noscript>
<div class="alert-message warning">
<h4>{{ _('JavaScript required!') }}</h4>
<p>{{ _('You must enable JavaScript in your browser or LuCI will not work properly.') }}</p>
</div>
</noscript>
<div id="tabmenu" class="cbi-tabmenu" style="display:none"></div>
@@ -0,0 +1,53 @@
{#
luci-theme-shadcnui — header_login.ut
Renders the <head> and opens the <body> for the login page only. The actual
login form and a closing <body>/<html> are provided by the global sysauth.ut
(which we override) and by footer_login.ut.
-#}
{%
import { access } from 'fs';
import { cursor } from 'uci';
const boardinfo = ubus.call('system', 'board');
const hostname = striptags(boardinfo?.hostname ?? 'OpenWrt');
let cfg = cursor();
let mode = 'system', base = 'zinc', accent = '', radius = '0.5rem', bg_url = '';
if (access('/etc/config/shadcnui')) {
mode = cfg.get('shadcnui', 'main', 'mode') ?? mode;
base = cfg.get('shadcnui', 'main', 'base') ?? base;
accent = cfg.get('shadcnui', 'main', 'accent') ?? '';
radius = cfg.get('shadcnui', 'main', 'radius') ?? radius;
bg_url = cfg.get('shadcnui', 'main', 'bg_url') ?? '';
}
let resolved = (mode == 'dark') ? 'dark' : 'light';
let style_overrides = '';
if (accent) style_overrides += `--primary:${accent};--ring:${accent};`;
if (radius != '0.5rem') style_overrides += `--radius:${radius};`;
if (bg_url) style_overrides += `--login-bg:url('${replace(bg_url, "'", "\\'")}');`;
http.prepare_content('text/html; charset=UTF-8');
-%}
<!DOCTYPE html>
<html lang="{{ dispatcher.lang }}"
data-base="{{ base }}"
data-mode="{{ mode }}"
data-theme="{{ resolved }}"
style="{{ style_overrides }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<meta name="darkreader-lock">
<title>{{ hostname }} — LuCI</title>
<link rel="stylesheet" href="{{ media }}/css/shadcnui.css">
<link rel="icon" type="image/svg+xml" href="{{ media }}/icons/favicon.svg">
<script src="{{ media }}/js/theme.js" defer></script>
</head>
<body class="lang_{{ dispatcher.lang }} sysauth">
@@ -0,0 +1,13 @@
{#
luci-theme-shadcnui — out_header_login.ut
Idempotent header include used by sysauth.ut (the per-theme login page).
Pattern copied from luci-theme-argon — guards against double-include via
ctx.template_header_sent.
-#}
{%
if (!ctx.template_header_sent) {
include("themes/" + theme + "/header_login");
ctx.template_header_sent = true;
}
%}
@@ -0,0 +1,82 @@
{#
luci-theme-shadcnui — global sysauth.ut override
Replaces /usr/share/ucode/luci/template/sysauth.ut from luci-base. Pattern
copied from luci-theme-argon: include the theme's header_login wrapper,
render the login form, then include footer_login.
The runtime exposes:
theme — basename of mediaurlbase (e.g. 'shadcnui')
media — full mediaurlbase URL (e.g. '/luci-static/shadcnui')
duser — default username (usually 'root')
fuser — non-empty if the previous attempt failed
-#}
{% include("themes/" + theme + "/out_header_login") %}
{%
import { access } from 'fs';
import { cursor } from 'uci';
const boardinfo = ubus.call('system', 'board');
const hostname = striptags(boardinfo?.hostname ?? 'OpenWrt');
const initials = substr(uc(hostname), 0, 2);
let cfg = cursor();
let bg_url = '';
if (access('/etc/config/shadcnui')) {
bg_url = cfg.get('shadcnui', 'main', 'bg_url') ?? '';
}
%}
<main id="login-root">
{% if (bg_url): %}
<div class="login-bg" style="background-image:url('{{ bg_url }}')"></div>
{% endif %}
<div class="sysauth-card">
<div class="sysauth-brand">
<div class="logo" aria-hidden="true">{{ initials }}</div>
<div>
<h2>{{ hostname }}</h2>
<p class="muted">{{ _('Please sign in to manage this device.') }}</p>
</div>
</div>
<form method="post" action="{{ http.getenv('REQUEST_URI') }}">
{% if (fuser): %}
<div class="alert-message error">
{{ _('Invalid username and/or password! Please try again.') }}
</div>
{% endif %}
<div class="cbi-value">
<label for="cbi-input-user">{{ _('Username') }}</label>
<input id="cbi-input-user" type="text" name="luci_username"
value="{{ entityencode(duser, true) }}"
autocomplete="username" autofocus>
</div>
<div class="cbi-value">
<label for="cbi-input-password">{{ _('Password') }}</label>
<input id="cbi-input-password" type="password" name="luci_password"
autocomplete="current-password">
</div>
<div class="cbi-page-actions">
<input type="submit" class="btn cbi-button cbi-button-apply"
value="{{ _('Log in') }}">
</div>
</form>
</div>
<script>
var pw = document.getElementsByName('luci_password')[0];
if (pw) pw.focus();
</script>
</main>
{% include("themes/" + theme + "/footer_login") %}