🎁 Sync 2026-08-02 20:24:47

This commit is contained in:
github-actions[bot] committed 2026-08-02 20:24:47 +08:00
1 parent 540866391e
commit cfc7814ebd
20 files changed
+687 -447

No files matched your search

+2 -2
View File
@@ -6,11 +6,11 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=cloudreve
PKG_VERSION:=4.18.0
PKG_RELEASE:=14
PKG_RELEASE:=15
PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/cloudreve/Cloudreve.git
PKG_SOURCE_VERSION:=0bb0ab833571d380153edd3529e01a7957b8b4ce
PKG_SOURCE_VERSION:=20c95ad73f3a8bcb72887fea91ff31ab24fa1011
PKG_MIRROR_HASH:=skip
PKG_LICENSE:=GPL-3.0-only
+1 -1
View File
@@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=daed
PKG_VERSION:=2026.07.31
PKG_RELEASE:=37
PKG_RELEASE:=38
PKG_SOURCE:=daed-src-2026.07.31-178adbdeb296.tar.gz
PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-daede/releases/download/daed-src
+1 -2
View File
@@ -23,8 +23,7 @@ start_service() {
config_get log_maxsize "config" "log_maxsize" "5"
procd_open_instance "$CONF"
procd_set_param env DAE_LOCATION_ASSET="/usr/share/v2ray"
procd_set_param env TZ="$(uci -q get system.@system[0].zonename)"
procd_set_param env DAE_LOCATION_ASSET="/usr/share/v2ray" TZ="$(uci -q get system.@system[0].zonename)"
procd_set_param command "$PROG" run
procd_append_param command --config "/etc/daed/"
procd_append_param command --listen "$listen_addr"
+4 -4
View File
@@ -196,14 +196,15 @@ Two rules of thumb that follow from prefix matching:
Style a third-party app's page — write or adjust its `patches/*.css`, or check a `main.css`/component change against it — **without having the app (or a device) installed**. Save the page's rendered HTML once, then develop against it with the theme live and hot-reloading.
- **Where snapshots live:** `.dev/mocks/*.html` (git-ignored — snapshots are large, device/fork-specific and go stale, so they stay local). The directory need not exist in a fresh clone; it's created on first capture.
- **Capture one:** with the dev server proxying a device that has the page, open the page through the proxy and press <kbd>Alt/Option+Shift+S</kbd> (or call `__auroraMockCapture()` in the console). The injected `scripts/mock-capture.client.js` helper POSTs the live DOM to `/mocks/__save`, which writes `.dev/mocks/<data-page>.html` — named after the page's `data-page`, doctype included, dev-only script tags stripped. (The endpoint only accepts requests carrying the helper's custom header, which cross-origin pages can't send without a CORS preflight this server never approves.) Manual capture still works: run `copy(document.documentElement.outerHTML)` in the DevTools console and save the paste as `.dev/mocks/<name>.html` — the filename is free; the page's real identity is the `data-page` attribute already in its `<body>`, which patch selectors match.
- **The mock bar:** every HTML page this dev server hands out — proxied device pages and served snapshots alike — gets `scripts/mock-bar.client.js` (served at `/mocks/__bar.js`), a floating bar in the bottom-left corner. It lives in a Shadow DOM, so theme and patch CSS can neither restyle it nor be polluted by it, and the theme's own floating toolbar keeps the bottom-right corner. On a device page it lists what `.dev/mocks/` holds, so the workflow is reachable without typing the `/mocks/` URL: `◆` appears when this page's `data-page` matches a snapshot and opens it in one click, `⊕` captures the open page, and an empty `.dev/mocks/` shrinks the bar to a lone `⊕`. Inside a snapshot it names the open one, steps through the rest, and `↩` goes back to the same page on the device. `✕` collapses it to a dot, remembered in `localStorage['aurora.mockbar.collapsed']`. The snapshot list is injected inline next to the script; both tags carry `data-aurora-mock`, which is how a capture strips them back out — a snapshot must never bake in a list that is re-injected, current, on every serve.
- **Capture one:** with the dev server proxying a device that has the page, open the page through the proxy and hit `⊕` on the mock bar — or press <kbd>Alt/Option+Shift+S</kbd>, or call `__auroraMockCapture()` in the console. It POSTs the live DOM to `/mocks/__save`, which writes `.dev/mocks/<data-page>.html` — named after the page's `data-page`, doctype included, dev-only script tags stripped. (The endpoint only accepts requests carrying the helper's custom header, which cross-origin pages can't send without a CORS preflight this server never approves.) Manual capture still works: run `copy(document.documentElement.outerHTML)` in the DevTools console and save the paste as `.dev/mocks/<name>.html` — the filename is free; the page's real identity is the `data-page` attribute already in its `<body>`, which patch selectors match.
- **Capture it from a device running _this_ theme — snapshots are not portable between themes.** A snapshot is a verbatim copy of a rendered page, so it hard-codes the theme that rendered it in three places: the stylesheet links (`/luci-static/aurora/main.css` plus that page's patch), the device's stored UCI token overrides in an inline `<style>`, and the theme's own header/nav markup. Drop a snapshot captured under `luci-theme-shadcn` in here (or vice versa) and the page renders **completely unstyled**, because a dev server only serves its own `/luci-static/<theme>/` prefix. The giveaway is a terminal line naming the other theme's stylesheet:
```
[Mocks] miss /luci-static/shadcn/main.css → 404 (mirror it at .dev/mocks/static/… to serve it)
```
Mirroring, which that generic hint suggests, is the wrong fix here — re-capture the page from a device running this theme. If you must reuse a foreign snapshot anyway, only the app's own content region means anything: point its stylesheet links at this theme, and delete the inline `<style>` block, or the captured device's colors override this checkout's tokens.
- **View:** `pnpm dev`, then open <http://localhost:5173/mocks/> — an auto-generated index lists every snapshot with its `data-page` and age. The `mock-pages-plugin` (in `vite.config.ts`) serves each page with the Vite HMR client injected, so editing any theme source (`main.css`, a component, a `patches/*.css`, or served JS) triggers the usual full reload (see [Live Reload Behavior](#live-reload-behavior)). The snapshot keeps its absolute `/luci-static/…` links; theme CSS/JS, fonts and images resolve locally and compile on the fly. Serving prepends the `<!doctype html>` that `outerHTML` captures drop, so mocks render in standards mode exactly like the real page.
- **Navigate between snapshots in place:** `scripts/mock-nav.client.js` (injected into every mock) resolves clicks on the snapshot's own LuCI links (`/cgi-bin/luci/…`) against the captured snapshots by `data-page` (exact match) and jumps straight to the matching mock — an app's tab bar or the sidebar works just like on the device. Uncaptured targets are blocked with a hint naming the missing snapshot instead of falling through to the proxy. A floating switcher (bottom-left) lists every snapshot, cycles with <kbd>[</kbd>/<kbd>]</kbd> (or its ‹/› buttons), and links back to the index.
- **Navigate between snapshots in place:** inside a mock the bar also takes over clicks on the snapshot's own LuCI links (`/cgi-bin/luci/…`), resolving them against the captured snapshots by `data-page` (exact match) and jumping straight to the matching mock — an app's tab bar or the sidebar works just like on the device. Uncaptured targets are blocked with a hint naming the missing snapshot instead of falling through to the proxy. The bar lists every snapshot, cycles with <kbd>[</kbd>/<kbd>]</kbd> (or its ‹/› buttons) and links back to the index. `↩` leaves for the real page: its target is the `requestpath` from LuCI's own inline bootstrap, but only when that agrees with the snapshot's `data-page` (a hand-assembled mock can carry the segments of the page it was built from); otherwise it falls back to splitting `data-page`, which is lossy whenever a path segment contains a dash of its own (`admin-status-disks-info`), then to the last device page visited in this tab.
- **Third-party assets:** an app's own css/js the snapshot references (e.g. `qmodem-next.css`, or a device-only custom logo) isn't in this repo. To serve it, mirror its URL under `.dev/mocks/static/` (e.g. `.dev/mocks/static/luci-static/resources/qmodem/qmodem-next.css`); files there are served as-is (no HMR). Misses requested by a mock page 404 immediately — never proxied to the router, so mocks stay fully offline-capable — and each miss prints a one-time terminal hint with the exact mirror path. (CSS-initiated requests, e.g. nav icons, carry the stylesheet's URL as referer and can't be attributed to the mock page; any `/luci-static` request that falls through to the proxy is therefore bounded to 5s and answers 504 when the router is unreachable.) The theme still applies without them.
- **No auth, no runtime:** a snapshot is static DOM, so `mock-pages-plugin` strips LuCI's runtime scripts (`luci.js`/`cbi.js`/`xhr.js` and `/cgi-bin/` endpoints) and injects a no-op `L`/`LuCI`/`XHR` stub before serving. Without this, LuCI boots, polls the backend, gets 403 (no session) and pops the "Session expired" modal. The trade-off: framework-dependent theme JS (e.g. `menu-aurora`) no-ops in mocks — the captured DOM is already rendered, so it still looks right. The theme's own inline scripts (dark mode, toolbar state) and any `src/media/` JS still run.
@@ -325,8 +326,7 @@ luci-theme-aurora/
│ ├── scripts/ # Build scripts + dev-server client helpers
│ │ ├── clean.js # Build cleanup utility
│ │ ├── gen-tokens.js # Regenerates src/media/_tokens.css from @eamonxg/aurora-tokens
│ │ ├── mock-capture.client.js # Injected into proxied device pages — hotkey capture to /mocks/__save
│ │ ├── mock-nav.client.js # Injected into /mocks/ pages — link takeover + floating switcher
│ │ ├── mock-bar.client.js # Injected into device pages and /mocks/ — snapshot bar, capture, link takeover
│ │ └── setup.js # pnpm setup:router — .env wizard + passwordless SSH to the router
│ ├── src/ # Source code
│ │ ├── assets/icons/ # SVG icons
@@ -0,0 +1,499 @@
/**
* Aurora dev helper — one floating bar, injected by vite.config.ts into both
* contexts it is useful in. Never ships to the router.
*
* proxied device page (/cgi-bin/luci/…)
* · lists every snapshot in .dev/mocks/ so the workflow is reachable
* without typing /mocks/ by hand
* · jumps straight to this page's own snapshot when one was captured
* (matched on <body data-page>)
* · captures the open page — the button does what Alt/Option+Shift+S does
* served snapshot (/mocks/…)
* · names the open snapshot, cycles with ‹ / › or the [ and ] keys
* · takes over the snapshot's own LuCI links: they resolve against the
* captured snapshots instead of falling through to the proxied router
*
* Shadow DOM throughout, so theme/patch CSS can't restyle the bar and its
* styles can't leak into the page under review. The theme's own floating
* toolbar owns the bottom-right corner, so this one sits bottom-left.
*
* Data contract (injected inline before this script):
* window.__AURORA_MOCKS__ = { current: "<file>"|null, mocks: [{ file, page }] }
*/
(() => {
"use strict";
if (window.__auroraMockBar) return;
window.__auroraMockBar = true;
const data = window.__AURORA_MOCKS__ || {};
const mocks = Array.isArray(data.mocks) ? data.mocks.slice() : [];
// A snapshot is static DOM: capturing one would only re-save the theme's own
// output, and there is no live page to re-capture.
const inMock = location.pathname.startsWith("/mocks/");
const COLLAPSE_KEY = "aurora.mockbar.collapsed";
const mockUrl = (file) => "/mocks/" + encodeURIComponent(file);
const displayName = (file) => file.replace(/\.html$/, "");
const escapeHtml = (s) =>
s.replace(
/[&<>"]/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c],
);
// The page's own identity, used to find its snapshot from a device page.
const currentPage = document.body?.dataset?.page || null;
const snapshotOfThisPage = () =>
currentPage ? mocks.find((m) => m.page === currentPage) : undefined;
const LAST_DEVICE_KEY = "aurora.mockbar.device";
const readStore = (store, key) => {
try {
return window[store].getItem(key);
} catch {
return null; // private mode
}
};
const writeStore = (store, key, value) => {
try {
if (value === null) window[store].removeItem(key);
else window[store].setItem(key, value);
} catch {
/* private mode — the bar just forgets between reloads */
}
};
// Where "back to the device" goes from inside a snapshot. data-page is the
// request path joined with "-", so splitting it back apart is wrong whenever
// a segment contains a dash of its own (admin-status-disks-info). LuCI's own
// inline bootstrap carries the segments verbatim, so read those first and
// keep the lossy split as the fallback.
const devicePath = () => {
const page = mocks.find((m) => m.file === data.current)?.page;
const bootstrap = document.documentElement.innerHTML.match(
/"requestpath"\s*:\s*\[([^\]]*)\]/,
);
const segs = bootstrap?.[1]
.match(/"((?:[^"\\]|\\.)*)"/g)
?.map((s) => JSON.parse(s));
// Only trust the bootstrap when it agrees with the snapshot's identity —
// a hand-assembled mock can carry the segments of the page it was built
// from.
if (segs && (!page || segs.join("-") === page))
return "/cgi-bin/luci/" + segs.join("/");
if (page) return "/cgi-bin/luci/" + page.split("-").join("/");
return readStore("sessionStorage", LAST_DEVICE_KEY) || "/cgi-bin/luci/";
};
// ---- shell ---------------------------------------------------------------
const host = document.createElement("aurora-mock-bar");
const root = host.attachShadow({ mode: "open" });
root.innerHTML = `
<style>
:host {
position: fixed;
left: 16px;
bottom: 16px;
z-index: 2147483000;
font: 12px/1.5 system-ui, -apple-system, sans-serif;
}
button {
font: inherit;
color: inherit;
background: none;
border: 0;
cursor: pointer;
padding: 0;
}
.pill {
display: flex;
align-items: center;
gap: 2px;
padding: 4px 6px;
border-radius: 999px;
background: rgba(18, 18, 24, 0.82);
color: #fff;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.pill[hidden],
.dot[hidden],
.step[hidden],
.here[hidden],
.back[hidden],
.capture[hidden] {
display: none;
}
.step,
.here,
.back,
.capture,
.close {
width: 22px;
height: 22px;
border-radius: 50%;
font-size: 14px;
opacity: 0.75;
}
.step:hover,
.here:hover,
.back:hover,
.capture:hover,
.close:hover {
background: rgba(255, 255, 255, 0.14);
opacity: 1;
}
.here {
color: #4ade80;
opacity: 1;
}
.close {
font-size: 12px;
opacity: 0.5;
}
.name {
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 2px 6px;
border-radius: 999px;
}
.name:hover {
background: rgba(255, 255, 255, 0.14);
}
.count {
opacity: 0.6;
margin-left: 4px;
}
.dot {
width: 14px;
height: 14px;
border-radius: 50%;
background: rgba(18, 18, 24, 0.5);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
}
.dot:hover {
background: rgba(18, 18, 24, 0.85);
}
.panel {
position: absolute;
left: 0;
bottom: calc(100% + 8px);
min-width: 260px;
max-width: 360px;
max-height: 60vh;
overflow: auto;
padding: 6px;
border-radius: 12px;
background: rgba(18, 18, 24, 0.92);
color: #fff;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.panel[hidden] {
display: none;
}
.panel a {
display: block;
padding: 6px 10px;
border-radius: 8px;
color: #fff;
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.panel a:hover {
background: rgba(255, 255, 255, 0.12);
}
.panel a.current {
color: #4ade80;
}
.panel a.current::before {
content: "● ";
font-size: 9px;
vertical-align: 1px;
}
.foot {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 4px;
padding: 6px 10px 2px;
border-top: 1px solid rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.55);
}
.foot a {
display: inline;
padding: 0;
color: inherit;
}
.foot a:hover {
color: #fff;
background: none;
}
.toast {
position: absolute;
left: 0;
bottom: calc(100% + 8px);
max-width: 320px;
padding: 8px 12px;
border-radius: 10px;
background: rgba(18, 18, 24, 0.92);
color: #ffd166;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
opacity: 0;
transition: opacity 160ms ease;
pointer-events: none;
}
.toast.show {
opacity: 1;
}
</style>
<div class="panel" hidden></div>
<div class="toast"></div>
<div class="pill">
<button class="step prev" title="上一个快照 [">‹</button>
<button class="here" title="打开本页快照">◆</button>
<button class="name" title="全部快照"></button>
<button class="step next" title="下一个快照 ]">›</button>
<button class="back" title="回到真机页">↩</button>
<button class="capture" title="捕获此页(Alt/Option+Shift+S)">⊕</button>
<button class="close" title="收起">✕</button>
</div>
<button class="dot" title="Aurora 快照" hidden></button>
`;
const panel = root.querySelector(".panel");
const toastEl = root.querySelector(".toast");
const pill = root.querySelector(".pill");
const dot = root.querySelector(".dot");
const nameBtn = root.querySelector(".name");
const hereBtn = root.querySelector(".here");
const backBtn = root.querySelector(".back");
const captureBtn = root.querySelector(".capture");
const prevBtn = root.querySelector(".prev");
const nextBtn = root.querySelector(".next");
let toastTimer;
const toast = (text, ok) => {
toastEl.textContent = text;
toastEl.style.color = ok ? "#4ade80" : "#ffd166";
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove("show"), 2800);
};
// ---- rendering -----------------------------------------------------------
const currentIndex = () =>
Math.max(
mocks.findIndex((m) => m.file === data.current),
0,
);
const render = () => {
const here = snapshotOfThisPage();
const count = mocks.length;
prevBtn.hidden = nextBtn.hidden = !inMock || count < 2;
captureBtn.hidden = inMock;
hereBtn.hidden = inMock || !here;
backBtn.hidden = !inMock;
if (inMock) backBtn.title = `回到真机页 ${devicePath()}`;
if (inMock) {
nameBtn.innerHTML =
escapeHtml(displayName(data.current || "")) +
`<span class="count">${currentIndex() + 1}/${count}</span>`;
} else if (!count) {
nameBtn.textContent = "捕获此页";
} else {
nameBtn.innerHTML =
(here ? "本页有快照" : "快照") + `<span class="count">${count}</span>`;
}
panel.innerHTML =
mocks
.map((m) => {
const isCurrent = inMock
? m.file === data.current
: !!currentPage && m.page === currentPage;
return (
`<a href="${mockUrl(m.file)}"` +
(isCurrent ? ' class="current"' : "") +
` title="${escapeHtml(m.page || m.file)}">` +
`${escapeHtml(displayName(m.file))}</a>`
);
})
.join("") +
'<div class="foot"><a href="/mocks/">全部快照 ↗</a>' +
`<span>${inMock ? "[ / ] 切换" : "Alt/⇧+S 捕获"}</span></div>`;
};
const setCollapsed = (collapsed) => {
pill.hidden = collapsed;
dot.hidden = !collapsed;
if (collapsed) panel.hidden = true;
writeStore("localStorage", COLLAPSE_KEY, collapsed ? "1" : null);
};
// ---- capture -------------------------------------------------------------
const capture = async () => {
try {
const response = await fetch("/mocks/__save", {
method: "POST",
headers: {
"Content-Type": "text/html; charset=utf-8",
"X-Aurora-Capture": "1",
},
body: "<!doctype html>\n" + document.documentElement.outerHTML,
});
const info = await response.json().catch(() => ({}));
if (!response.ok)
throw new Error(info.error || `HTTP ${response.status}`);
// Fold the fresh snapshot into the list the page was served with, so the
// bar reflects it without a reload.
const entry = { file: info.file, page: info.page ?? null };
const at = mocks.findIndex((m) => m.file === entry.file);
if (at === -1) mocks.push(entry);
else mocks[at] = entry;
mocks.sort((a, b) => a.file.localeCompare(b.file));
render();
toast(`已捕获 ${info.file}`, true);
} catch (err) {
toast(`捕获失败:${(err && err.message) || err}`, false);
}
};
// ---- wiring --------------------------------------------------------------
const cycle = (step) => {
if (!inMock || mocks.length < 2) return;
const next = mocks[(currentIndex() + step + mocks.length) % mocks.length];
location.href = mockUrl(next.file);
};
prevBtn.addEventListener("click", () => cycle(-1));
nextBtn.addEventListener("click", () => cycle(1));
hereBtn.addEventListener("click", () => {
const here = snapshotOfThisPage();
if (here) location.href = mockUrl(here.file);
});
backBtn.addEventListener("click", () => {
location.href = devicePath();
});
captureBtn.addEventListener("click", capture);
nameBtn.addEventListener("click", () => {
if (!inMock && !mocks.length) return capture();
panel.hidden = !panel.hidden;
});
root
.querySelector(".close")
.addEventListener("click", () => setCollapsed(true));
dot.addEventListener("click", () => setCollapsed(false));
document.addEventListener("click", (event) => {
if (!panel.hidden && !event.composedPath().includes(host)) {
panel.hidden = true;
}
});
render();
setCollapsed(readStore("localStorage", COLLAPSE_KEY) === "1");
document.documentElement.appendChild(host);
// Remembered as the last resort for "back to the device" from a snapshot
// that carries neither a usable bootstrap nor a data-page.
if (!inMock)
writeStore(
"sessionStorage",
LAST_DEVICE_KEY,
location.pathname + location.search,
);
// ---- navigation takeover (snapshots only) --------------------------------
const pageFromHref = (href) => {
try {
const url = new URL(href, location.href);
if (url.origin !== location.origin) return null;
const match = url.pathname.match(/^\/cgi-bin\/luci(?:$|\/(.*))/);
if (!match) return null;
return decodeURIComponent(match[1] || "")
.split("/")
.filter(Boolean)
.join("-");
} catch {
return null;
}
};
if (inMock) {
const byPage = new Map(
mocks.filter((m) => m.page).map((m) => [m.page, m.file]),
);
document.addEventListener(
"click",
(event) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
)
return;
const target = event.target instanceof Element ? event.target : null;
const anchor = target && target.closest("a[href]");
if (!anchor) return;
const page = pageFromHref(anchor.getAttribute("href"));
if (page === null) return;
// Never let a click inside a mock fall through to the proxied router.
event.preventDefault();
const file = byPage.get(page);
if (file) location.href = mockUrl(file);
else
toast(
`未捕获快照:${page || "/cgi-bin/luci"}(真机页 Alt/Option+Shift+S 可捕获)`,
);
},
true,
);
}
// ---- keyboard ------------------------------------------------------------
const isEditable = (el) =>
el instanceof Element &&
(el.isContentEditable || /^(input|textarea|select)$/i.test(el.tagName));
addEventListener("keydown", (event) => {
if (isEditable(event.target)) return;
if (
!inMock &&
event.altKey &&
event.shiftKey &&
!event.metaKey &&
!event.ctrlKey &&
event.code === "KeyS"
) {
event.preventDefault();
capture();
return;
}
if (event.metaKey || event.ctrlKey || event.altKey) return;
if (event.key === "[") cycle(-1);
else if (event.key === "]") cycle(1);
});
if (!inMock) {
window.__auroraMockCapture = capture;
console.info(
"[aurora] mock bar ready — 左下角可捕获/打开快照,Alt/Option+Shift+S(或 __auroraMockCapture())保存当前页到 .dev/mocks/",
);
}
})();
@@ -1,75 +0,0 @@
/**
* Aurora dev helper — injected by the /cgi-bin dev proxy (vite.config.ts)
* into real device-served LuCI pages. Never ships to the router.
*
* Alt/Option+Shift+S — or __auroraMockCapture() from the console — POSTs the
* live DOM to /mocks/__save, which writes .dev/mocks/<data-page>.html for the
* /mocks/ workflow (see "Mock Pages" in .dev/docs/DEVELOPMENT.md).
*/
(() => {
"use strict";
// Mock pages are served, not proxied, so this only runs on real pages —
// the guard is just belt and braces against a stale captured tag.
if (window.__auroraMockCapture || location.pathname.startsWith("/mocks/"))
return;
let toastEl;
let toastTimer;
const toast = (text, ok) => {
if (!toastEl) {
toastEl = document.createElement("div");
toastEl.style.cssText =
"position:fixed;left:50%;bottom:32px;transform:translateX(-50%);" +
"z-index:2147483647;padding:9px 14px;border-radius:10px;" +
"font:13px/1.5 system-ui,-apple-system,sans-serif;" +
"background:rgba(18,18,24,.9);box-shadow:0 8px 32px rgba(0,0,0,.35);" +
"backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);" +
"transition:opacity 160ms ease;pointer-events:none;max-width:80vw";
document.documentElement.appendChild(toastEl);
}
toastEl.textContent = text;
toastEl.style.color = ok ? "#4ade80" : "#ffd166";
toastEl.style.opacity = "1";
clearTimeout(toastTimer);
toastTimer = setTimeout(() => (toastEl.style.opacity = "0"), 3000);
};
const capture = async () => {
try {
const response = await fetch("/mocks/__save", {
method: "POST",
headers: {
"Content-Type": "text/html; charset=utf-8",
"X-Aurora-Capture": "1",
},
body: "<!doctype html>\n" + document.documentElement.outerHTML,
});
const info = await response.json().catch(() => ({}));
if (!response.ok)
throw new Error(info.error || `HTTP ${response.status}`);
toast(`已捕获 ${info.file} — 在 /mocks/ 打开`, true);
} catch (err) {
toast(`捕获失败:${(err && err.message) || err}`, false);
}
};
window.__auroraMockCapture = capture;
addEventListener("keydown", (event) => {
if (
event.altKey &&
event.shiftKey &&
!event.metaKey &&
!event.ctrlKey &&
event.code === "KeyS"
) {
event.preventDefault();
capture();
}
});
console.info(
"[aurora] mock capture ready — Alt/Option+Shift+S(或 __auroraMockCapture())保存当前页到 .dev/mocks/",
);
})();
@@ -1,289 +0,0 @@
/**
* Aurora dev helper — injected by mock-pages-plugin (vite.config.ts) into
* every page served under /mocks/. Never ships to the router.
*
* 1. Navigation takeover: clicks on the snapshot's own LuCI links
* (/cgi-bin/luci/…) resolve against the captured snapshots — matched by
* each snapshot's <body data-page>, exact match — and jump to the matching
* mock instead of falling through to the proxied router. Uncaptured
* targets are blocked with a hint naming the missing snapshot.
* 2. Floating switcher (bottom-left; the theme's own floating toolbar owns
* the bottom-right corner). Shadow DOM, so theme/patch CSS can't restyle
* it and its styles can't leak into the page under review. Lists every
* snapshot, cycles with the ‹/› buttons or the [ and ] keys, links back
* to the /mocks/ index.
*
* Data contract (injected inline before this script):
* window.__AURORA_MOCKS__ = { current: "<file>", mocks: [{ file, page }] }
*/
(() => {
"use strict";
const data = window.__AURORA_MOCKS__;
if (!data || !Array.isArray(data.mocks) || !data.mocks.length) return;
const mocks = data.mocks;
const byPage = new Map(
mocks.filter((m) => m.page).map((m) => [m.page, m.file]),
);
const current = Math.max(
mocks.findIndex((m) => m.file === data.current),
0,
);
const mockUrl = (file) => "/mocks/" + encodeURIComponent(file);
const displayName = (file) => file.replace(/\.html$/, "");
const escapeHtml = (s) =>
s.replace(
/[&<>"]/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c],
);
// ---- floating switcher ---------------------------------------------------
const host = document.createElement("aurora-mock-nav");
const root = host.attachShadow({ mode: "open" });
root.innerHTML = `
<style>
:host {
position: fixed;
left: 16px;
bottom: 16px;
z-index: 2147483000;
font: 12px/1.5 system-ui, -apple-system, sans-serif;
}
button {
font: inherit;
color: inherit;
background: none;
border: 0;
cursor: pointer;
padding: 0;
}
.pill {
display: flex;
align-items: center;
gap: 2px;
padding: 4px 6px;
border-radius: 999px;
background: rgba(18, 18, 24, 0.82);
color: #fff;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.step {
width: 22px;
height: 22px;
border-radius: 50%;
font-size: 14px;
opacity: 0.75;
}
.step:hover {
background: rgba(255, 255, 255, 0.14);
opacity: 1;
}
.name {
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 2px 6px;
border-radius: 999px;
}
.name:hover {
background: rgba(255, 255, 255, 0.14);
}
.count {
opacity: 0.6;
margin-left: 4px;
}
.panel {
position: absolute;
left: 0;
bottom: calc(100% + 8px);
min-width: 260px;
max-width: 360px;
max-height: 60vh;
overflow: auto;
padding: 6px;
border-radius: 12px;
background: rgba(18, 18, 24, 0.92);
color: #fff;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.panel[hidden] {
display: none;
}
.panel a {
display: block;
padding: 6px 10px;
border-radius: 8px;
color: #fff;
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.panel a:hover {
background: rgba(255, 255, 255, 0.12);
}
.panel a.current {
color: #4ade80;
}
.panel a.current::before {
content: "● ";
font-size: 9px;
vertical-align: 1px;
}
.foot {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 4px;
padding: 6px 10px 2px;
border-top: 1px solid rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.55);
}
.foot a {
display: inline;
padding: 0;
color: inherit;
}
.foot a:hover {
color: #fff;
background: none;
}
.toast {
position: absolute;
left: 0;
bottom: calc(100% + 8px);
max-width: 320px;
padding: 8px 12px;
border-radius: 10px;
background: rgba(18, 18, 24, 0.92);
color: #ffd166;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
opacity: 0;
transition: opacity 160ms ease;
pointer-events: none;
}
.toast.show {
opacity: 1;
}
</style>
<div class="panel" hidden></div>
<div class="toast"></div>
<div class="pill">
<button class="step prev" title="上一个快照 [">‹</button>
<button class="name" title="全部快照"></button>
<button class="step next" title="下一个快照 ]">›</button>
</div>
`;
const panel = root.querySelector(".panel");
const toastEl = root.querySelector(".toast");
const nameBtn = root.querySelector(".name");
nameBtn.innerHTML =
escapeHtml(displayName(data.current)) +
`<span class="count">${current + 1}/${mocks.length}</span>`;
panel.innerHTML =
mocks
.map(
(m) =>
`<a href="${mockUrl(m.file)}"` +
(m.file === data.current ? ' class="current"' : "") +
` title="${escapeHtml(m.page || m.file)}">` +
`${escapeHtml(displayName(m.file))}</a>`,
)
.join("") +
'<div class="foot"><a href="/mocks/">全部快照 ↗</a><span>[ / ] 切换</span></div>';
const cycle = (step) => {
if (mocks.length < 2) return;
const next = mocks[(current + step + mocks.length) % mocks.length];
location.href = mockUrl(next.file);
};
root.querySelector(".prev").addEventListener("click", () => cycle(-1));
root.querySelector(".next").addEventListener("click", () => cycle(1));
nameBtn.addEventListener("click", () => {
panel.hidden = !panel.hidden;
});
document.addEventListener("click", (event) => {
if (!panel.hidden && !event.composedPath().includes(host)) {
panel.hidden = true;
}
});
let toastTimer;
const toast = (text) => {
toastEl.textContent = text;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove("show"), 2600);
};
document.documentElement.appendChild(host);
// ---- navigation takeover -------------------------------------------------
const pageFromHref = (href) => {
try {
const url = new URL(href, location.href);
if (url.origin !== location.origin) return null;
const match = url.pathname.match(/^\/cgi-bin\/luci(?:$|\/(.*))/);
if (!match) return null;
return decodeURIComponent(match[1] || "")
.split("/")
.filter(Boolean)
.join("-");
} catch {
return null;
}
};
document.addEventListener(
"click",
(event) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
)
return;
const target = event.target instanceof Element ? event.target : null;
const anchor = target && target.closest("a[href]");
if (!anchor) return;
const page = pageFromHref(anchor.getAttribute("href"));
if (page === null) return;
// Never let a click inside a mock fall through to the proxied router.
event.preventDefault();
const file = byPage.get(page);
if (file) location.href = mockUrl(file);
else
toast(
`未捕获快照:${page || "/cgi-bin/luci"}(真机页 Alt/Option+Shift+S 可捕获)`,
);
},
true,
);
const isEditable = (el) =>
el instanceof Element &&
(el.isContentEditable || /^(input|textarea|select)$/i.test(el.tagName));
addEventListener("keydown", (event) => {
if (event.metaKey || event.ctrlKey || event.altKey) return;
if (isEditable(event.target)) return;
if (event.key === "[") cycle(-1);
else if (event.key === "]") cycle(1);
});
})();
+12 -2
View File
@@ -498,11 +498,21 @@ body[data-nav-type="sidebar"] {
}
& .sidebar-list .navigation-direct {
@apply truncate text-lg;
/* flex, matching .nav-category: icon + label on one row (the label
span owns the truncation). */
@apply flex items-center gap-2 text-lg;
}
& .sidebar-list .nav-icon {
@apply size-4.5;
}
& .sidebar-submenu {
@apply m-0 min-h-0 list-none space-y-0.5 overflow-hidden p-0 pl-4;
/* pl: the parent row's px-3 (0.75) + icon (1.125) + gap (0.5) put the
label at 2.375rem; sublinks bring their own px-3, so the list
supplies the remaining 1.625rem and sublink text hangs off the
parent label, not the row edge. */
@apply m-0 min-h-0 list-none space-y-0.5 overflow-hidden p-0 pl-6.5;
}
& .sidebar-submenu .navigation-sublink {
@@ -173,10 +173,13 @@
@apply flex h-full flex-1 flex-col items-center justify-around gap-2;
& > div {
@apply flex flex-wrap;
@apply flex flex-wrap gap-2 max-md:gap-1.5;
}
/* `flex-1` alone lets the badges shrink to min-content, so six SSIDs stay
crammed on one line instead of wrapping. The floor mirrors
luci-theme-bootstrap's `min-width: 220px` on the same badges. */
& .ifacebadge {
@apply flex-1;
@apply min-w-55 flex-1;
}
}
@@ -55,6 +55,31 @@
@apply text-brand;
}
/* ── Row icons ──
A leading glyph inline with the label, on sidebar and mobile-drawer
rows alike (the mega-menu title draws its own ::before). bg-current +
mask lets the glyph track the row's text colour through hover, expanded
and active states; the size is set per surface — sidebar rows in
_layout.css, drawer rows in _overlay.css. */
.nav-icon {
@apply shrink-0 bg-current opacity-55 transition-opacity duration-150 [mask:var(--menu-icon,var(--icon-category))_center/contain_no-repeat];
}
/* Direct-row labels; group rows keep .nav-category-label. flex-1 also
holds the label against the drawer's justify-between rows. */
.nav-label {
@apply min-w-0 flex-1 truncate;
}
/* Full-strength glyph wherever the row itself is highlighted. */
.navigation-direct:hover .nav-icon,
.navigation-group-toggle:hover .nav-icon,
.navigation-direct.is-active-page .nav-icon,
.navigation-group.is-expanded > .navigation-group-toggle .nav-icon,
.navigation-group.is-active-group > .navigation-group-toggle .nav-icon {
@apply opacity-100;
}
.navigation-group-region {
@apply grid grid-rows-[0fr] opacity-0 transition-[grid-template-rows,opacity] duration-[250ms] ease-out;
}
@@ -85,45 +110,49 @@
}
/* ── First-level section icons ──
The name → icon map, consumed by the mega-menu title (_layout.css).
Keyed on the LuCI node name (data-section, set by menu-aurora.js) —
stable and language-independent. Scoped to .desktop-nav-title so the
selector can't accidentally match unrelated data-section usage elsewhere.
The only maintenance point: add new first-level sections here; anything
unmapped falls back to the default icon at the consumer. */
.desktop-nav-title[data-section="status"] {
The name → icon map, shared by every navigation surface: the mega-menu
title (::before consumer in _layout.css) and the sidebar/mobile-drawer
row glyphs (.nav-icon below). Keyed on the LuCI node name (data-section,
set by menu-aurora.js) — stable and language-independent. Scoped to the
three row elements that carry the attribute so the selector can't
accidentally match unrelated data-section usage elsewhere; :is() keeps
every key at (0,2,0) — the mega-menu consumer's fallback chain would
outrank anything higher (see _layout.css). The only maintenance point:
add new first-level sections here; anything unmapped falls back to the
default icon at the consumer. */
:is(.desktop-nav-title, .navigation-direct, .navigation-group-toggle)[data-section="status"] {
@apply [--menu-icon:var(--icon-activity)];
}
.desktop-nav-title[data-section="statistics"] {
:is(.desktop-nav-title, .navigation-direct, .navigation-group-toggle)[data-section="statistics"] {
@apply [--menu-icon:var(--icon-chart-bar)];
}
.desktop-nav-title[data-section="nlbw"] {
:is(.desktop-nav-title, .navigation-direct, .navigation-group-toggle)[data-section="nlbw"] {
@apply [--menu-icon:var(--icon-gauge)];
}
.desktop-nav-title[data-section="system"] {
:is(.desktop-nav-title, .navigation-direct, .navigation-group-toggle)[data-section="system"] {
@apply [--menu-icon:var(--icon-settings)];
}
.desktop-nav-title[data-section="services"] {
:is(.desktop-nav-title, .navigation-direct, .navigation-group-toggle)[data-section="services"] {
@apply [--menu-icon:var(--icon-apps)];
}
.desktop-nav-title[data-section="nas"] {
:is(.desktop-nav-title, .navigation-direct, .navigation-group-toggle)[data-section="nas"] {
@apply [--menu-icon:var(--icon-server)];
}
.desktop-nav-title[data-section="control"] {
:is(.desktop-nav-title, .navigation-direct, .navigation-group-toggle)[data-section="control"] {
@apply [--menu-icon:var(--icon-adjustments)];
}
.desktop-nav-title[data-section="network"] {
:is(.desktop-nav-title, .navigation-direct, .navigation-group-toggle)[data-section="network"] {
@apply [--menu-icon:var(--icon-world)];
}
.desktop-nav-title[data-section="vpn"] {
:is(.desktop-nav-title, .navigation-direct, .navigation-group-toggle)[data-section="vpn"] {
@apply [--menu-icon:var(--icon-shield)];
}
.desktop-nav-title[data-section="docker"] {
:is(.desktop-nav-title, .navigation-direct, .navigation-group-toggle)[data-section="docker"] {
@apply [--menu-icon:var(--icon-box)];
}
/* "asterisk" covers VoIP/PBX apps (e.g. luci-app-asterisk forks) — no
official OpenWrt package registers a verified menu.d node under this
name, so this is a best-effort key. */
.desktop-nav-title[data-section="asterisk"] {
:is(.desktop-nav-title, .navigation-direct, .navigation-group-toggle)[data-section="asterisk"] {
@apply [--menu-icon:var(--icon-phone)];
}
@@ -28,12 +28,22 @@
@apply max-md:shrink-0;
& .mobile-nav-link {
@apply max-md:flex max-md:min-h-13 max-md:w-full max-md:items-center max-md:justify-between max-md:gap-4 max-md:rounded-none max-md:border-0 max-md:bg-transparent max-md:px-0 max-md:py-2.5 max-md:text-left max-md:text-2xl max-md:leading-tight max-md:font-medium max-md:tracking-tight max-md:no-underline max-md:shadow-none;
/* gap-3 (not 4): tight enough that the leading .nav-icon reads
attached to its label; justify-between still pins the group
arrow to the right edge. */
@apply max-md:flex max-md:min-h-13 max-md:w-full max-md:items-center max-md:justify-between max-md:gap-3 max-md:rounded-none max-md:border-0 max-md:bg-transparent max-md:px-0 max-md:py-2.5 max-md:text-left max-md:text-2xl max-md:leading-tight max-md:font-medium max-md:tracking-tight max-md:no-underline max-md:shadow-none;
& .nav-icon {
/* Row icons at drawer scale, against the text-2xl labels. */
@apply max-md:size-6;
}
}
& .mobile-nav-submenu {
& .mobile-nav-submenu-list {
@apply max-md:mx-0 max-md:mt-0 max-md:mb-2 max-md:py-1 max-md:pr-0 max-md:pl-4;
/* pl: rows are px-0, so icon (1.5) + gap (0.75) put the label
at 2.25rem; sublinks bring px-3, the list supplies the rest. */
@apply max-md:mx-0 max-md:mt-0 max-md:mb-2 max-md:py-1 max-md:pr-0 max-md:pl-6;
}
& .mobile-nav-subitem {
@@ -0,0 +1,35 @@
/*
PATCH: admin-system-diskman (luci-app-diskman)
Loaded on-demand by header.ut only when data-page matches this filename —
the prefix match also covers .../partition/<dev> and .../btrfs/<uuid>.
Native declarations keep this standalone patch free of Tailwind helper output;
theme values come through the :root custom properties.
*/
[data-page^="admin-system-diskman"] {
/* disk_info.htm injects the partition bar as a full-span row whose cell carries
an inline white-space:nowrap. Once .tr turns into a flex row on mobile, that
cell's automatic minimum size is its min-content — the whole unwrapped bar —
so it refuses to shrink and drags the page ~300px wide. Zero the minimum and
give the bar its own row. */
td[colspan]:has(> div[title]) {
flex: 0 0 100%;
min-width: 0;
overflow: hidden;
border-radius: var(--radius-base);
/* The segments are inline-blocks sized in percent: any whitespace between
them adds a word space on top of the 100% they already sum to. */
font-size: 0;
}
/* Each segment is exactly as wide as its share of the disk, but its label does
not wrap and spills onto the neighbouring segments. Clip it — the full text
stays available through the title attribute. */
td[colspan] > div[title],
.tr > div[style*="nowrap"] > div[title] {
overflow: hidden;
font-size: 0.75rem;
line-height: 1.5rem;
text-overflow: ellipsis;
}
}
@@ -345,7 +345,15 @@ return baseclass.extend({
href: item.href,
};
if (item.isActivePage) attributes["aria-current"] = "page";
return E("li", { class: itemClass }, [E("a", attributes, [item.title])]);
// data-section keys the shared name → icon map (_nav.css); the icon
// span renders it, unmapped names fall back at the consumer.
attributes["data-section"] = item.name;
return E("li", { class: itemClass }, [
E("a", attributes, [
E("span", { class: "nav-icon", "aria-hidden": "true" }),
E("span", { class: "nav-label" }, [item.title]),
]),
]);
}
const submenuId = this.navigationSubmenuId(mode, item.name);
@@ -364,6 +372,8 @@ return baseclass.extend({
"aria-controls": submenuId,
};
if (item.isActiveGroup) toggleAttributes["aria-current"] = "location";
// See the direct-row note: keys the shared icon map.
toggleAttributes["data-section"] = item.name;
const list = E("ul", {
class: mobile
@@ -400,6 +410,7 @@ return baseclass.extend({
return E("li", { class: groupClasses.join(" ") }, [
E("button", toggleAttributes, [
E("span", { class: "nav-icon", "aria-hidden": "true" }),
E("span", { class: "nav-category-label" }, [item.title]),
]),
E("div", regionAttributes, [list]),
@@ -125,11 +125,16 @@ test("shared navigation styles own accordion animation without a guide rail", ()
test("desktop sidebar styles only provide desktop navigation density", () => {
const sidebar = getBlock(layoutStyles, 'body[data-nav-type="sidebar"]');
const direct = getBlock(sidebar, "& .sidebar-list .navigation-direct");
const icon = getBlock(sidebar, "& .sidebar-list .nav-icon");
const submenu = getBlock(sidebar, "& .sidebar-submenu");
const sublink = getBlock(sidebar, "& .sidebar-submenu .navigation-sublink");
assertIncludesUtilities(direct, ["truncate", "text-lg"]);
assertIncludesUtilities(submenu, ["pl-4"]);
// Direct rows are icon + label flex rows; the label span truncates.
assertIncludesUtilities(direct, ["flex", "items-center", "gap-2", "text-lg"]);
assertIncludesUtilities(icon, ["size-4.5"]);
// pl-6.5 hangs sublink text off the parent label (px-3 + icon + gap −
// the sublink's own px-3), not the row edge.
assertIncludesUtilities(submenu, ["pl-6.5"]);
assertIncludesUtilities(sublink, ["px-3", "py-1.5", "text-sm"]);
assert.doesNotMatch(
sidebar,
@@ -140,10 +145,15 @@ test("desktop sidebar styles only provide desktop navigation density", () => {
test("mobile drawer styles only provide mobile navigation density", () => {
const drawer = getBlock(overlayStyles, ".mobile-menu-overlay");
const icon = getBlock(drawer, "& .nav-icon");
const submenu = getBlock(drawer, "& .mobile-nav-submenu-list");
const sublink = getBlock(drawer, "& .mobile-nav-sublink");
assertIncludesUtilities(submenu, ["max-md:pl-4"]);
// Row icons at drawer scale, against the text-2xl labels.
assertIncludesUtilities(icon, ["max-md:size-6"]);
// pl-6 hangs sublink text off the parent label (rows are px-0: icon +
// gap − the sublink's own px-3).
assertIncludesUtilities(submenu, ["max-md:pl-6"]);
assertIncludesUtilities(sublink, [
"max-md:min-h-10",
"max-md:px-3",
+38 -41
View File
@@ -383,19 +383,15 @@ function createLocalServePlugin(): Plugin {
// patches/*.css, served JS) trigger the existing full-reload in handleHotUpdate.
// Any third-party asset a snapshot needs (the app's own css/js, e.g.
// qmodem-next.css) goes under .dev/mocks/static/ mirroring its /luci-static/…
// URL and is served as-is (no HMR). Served snapshots additionally get
// scripts/mock-nav.client.js (links between captured pages navigate in place,
// plus a floating switcher), and proxied device pages get
// scripts/mock-capture.client.js (hotkey → POST /mocks/__save captures the
// open page as a snapshot). See "Mock Pages" in .dev/docs/DEVELOPMENT.md.
// URL and is served as-is (no HMR). Both served snapshots and proxied device
// pages get scripts/mock-bar.client.js — a floating bar that lists the
// snapshots, opens the one matching the open page, captures the open device
// page (POST /mocks/__save), and inside a snapshot takes over its LuCI links.
// See "Mock Pages" in .dev/docs/DEVELOPMENT.md.
const MOCK_ROUTE = "/mocks";
const MOCKS_DIR = resolve(CURRENT_DIR, "mocks");
const MOCKS_STATIC_DIR = join(MOCKS_DIR, "static");
const MOCK_NAV_CLIENT = resolve(CURRENT_DIR, "scripts/mock-nav.client.js");
const MOCK_CAPTURE_CLIENT = resolve(
CURRENT_DIR,
"scripts/mock-capture.client.js",
);
const MOCK_BAR_CLIENT = resolve(CURRENT_DIR, "scripts/mock-bar.client.js");
// A captured LuCI page is tens of KB — anything past this is not a page.
const MOCK_SAVE_LIMIT = 20 * 1024 * 1024;
@@ -513,17 +509,19 @@ function listMocks(): MockEntry[] {
.sort((a, b) => a.file.localeCompare(b.file));
}
// Handing the snapshot list to the page lets mock-nav.client.js take over the
// snapshot's own /cgi-bin/luci/ links (jump to the matching mock instead of
// falling through to the proxied router) and render the floating switcher.
function injectMockNav(html: string, current: string): string {
// Handing the snapshot list to the page is what makes the bar work in both
// contexts: inside a snapshot it also drives the link takeover, on a device
// page it tells the bar which snapshots exist (`current` is null there — a
// live page is not one of them). data-aurora-mock marks every tag this server
// injects so a capture can strip them back out.
function injectMockBar(html: string, current: string | null): string {
const payload = JSON.stringify({
current,
mocks: listMocks().map(({ file, page }) => ({ file, page })),
}).replace(/</g, "\\u003c");
const tags =
`<script>window.__AURORA_MOCKS__ = ${payload};</script>\n` +
` <script defer src="${MOCK_ROUTE}/__nav.js"></script>\n `;
`<script data-aurora-mock>window.__AURORA_MOCKS__ = ${payload};</script>\n` +
` <script data-aurora-mock defer src="${MOCK_ROUTE}/__bar.js"></script>\n `;
return html.includes("</head>")
? html.replace("</head>", `${tags}</head>`)
: html + tags;
@@ -623,23 +621,17 @@ function createMockPlugin(): Plugin {
// response — thrown, it becomes an unhandled rejection and takes the
// whole dev server down with it.
try {
// Dev-helper client scripts (kept as real files for editability).
if (
pathname === `${MOCK_ROUTE}/__nav.js` ||
pathname === `${MOCK_ROUTE}/__capture.js`
) {
const file = pathname.endsWith("/__nav.js")
? MOCK_NAV_CLIENT
: MOCK_CAPTURE_CLIENT;
// Dev-helper client script (kept as a real file for editability).
if (pathname === `${MOCK_ROUTE}/__bar.js`) {
res.statusCode = 200;
res.setHeader("Content-Type", "text/javascript; charset=utf-8");
res.setHeader("Cache-Control", "no-store");
res.end(await readFile(file, "utf-8"));
res.end(await readFile(MOCK_BAR_CLIENT, "utf-8"));
return;
}
// Capture endpoint, written to by mock-capture.client.js from
// proxied device pages. The custom header doubles as a CORS gate: a
// Capture endpoint, written to by mock-bar.client.js from proxied
// device pages. The custom header doubles as a CORS gate: a
// foreign origin can't send it without a preflight this server never
// approves, so drive-by cross-site POSTs are rejected.
if (pathname === `${MOCK_ROUTE}/__save`) {
@@ -649,9 +641,15 @@ function createMockPlugin(): Plugin {
return sendJson(res, 403, { error: "missing capture header" });
const html = (await readRequestBody(req, MOCK_SAVE_LIMIT))
// The live DOM carries the dev-only tags this server injected;
// a snapshot must stay a clean capture of the real page.
// a snapshot must stay a clean capture of the real page. The
// snapshot list in particular must not be baked in — it is
// re-injected, current, on every serve.
.replace(
/<script[^>]*\bsrc="(?:\/@vite\/client|\/mocks\/__capture\.js)"[^>]*>\s*<\/script>\s*/gi,
/<script\b[^>]*\bdata-aurora-mock\b[^>]*>[\s\S]*?<\/script>\s*/gi,
"",
)
.replace(
/<script[^>]*\bsrc="\/@vite\/client"[^>]*>\s*<\/script>\s*/gi,
"",
);
const page =
@@ -679,8 +677,8 @@ function createMockPlugin(): Plugin {
return;
}
// A single snapshot: LuCI runtime neutralised, HMR client + in-mock
// navigation injected, doctype restored.
// A single snapshot: LuCI runtime neutralised, HMR client + mock bar
// injected, doctype restored.
if (inMocks && pathname.endsWith(".html")) {
const name = basename(decodeURIComponent(pathname));
const file = resolve(MOCKS_DIR, name);
@@ -702,7 +700,7 @@ function createMockPlugin(): Plugin {
const snapshot = await readFile(file, "utf-8");
res.end(
ensureDoctype(
injectMockNav(
injectMockBar(
injectHmrClient(neutralizeLuciRuntime(snapshot)),
name,
),
@@ -1060,15 +1058,14 @@ export default defineConfig(({ mode }) => {
) {
html = html.replace("</head>", `${client}\n\t</head>`);
}
// Real device pages also get the mock-capture helper:
// Alt/Option+Shift+S → POST /mocks/__save stores the open
// page as a snapshot for the /mocks/ workflow.
const capture = `<script defer src="${MOCK_ROUTE}/__capture.js"></script>`;
if (
html.includes("</head>") &&
!html.includes("/__capture.js")
) {
html = html.replace("</head>", `${capture}\n\t</head>`);
// Real device pages also get the mock bar, so the snapshot
// workflow is one click away instead of a URL to remember:
// it lists what .dev/mocks/ holds, opens this page's own
// snapshot when there is one, and captures the open page
// (same as Alt/Option+Shift+S). `current` is null — a live
// page is not itself a snapshot.
if (html.includes("</head>") && !html.includes("/__bar.js")) {
html = injectMockBar(html, null);
}
const { "transfer-encoding": _, ...headers } = proxyRes.headers;
res.writeHead(status, {
+2 -2
View File
@@ -8,8 +8,8 @@ include $(TOPDIR)/rules.mk
LUCI_TITLE:=Aurora Theme (A modern browser theme built with Vite and Tailwind CSS)
LUCI_DEPENDS:=+luci-base
PKG_VERSION:=1.1.10
PKG_RELEASE:=61
PKG_VERSION:=1.1.13
PKG_RELEASE:=62
PKG_LICENSE:=Apache-2.0
LUCI_MINIFY_CSS:=
@@ -1,2 +1,2 @@
/*! tailwindcss v4.3.2 | MIT License | https://tailwindcss.com */
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-border-style:solid;--tw-duration:initial;--tw-ease:initial;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-content:"";--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-space-y-reverse:0;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-border-spacing-x:0;--tw-border-spacing-y:0}}}@layer theme{:root,:host{--font-sans:var(--font-sans);--font-mono:var(--font-mono);--spacing:var(--spacing);--container-xs:20rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--leading-tight:1.25;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--ease-out:cubic-bezier(0, 0, .2, 1);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--blur-md:12px;--blur-lg:16px;--blur-xl:24px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1Line truncated
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-border-style:solid;--tw-duration:initial;--tw-ease:initial;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-content:"";--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-space-y-reverse:0;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-border-spacing-x:0;--tw-border-spacing-y:0}}}@layer theme{:root,:host{--font-sans:var(--font-sans);--font-mono:var(--font-mono);--spacing:var(--spacing);--container-xs:20rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--leading-tight:1.25;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--ease-out:cubic-bezier(0, 0, .2, 1);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--blur-md:12px;--blur-lg:16px;--blur-xl:24px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1Line truncated
@@ -0,0 +1 @@
[data-page^=admin-system-diskman] td[colspan]:has(>div[title]){border-radius:var(--radius-base);flex:0 0 100%;min-width:0;font-size:0;overflow:hidden}[data-page^=admin-system-diskman] td[colspan]>div[title],[data-page^=admin-system-diskman] .tr>div[style*=nowrap]>div[title]{text-overflow:ellipsis;font-size:.75rem;line-height:1.5rem;overflow:hidden}
@@ -1 +1 @@
"use strict";"require baseclass";"require ui";return baseclass.extend({__init__(){ui.menu.load().then(e=>this.render(e)),this.initNavigationControls(),this.initUciIndicator()},initUciIndicator(){const e=ui.changes?.setIndicator;e&&(ui.changes.setIndicator=function(t){e.call(this,t),document.querySelector('[data-indicator="uci-changes"]')?.setAttribute("data-count",t||0)})},initNavigationControls(){const e=document.querySelector("#mobile-menu-overlay"),t=document.querySelector("#navigation-toggle");if(!t||!e)return;const i=e.querySelector("#mobile-nav-list"),a=window.matchMedia("(min-width: 768px)"),n="aurora.sidebarCollapsed";let s;const o=()=>a.matches&&"sidebar"===document.body.dataset.navType,l=e=>{const i=o();t.classList.toggle("is-expanded",e&&!a.matches),t.setAttribute("aria-expanded",e?"true":"false"),t.setAttribute("aria-controls",i?"sidebar-panel":"mobile-menu-overlay");const n=e&&!i?_("Close"):_("Navigation");t.setAttribute("title",n),t.setAttribute("aria-label",n)},r=()=>{e.classList.remove("mobile-menu-open"),document.body.classList.remove("mobile-navigation-open"),document.body.style.overflow="",this.resetNavigationGroups(i)},c=t=>{if(o()){r();const e=!t,i=document.body;return i.classList.remove("sidebar-anim-open","sidebar-anim-close"),i.classList.add(e?"sidebar-anim-close":"sidebar-anim-open"),clearTimeout(s),s=setTimeout(()=>{i.classList.remove("sidebar-anim-open","sidebar-anim-close")},300),i.classList.toggle("sidebar-collapsed",e),localStorage.setItem(n,e),void l(t)}if(a.matches)return r(),void l(!1);e.classList.toggle("mobile-menu-open",t),document.body.classList.toggle("mobile-navigation-open",t),document.body.style.overflow=t?"hidden":"",t?this.expandActiveNavigationGroup(i):this.resetNavigationGroups(i),l(t)},d=()=>{if(r(),o()){const e="true"===localStorage.getItem(n);return document.body.classList.toggle("sidebar-collapsed",e),void l(!e)}l(!1)};t.addEventListener("click",t=>{t.stopPropagation(),c(!(o()?!document.body.classList.contains("sidebar-collapsed"):!a.matches&&e.classList.contains("mobile-menu-open")))}),e.addEventListener("click",t=>{t.target===e&&c(!1)}),document.addEventListener("keydown",t=>{"Escape"===t.key&&e.classList.contains("mobile-menu-open")&&c(!1)}),a.addEventListener("change",d),d(),document.addEventListener("click",t=>{const i=t.target?.closest?.(".navigation-direct, .navigation-sublink, .mobile-nav-logout");i&&e.contains(i)&&c(!1)})},renderMobileMenu(e){const t=document.querySelector("#mobile-nav-list"),i=document.querySelector("#mobile-nav-footer-action");t&&(t.innerHTML=""),i&&(i.innerHTML=""),t&&(e.forEach(e=>{e.isLogout?i&&i.appendChild(E("a",{class:"mobile-nav-logout",href:e.href},[e.title])):t.appendChild(this.renderNavigationItem(e,"mobile"))}),this.bindNavigationAccordion(t))},render(e){if(this.renderModeMenu(e),L.env.dispatchpath.length>=3){let t=e,i="";for(let e=0;e<3&&t;e++){const a=L.env.dispatchpath[e];t=t.children?.[a],i+=(i?"/":"")+a}t&&this.renderTabMenu(t,i)}},renderTabMenu(e,t,i=0){const a=document.querySelector("#tabmenu"),n=E("ul",{class:"tabs"}),s=ui.menu.getChildren(e);let o=null;return s.forEach(e=>{const a=L.env.dispatchpath[3+i]===e.name;n.appendChild(E("li",{class:`tabmenu-item-${e.name}${a?" active":""}`},[E("a",{href:L.url(t,e.name)},[_(e.title)])])),a&&(o=e)}),n.children.length?(a.appendChild(n),a.style.display="",o&&this.renderTabMenu(o,`${t}/${o.name}`,i+1),n):E([])},renderMainMenu(e,t,i=0,a=null){const n=i?E("ul",{class:"desktop-nav-list"}):document.querySelector("#topmenu"),s=ui.menu.getChildren(e);if(i>1)return E([]);if(0===i){const e=document.body?.dataset?.navType||"mega-menu";if("sidebar"===e)return this.renderSidebar(a||[]),n||E([]);if(!n||!s.length)return E([]);"mega-menu"===e?this.initMegaMenu(s,t,n):this.initDropdown(s,t,n)}else{if(!s.length)return E([]);s.forEach(i=>{const a=this.isActivePath(e.name,i.name),s={class:a?"is-active-page":"",href:L.url(t,i.name)};a&&(s["aria-current"]="page"),n.appendChild(E("li",{},[E("a",s,[_(i.title)])]))})}return n.style.display="",n},isActivePath:(e,t)=>L.env.dispatchpath[1]===e&&(null==t||L.env.dispatchpath[2]===t),buildNavigationModel(e,t){return e.filter(e=>e?.name).map(e=>{const i=ui.menu.getChildren(e).filter(e=>e?.name).map(i=>({name:i.name,title:_(i.title),href:L.url(t,e.name,i.name),isActivePage:this.isActivePath(e.name,i.name)})),a=i.length>0,n=this.isActivePath(e.name);return{name:e.name,title:_(e.title),href:L.url(t,e.name),hasChildren:a,isLogout:"logout"===e.name,isActiveGroup:a&&n,isActivePage:!a&&n,activePage:i.find(e=>e.isActivePage)||null,pages:i}})},navigationSubmenuId:(e,t)=>`${e}-submenu-${encodeURIComponent(String(t))}`,renderNavigationItem(e,t){const i="mobile"===t,a=i?"mobile-nav-item":"",n=i?"navigation-direct mobile-nav-link":"navigation-direct nav-link";if(!e.hasChildren){const t={class:`${n}${e.isActivePage?" is-active-page":""}`,href:e.href};return e.isActivePage&&(t["aria-current"]="page"),E("li",{class:a},[E("a",t,[e.title])])}const s=this.navigationSubmenLine truncated
"use strict";"require baseclass";"require ui";return baseclass.extend({__init__(){ui.menu.load().then(e=>this.render(e)),this.initNavigationControls(),this.initUciIndicator()},initUciIndicator(){const e=ui.changes?.setIndicator;e&&(ui.changes.setIndicator=function(t){e.call(this,t),document.querySelector('[data-indicator="uci-changes"]')?.setAttribute("data-count",t||0)})},initNavigationControls(){const e=document.querySelector("#mobile-menu-overlay"),t=document.querySelector("#navigation-toggle");if(!t||!e)return;const a=e.querySelector("#mobile-nav-list"),i=window.matchMedia("(min-width: 768px)"),n="aurora.sidebarCollapsed";let s;const o=()=>i.matches&&"sidebar"===document.body.dataset.navType,l=e=>{const a=o();t.classList.toggle("is-expanded",e&&!i.matches),t.setAttribute("aria-expanded",e?"true":"false"),t.setAttribute("aria-controls",a?"sidebar-panel":"mobile-menu-overlay");const n=e&&!a?_("Close"):_("Navigation");t.setAttribute("title",n),t.setAttribute("aria-label",n)},r=()=>{e.classList.remove("mobile-menu-open"),document.body.classList.remove("mobile-navigation-open"),document.body.style.overflow="",this.resetNavigationGroups(a)},c=t=>{if(o()){r();const e=!t,a=document.body;return a.classList.remove("sidebar-anim-open","sidebar-anim-close"),a.classList.add(e?"sidebar-anim-close":"sidebar-anim-open"),clearTimeout(s),s=setTimeout(()=>{a.classList.remove("sidebar-anim-open","sidebar-anim-close")},300),a.classList.toggle("sidebar-collapsed",e),localStorage.setItem(n,e),void l(t)}if(i.matches)return r(),void l(!1);e.classList.toggle("mobile-menu-open",t),document.body.classList.toggle("mobile-navigation-open",t),document.body.style.overflow=t?"hidden":"",t?this.expandActiveNavigationGroup(a):this.resetNavigationGroups(a),l(t)},d=()=>{if(r(),o()){const e="true"===localStorage.getItem(n);return document.body.classList.toggle("sidebar-collapsed",e),void l(!e)}l(!1)};t.addEventListener("click",t=>{t.stopPropagation(),c(!(o()?!document.body.classList.contains("sidebar-collapsed"):!i.matches&&e.classList.contains("mobile-menu-open")))}),e.addEventListener("click",t=>{t.target===e&&c(!1)}),document.addEventListener("keydown",t=>{"Escape"===t.key&&e.classList.contains("mobile-menu-open")&&c(!1)}),i.addEventListener("change",d),d(),document.addEventListener("click",t=>{const a=t.target?.closest?.(".navigation-direct, .navigation-sublink, .mobile-nav-logout");a&&e.contains(a)&&c(!1)})},renderMobileMenu(e){const t=document.querySelector("#mobile-nav-list"),a=document.querySelector("#mobile-nav-footer-action");t&&(t.innerHTML=""),a&&(a.innerHTML=""),t&&(e.forEach(e=>{e.isLogout?a&&a.appendChild(E("a",{class:"mobile-nav-logout",href:e.href},[e.title])):t.appendChild(this.renderNavigationItem(e,"mobile"))}),this.bindNavigationAccordion(t))},render(e){if(this.renderModeMenu(e),L.env.dispatchpath.length>=3){let t=e,a="";for(let e=0;e<3&&t;e++){const i=L.env.dispatchpath[e];t=t.children?.[i],a+=(a?"/":"")+i}t&&this.renderTabMenu(t,a)}},renderTabMenu(e,t,a=0){const i=document.querySelector("#tabmenu"),n=E("ul",{class:"tabs"}),s=ui.menu.getChildren(e);let o=null;return s.forEach(e=>{const i=L.env.dispatchpath[3+a]===e.name;n.appendChild(E("li",{class:`tabmenu-item-${e.name}${i?" active":""}`},[E("a",{href:L.url(t,e.name)},[_(e.title)])])),i&&(o=e)}),n.children.length?(i.appendChild(n),i.style.display="",o&&this.renderTabMenu(o,`${t}/${o.name}`,a+1),n):E([])},renderMainMenu(e,t,a=0,i=null){const n=a?E("ul",{class:"desktop-nav-list"}):document.querySelector("#topmenu"),s=ui.menu.getChildren(e);if(a>1)return E([]);if(0===a){const e=document.body?.dataset?.navType||"mega-menu";if("sidebar"===e)return this.renderSidebar(i||[]),n||E([]);if(!n||!s.length)return E([]);"mega-menu"===e?this.initMegaMenu(s,t,n):this.initDropdown(s,t,n)}else{if(!s.length)return E([]);s.forEach(a=>{const i=this.isActivePath(e.name,a.name),s={class:i?"is-active-page":"",href:L.url(t,a.name)};i&&(s["aria-current"]="page"),n.appendChild(E("li",{},[E("a",s,[_(a.title)])]))})}return n.style.display="",n},isActivePath:(e,t)=>L.env.dispatchpath[1]===e&&(null==t||L.env.dispatchpath[2]===t),buildNavigationModel(e,t){return e.filter(e=>e?.name).map(e=>{const a=ui.menu.getChildren(e).filter(e=>e?.name).map(a=>({name:a.name,title:_(a.title),href:L.url(t,e.name,a.name),isActivePage:this.isActivePath(e.name,a.name)})),i=a.length>0,n=this.isActivePath(e.name);return{name:e.name,title:_(e.title),href:L.url(t,e.name),hasChildren:i,isLogout:"logout"===e.name,isActiveGroup:i&&n,isActivePage:!i&&n,activePage:a.find(e=>e.isActivePage)||null,pages:a}})},navigationSubmenuId:(e,t)=>`${e}-submenu-${encodeURIComponent(String(t))}`,renderNavigationItem(e,t){const a="mobile"===t,i=a?"mobile-nav-item":"",n=a?"navigation-direct mobile-nav-link":"navigation-direct nav-link";if(!e.hasChildren){const t={class:`${n}${e.isActivePage?" is-active-page":""}`,href:e.href};return e.isActivePage&&(t["aria-current"]="page"),t["data-section"]=e.name,E("li",{class:i},[E("a",t,[E("span",{class:Line truncated
+2 -2
View File
@@ -10,12 +10,12 @@ include $(INCLUDE_DIR)/kernel.mk
PKG_NAME:=natflow
PKG_VERSION:=20260531
PKG_RELEASE:=57
PKG_RELEASE:=58
PKG_SOURCE:=$(PKG_VERSION).tar.xz
PKG_SOURCE_URL:=https://github.com/ptpt52/natflow.git
PKG_SOURCE_PROTO:=git
PKG_SOURCE_VERSION:=6161bacd30128d2ea0bae5c05290bd46e6d3606d
PKG_SOURCE_VERSION:=6ffd9078fe2c2946112a05a80a9c8a496250d98c
PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION)
PKG_MAINTAINER:=Chen Minqiang <ptpt52@gmail.com>
PKG_LICENSE:=GPL-2.0