mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-30 08:25:13 +08:00
🎨 Sync 2026-08-20 20:39:01
This commit is contained in:
1 parent
9022c43ed2
commit
289ca3521f
14 files changed
+465
-49
No files matched your search
@@ -12,7 +12,7 @@ PKG_ARCH_ISTOREENHANCE:=$(ARCH)
|
||||
PKG_NAME:=istoreenhance
|
||||
# use PKG_SOURCE_DATE instead of PKG_VERSION for compitable
|
||||
PKG_SOURCE_DATE:=0.7.15
|
||||
PKG_RELEASE:=4
|
||||
PKG_RELEASE:=5
|
||||
ARCH_HEXCODE:=
|
||||
ifeq ($(ARCH),x86_64)
|
||||
ARCH_HEXCODE=8664
|
||||
@@ -25,7 +25,7 @@ ARCH_HEXCODE=1b0c
|
||||
endif
|
||||
PKG_SOURCE_VERSION:=c74312de1d50b9de65d0044fcf7c4d88a1f9917c
|
||||
PKG_SOURCE:=iStoreEnhance-binary-$(PKG_SOURCE_DATE).tar.gz
|
||||
PKG_SOURCE_URL:=https://github.com/kspeeder/docker_kspeeder/releases/download/v0.7.15/
|
||||
PKG_SOURCE_URL:=https://github.com/kspeeder/docker_kspeeder/releases/download/v$(PKG_SOURCE_DATE)/
|
||||
PKG_HASH:=skip
|
||||
|
||||
PKG_BUILD_DIR:=$(BUILD_DIR)/iStoreEnhance-binary-$(PKG_SOURCE_DATE)
|
||||
|
||||
@@ -12,7 +12,7 @@ PKG_ARCH_LINKEASE:=$(ARCH)
|
||||
PKG_NAME:=linkease-common-bin
|
||||
# use PKG_SOURCE_DATE instead of PKG_VERSION for compitable
|
||||
PKG_SOURCE_DATE:=1.7.5
|
||||
PKG_RELEASE:=9
|
||||
PKG_RELEASE:=10
|
||||
|
||||
ARCH_HEXCODE:=
|
||||
ifeq ($(ARCH),x86_64)
|
||||
@@ -28,7 +28,7 @@ else ifeq ($(ARCH),mipsel)
|
||||
ARCH_HEXCODE=1b0c
|
||||
endif
|
||||
|
||||
PKG_SOURCE_VERSION:=1d2184f103526f9427259c2aba880486cbd930fd
|
||||
PKG_SOURCE_VERSION:=2f7e306c71c36126c320b4e2f0130fda4f3c15bf
|
||||
PKG_SOURCE:=linkease-common-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE).tar.gz
|
||||
PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/linkease-runtime-v$(PKG_SOURCE_DATE)/
|
||||
PKG_BUILD_DIR:=$(BUILD_DIR)/linkease-common-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE)
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@ PKG_ARCH_LINKEASE:=$(ARCH)
|
||||
PKG_NAME:=linkease
|
||||
# use PKG_SOURCE_DATE instead of PKG_VERSION for compitable
|
||||
PKG_SOURCE_DATE:=1.7.5
|
||||
PKG_RELEASE:=12
|
||||
PKG_RELEASE:=13
|
||||
|
||||
ARCH_HEXCODE:=
|
||||
ifeq ($(ARCH),x86_64)
|
||||
@@ -28,7 +28,7 @@ else ifeq ($(ARCH),mipsel)
|
||||
ARCH_HEXCODE=1b0c
|
||||
endif
|
||||
|
||||
PKG_SOURCE_VERSION:=1d2184f103526f9427259c2aba880486cbd930fd
|
||||
PKG_SOURCE_VERSION:=2f7e306c71c36126c320b4e2f0130fda4f3c15bf
|
||||
PKG_SOURCE:=linkease-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE).tar.gz
|
||||
PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/linkease-runtime-v$(PKG_SOURCE_DATE)/
|
||||
PKG_BUILD_DIR:=$(BUILD_DIR)/linkease-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE)
|
||||
|
||||
@@ -11,7 +11,7 @@ PKG_ARCH_LINKEASE:=$(ARCH)
|
||||
PKG_NAME:=linkeasefull
|
||||
# use PKG_SOURCE_DATE instead of PKG_VERSION for compitable
|
||||
PKG_SOURCE_DATE:=3.0.11
|
||||
PKG_RELEASE:=11
|
||||
PKG_RELEASE:=12
|
||||
|
||||
ARCH_HEXCODE:=
|
||||
ifeq ($(ARCH),x86_64)
|
||||
@@ -24,7 +24,7 @@ LINKEASE_RUNTIME_ARCH:=arm64
|
||||
PKG_HASH:=skip
|
||||
endif
|
||||
|
||||
PKG_SOURCE_VERSION:=1d2184f103526f9427259c2aba880486cbd930fd
|
||||
PKG_SOURCE_VERSION:=2f7e306c71c36126c320b4e2f0130fda4f3c15bf
|
||||
PKG_SOURCE:=linkease-runtime-$(PKG_SOURCE_DATE)-linux-$(LINKEASE_RUNTIME_ARCH).tar.gz
|
||||
PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/linkeasefull-runtime-v$(PKG_SOURCE_DATE)/
|
||||
PKG_BUILD_DIR:=$(BUILD_DIR)/linkease-runtime-$(PKG_SOURCE_DATE)-linux-$(LINKEASE_RUNTIME_ARCH)
|
||||
|
||||
@@ -240,11 +240,21 @@ return view.extend({
|
||||
return [ fam, pr ].filter(x => x).join(' · ') || '–';
|
||||
};
|
||||
|
||||
/* Sort the whole range here, not just the page: ui.Table only ever
|
||||
* orders the rows it was handed, so paging first would turn the
|
||||
* headers into a per-page sort while the count below still speaks
|
||||
* for every measurement. */
|
||||
let rows = entries.slice().reverse();
|
||||
/* Build the cells first, then sort them: the sort key comes out of a
|
||||
* cell, and ui.Table only ever orders the rows it was handed, so
|
||||
* paging first would turn the headers into a per-page sort while the
|
||||
* count below still speaks for every measurement. */
|
||||
let rows = entries.slice().reverse().map(e => [
|
||||
[ e.epoch ?? 0, when(e) ],
|
||||
(e.server && e.server.name) || '–',
|
||||
e.interface || '–',
|
||||
protoCell(e),
|
||||
[ num(e.download_mbps), fmtNum(e.download_mbps, 2) ],
|
||||
[ num(e.upload_mbps), fmtNum(e.upload_mbps, 2) ],
|
||||
[ num(e.ping_ms), fmtNum(e.ping_ms, 1) ],
|
||||
[ num(e.jitter_ms), fmtNum(e.jitter_ms, 1) ]
|
||||
]);
|
||||
|
||||
const sorting = this.table.getActiveSortState();
|
||||
|
||||
if (sorting)
|
||||
@@ -263,17 +273,8 @@ return view.extend({
|
||||
this.page = pages - 1;
|
||||
|
||||
this.renderPager(rows.length, pages);
|
||||
this.table.update(rows.slice(this.page * pageSize, (this.page + 1) * pageSize));
|
||||
|
||||
this.table.update(rows.slice(this.page * pageSize, (this.page + 1) * pageSize).map(e => [
|
||||
[ e.epoch ?? 0, when(e) ],
|
||||
(e.server && e.server.name) || '–',
|
||||
e.interface || '–',
|
||||
protoCell(e),
|
||||
[ num(e.download_mbps), fmtNum(e.download_mbps, 2) ],
|
||||
[ num(e.upload_mbps), fmtNum(e.upload_mbps, 2) ],
|
||||
[ num(e.ping_ms), fmtNum(e.ping_ms, 1) ],
|
||||
[ num(e.jitter_ms), fmtNum(e.jitter_ms, 1) ]
|
||||
]));
|
||||
},
|
||||
|
||||
/* Shown only when there is more than one page; the count is always
|
||||
|
||||
@@ -13,10 +13,10 @@ address, so they stay local).
|
||||
|---|---|---|---|
|
||||
| main.css (identity/raw) | ≤ 193 KB | size | production build, 2026-08 (191,899 B; +~2 KB custom-background mode, +~0.5 KB router progress bar / live region; the bar is now Turbo-shaped — width/opacity only, inserted per navigation) |
|
||||
| login.css (identity/raw) | ≤ 12 KB | size | production build, 2026-07 (10,935 B, token-pruned) |
|
||||
| menu-aurora.js (identity/raw) | ≤ 21.5 KB | size | production build, 2026-08 (20,851 B; +1.3 KB for the router's `syncRoute`/`closeSurfaces` hooks) |
|
||||
| menu-aurora.js (identity/raw) | ≤ 22 KB | size | production build, 2026-08 (21,809 B; +0.8 KB palette recents: record on pick, pure-LRU browse order, storage validation; +0.2 KB ">" logout command) |
|
||||
| router-aurora.js (identity/raw, Navigation-API browsers only) | ≤ 15 KB | size | production build, 2026-08 (14,844 B: template shells fetched, per-render listener teardown, timeout-as-failure, expiry gate, readonly folding, node css, wildcard actions, trickling progress bar, same-URL reload rule, visibility gate, contract check) |
|
||||
| Default logo (identity/raw) | ≤ 16 KB | size | production build, 2026-07 (15,057 B) |
|
||||
| Core admin cold theme assets (identity/raw) | ≤ 267 KB | size | main CSS + menu JS + router JS + default font + logo, 2026-08 (≈266.3 KB; the router is a one-time cost that removes per-click dispatcher work) |
|
||||
| Core admin cold theme assets (identity/raw) | ≤ 267.5 KB | size | main CSS + menu JS + router JS + default font + logo, 2026-08 (≈267.2 KB; the router is a one-time cost that removes per-click dispatcher work; +1.0 KB palette recents and logout command) |
|
||||
| Login cold theme assets, excluding configured background (identity/raw) | ≤ 55 KB | size | login CSS + default font + logo, 2026-07 (49,572 B) |
|
||||
| Blocking requests before first paint | ≤ 4 | count | current waterfall |
|
||||
| Repeat-visit asset requests | ≈ 0 | count | target state; package-built CSS/JS URLs are versioned, but long-lived cache headers still need live verification |
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
"require baseclass";
|
||||
"require ui";
|
||||
|
||||
const PALETTE_RECENTS_KEY = "aurora.paletteRecents";
|
||||
|
||||
return baseclass.extend({
|
||||
__init__() {
|
||||
ui.menu.load().then((tree) => this.render(tree));
|
||||
@@ -599,8 +601,12 @@ return baseclass.extend({
|
||||
if (!toggle || this.paletteIndex) return;
|
||||
|
||||
this.paletteIndex = [];
|
||||
let logout = null;
|
||||
items.forEach((item) => {
|
||||
if (item.isLogout) return;
|
||||
if (item.isLogout) {
|
||||
logout = item;
|
||||
return;
|
||||
}
|
||||
if (!item.hasChildren) {
|
||||
this.paletteIndex.push({
|
||||
title: item.title,
|
||||
@@ -645,6 +651,18 @@ return baseclass.extend({
|
||||
}),
|
||||
);
|
||||
|
||||
// Logout is a command, not a destination: it rides ">" only (last, after
|
||||
// the modes), stays out of the plain browse list, and is never recorded
|
||||
// as a recent — but a typed query still reaches it.
|
||||
if (logout)
|
||||
this.paletteIndex.push({
|
||||
title: logout.title,
|
||||
name: logout.name,
|
||||
group: null,
|
||||
href: logout.href,
|
||||
isLogout: true,
|
||||
});
|
||||
|
||||
// Only msgids that already exist in a shipped catalog (luci-base, or the
|
||||
// config app's as above) are used — the theme intentionally ships no
|
||||
// translations of its own.
|
||||
@@ -785,8 +803,15 @@ return baseclass.extend({
|
||||
// the clicked anchor is replaced by the re-render, which would strand
|
||||
// focus on <body> with the dialog still open.
|
||||
results.addEventListener("click", (e) => {
|
||||
const mode = e.target?.closest?.(".cmdk-row")?.dataset.mode;
|
||||
if (!mode) return;
|
||||
const row = e.target?.closest?.(".cmdk-row");
|
||||
if (!row) return;
|
||||
const mode = row.dataset.mode;
|
||||
if (!mode) {
|
||||
// Navigation rows: remember the pick, then let the click navigate
|
||||
// (same-document router or full load) untouched.
|
||||
if (row.dataset.name) this.recordPaletteRecent(row.dataset.name);
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
setTheme(mode);
|
||||
syncSwitchers();
|
||||
@@ -876,6 +901,46 @@ return baseclass.extend({
|
||||
|
||||
// Empty query matches everything at score 0 (the browse list); title hits
|
||||
// outrank name/path and group hits and are the only ones highlighted.
|
||||
// localStorage can be unavailable (privacy modes) or hold anything after
|
||||
// a downgrade — both read as "no history". No size cap: dedupe bounds the
|
||||
// list by the pages actually visited, i.e. the menu's own scale.
|
||||
readPaletteRecents() {
|
||||
try {
|
||||
const list = JSON.parse(localStorage.getItem(PALETTE_RECENTS_KEY));
|
||||
return Array.isArray(list)
|
||||
? list.filter((name) => typeof name === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
recordPaletteRecent(name) {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
PALETTE_RECENTS_KEY,
|
||||
JSON.stringify([
|
||||
name,
|
||||
...this.readPaletteRecents().filter((kept) => kept !== name),
|
||||
]),
|
||||
);
|
||||
} catch {
|
||||
// History is decorative; a sealed or full store just means none.
|
||||
}
|
||||
},
|
||||
|
||||
// Pure LRU: every visited page floats, so long use converges on a fully
|
||||
// most-recently-used browse order (Sublime's model). Names that vanished
|
||||
// from the menu (ACL change, package removed) drop out here; command rows
|
||||
// never float, whatever the store says.
|
||||
paletteBrowseRecents() {
|
||||
return this.readPaletteRecents().filter((name) =>
|
||||
this.paletteIndex.some(
|
||||
(page) => page.name === name && !page.mode && !page.isLogout,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
matchPaletteEntry(q, page) {
|
||||
if (!q) return { score: 0, ranges: null };
|
||||
|
||||
@@ -888,23 +953,37 @@ return baseclass.extend({
|
||||
return rest && { score: rest.score, ranges: null };
|
||||
},
|
||||
|
||||
renderPaletteResults(value) {
|
||||
collectPaletteMatches(value) {
|
||||
let q = value.trim().toLowerCase();
|
||||
// A leading ">" scopes the list to the command rows (the prototype's
|
||||
// command-only mode); ">" covers CJK IMEs emitting the full-width form.
|
||||
const cmdOnly = q[0] === ">" || q[0] === ">";
|
||||
if (cmdOnly) q = q.slice(1).trim();
|
||||
const theme = localStorage.getItem("aurora.theme") || "device";
|
||||
// Browsing floats the recently opened pages; recency rides the same
|
||||
// stable sort as fuzzy rank, so ties keep menu order and typed queries
|
||||
// (which leave recents empty) stay pure fuzzy.
|
||||
const recents = q || cmdOnly ? [] : this.paletteBrowseRecents();
|
||||
const matches = [];
|
||||
|
||||
for (const page of this.paletteIndex) {
|
||||
if (cmdOnly && !page.mode) continue;
|
||||
if (cmdOnly && !page.mode && !page.isLogout) continue;
|
||||
if (page.isLogout && !cmdOnly && !q) continue;
|
||||
const m = this.matchPaletteEntry(q, page);
|
||||
if (m) matches.push({ page, score: m.score, ranges: m.ranges });
|
||||
if (!m) continue;
|
||||
const at = recents.indexOf(page.name);
|
||||
matches.push({
|
||||
page,
|
||||
score: at < 0 ? m.score : recents.length - at,
|
||||
ranges: m.ranges,
|
||||
});
|
||||
}
|
||||
// sort() is spec-stable: equal scores keep menu order (and the browse
|
||||
// list stays in menu order by skipping the sort entirely).
|
||||
if (q) matches.sort((a, b) => b.score - a.score);
|
||||
if (q || recents.length) matches.sort((a, b) => b.score - a.score);
|
||||
return matches;
|
||||
},
|
||||
|
||||
renderPaletteResults(value) {
|
||||
const matches = this.collectPaletteMatches(value);
|
||||
const theme = localStorage.getItem("aurora.theme") || "device";
|
||||
|
||||
this.paletteInput.removeAttribute("aria-activedescendant");
|
||||
this.paletteList.replaceChildren();
|
||||
@@ -926,6 +1005,7 @@ return baseclass.extend({
|
||||
href: page.href || "#",
|
||||
};
|
||||
if (page.mode) attributes["data-mode"] = page.mode;
|
||||
else if (!page.isLogout) attributes["data-name"] = page.name;
|
||||
if (current) attributes["aria-current"] = "true";
|
||||
|
||||
this.paletteList.appendChild(
|
||||
|
||||
@@ -23,7 +23,10 @@ test("production assets stay within raw-transfer budgets", () => {
|
||||
// binds.
|
||||
assert.ok(main <= 193_000, "main.css exceeds 193 KB");
|
||||
assert.ok(login <= 12_000, "login.css exceeds 12 KB");
|
||||
assert.ok(menu <= 21_500, "menu-aurora.js exceeds 21.5 KB");
|
||||
// 22K: palette recents (record on pick, pure-LRU browse order, storage
|
||||
// validation) added ~0.8 KB and the ">" logout command ~0.2 KB. The total
|
||||
// budget below moved by the same amount.
|
||||
assert.ok(menu <= 22_000, "menu-aurora.js exceeds 22 KB");
|
||||
// 15K: the expiry gate, readonly folding, menu.d node css, wildcard
|
||||
// actions, progress bar, visibility gate and contract check added ~3 KB
|
||||
// over the first cut; the total-transfer budget below moved by the same
|
||||
@@ -31,8 +34,8 @@ test("production assets stay within raw-transfer budgets", () => {
|
||||
assert.ok(router <= 15_000, "router-aurora.js exceeds 15 KB");
|
||||
assert.ok(logo <= 16_000, "logo.svg exceeds 16 KB");
|
||||
assert.ok(
|
||||
main + menu + router + font + logo <= 267_000,
|
||||
"admin assets exceed 267 KB",
|
||||
main + menu + router + font + logo <= 267_500,
|
||||
"admin assets exceed 267.5 KB",
|
||||
);
|
||||
assert.ok(login + font + logo <= 55_000, "login assets exceed 55 KB");
|
||||
});
|
||||
|
||||
@@ -13,7 +13,10 @@ const E = (tagName, attributes, children) => ({
|
||||
children,
|
||||
});
|
||||
|
||||
const loadMenuModule = () => {
|
||||
const loadMenuModule = (
|
||||
localStorage = {},
|
||||
{ document = {}, navigator = { platform: "" } } = {},
|
||||
) => {
|
||||
const baseclass = {
|
||||
extend(module) {
|
||||
return module;
|
||||
@@ -34,8 +37,9 @@ const loadMenuModule = () => {
|
||||
"document",
|
||||
"window",
|
||||
"localStorage",
|
||||
"navigator",
|
||||
source,
|
||||
)(baseclass, ui, E, L, (value) => value, {}, {}, {});
|
||||
)(baseclass, ui, E, L, (value) => value, document, {}, localStorage, navigator);
|
||||
};
|
||||
|
||||
const menu = loadMenuModule();
|
||||
@@ -174,3 +178,287 @@ test("skips highlighting when case folding would skew the offsets", () => {
|
||||
test("renders the plain title when there are no ranges", () => {
|
||||
assert.deepEqual(menu.highlightPaletteMatch("Status", null), ["Status"]);
|
||||
});
|
||||
|
||||
// ---- Recents: recording, storage hygiene, and browse-order floating ----
|
||||
|
||||
const RECENTS_KEY = "aurora.paletteRecents";
|
||||
|
||||
const fakeStorage = (initial = {}) => {
|
||||
const map = new Map(Object.entries(initial));
|
||||
return {
|
||||
getItem: (key) => (map.has(key) ? map.get(key) : null),
|
||||
setItem: (key, value) => map.set(key, String(value)),
|
||||
map,
|
||||
};
|
||||
};
|
||||
|
||||
const navPage = (name, title, group = "Group") => ({
|
||||
title,
|
||||
name,
|
||||
group,
|
||||
href: `/${name}`,
|
||||
});
|
||||
|
||||
const paletteWith = (storage, index) => {
|
||||
const module = loadMenuModule(storage);
|
||||
module.paletteIndex = index;
|
||||
return module;
|
||||
};
|
||||
|
||||
const browseIndex = () => [
|
||||
navPage("status/overview", "概览", "状态"),
|
||||
navPage("status/syslog", "系统日志", "状态"),
|
||||
navPage("network/iface", "接口", "网络"),
|
||||
navPage("network/firewall", "防火墙", "网络"),
|
||||
{ title: "浅色模式", name: "theme light", group: "主题", mode: "light" },
|
||||
];
|
||||
|
||||
const stored = (recents) => ({ [RECENTS_KEY]: JSON.stringify(recents) });
|
||||
|
||||
const names = (matches) => matches.map((match) => match.page.name);
|
||||
|
||||
test("a recorded selection lands at the head of the stored recents", () => {
|
||||
const storage = fakeStorage();
|
||||
const palette = paletteWith(storage, browseIndex());
|
||||
|
||||
palette.recordPaletteRecent("status/syslog");
|
||||
palette.recordPaletteRecent("network/iface");
|
||||
|
||||
assert.deepEqual(JSON.parse(storage.map.get(RECENTS_KEY)), [
|
||||
"network/iface",
|
||||
"status/syslog",
|
||||
]);
|
||||
});
|
||||
|
||||
test("re-recording an entry moves it up instead of duplicating it", () => {
|
||||
const storage = fakeStorage(stored(["network/iface", "status/syslog"]));
|
||||
const palette = paletteWith(storage, browseIndex());
|
||||
|
||||
palette.recordPaletteRecent("status/syslog");
|
||||
|
||||
assert.deepEqual(JSON.parse(storage.map.get(RECENTS_KEY)), [
|
||||
"status/syslog",
|
||||
"network/iface",
|
||||
]);
|
||||
});
|
||||
|
||||
test("the store has no artificial cap — dedupe is the only bound", () => {
|
||||
const storage = fakeStorage(stored(["h", "g", "f", "e", "d", "c", "b", "a"]));
|
||||
const palette = paletteWith(storage, browseIndex());
|
||||
|
||||
palette.recordPaletteRecent("i");
|
||||
|
||||
assert.deepEqual(JSON.parse(storage.map.get(RECENTS_KEY)), [
|
||||
"i",
|
||||
"h",
|
||||
"g",
|
||||
"f",
|
||||
"e",
|
||||
"d",
|
||||
"c",
|
||||
"b",
|
||||
"a",
|
||||
]);
|
||||
});
|
||||
|
||||
test("corrupt or foreign stored values read as no history", () => {
|
||||
for (const value of ["not json", '"just a string"', "[1,2,3]", "{}"]) {
|
||||
const palette = paletteWith(
|
||||
fakeStorage({ [RECENTS_KEY]: value }),
|
||||
browseIndex(),
|
||||
);
|
||||
assert.deepEqual(palette.readPaletteRecents(), [], value);
|
||||
}
|
||||
});
|
||||
|
||||
test("a throwing storage neither breaks reads nor records", () => {
|
||||
const hostile = {
|
||||
getItem: () => {
|
||||
throw new Error("privacy mode");
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error("quota");
|
||||
},
|
||||
};
|
||||
const palette = paletteWith(hostile, browseIndex());
|
||||
|
||||
assert.deepEqual(palette.readPaletteRecents(), []);
|
||||
assert.doesNotThrow(() => palette.recordPaletteRecent("status/overview"));
|
||||
});
|
||||
|
||||
test("browsing floats stored recents in recency order, menu order behind", () => {
|
||||
const palette = paletteWith(
|
||||
fakeStorage(stored(["network/iface", "status/syslog"])),
|
||||
browseIndex(),
|
||||
);
|
||||
|
||||
assert.deepEqual(names(palette.collectPaletteMatches("")), [
|
||||
"network/iface",
|
||||
"status/syslog",
|
||||
"status/overview",
|
||||
"network/firewall",
|
||||
"theme light",
|
||||
]);
|
||||
});
|
||||
|
||||
test("every visited page floats in recency order; vanished names drop", () => {
|
||||
const wide = [
|
||||
navPage("p1", "One"),
|
||||
navPage("p2", "Two"),
|
||||
navPage("p3", "Three"),
|
||||
navPage("p4", "Four"),
|
||||
navPage("p5", "Five"),
|
||||
navPage("p6", "Six"),
|
||||
navPage("p7", "Seven"),
|
||||
];
|
||||
const palette = paletteWith(
|
||||
fakeStorage(stored(["removed/page", "p7", "p6", "p5", "p4", "p3", "p2"])),
|
||||
wide,
|
||||
);
|
||||
|
||||
// Pure LRU: all six visited pages float, only p1 keeps its menu slot.
|
||||
assert.deepEqual(names(palette.collectPaletteMatches("")), [
|
||||
"p7",
|
||||
"p6",
|
||||
"p5",
|
||||
"p4",
|
||||
"p3",
|
||||
"p2",
|
||||
"p1",
|
||||
]);
|
||||
});
|
||||
|
||||
test("a stored name never floats a command row", () => {
|
||||
const palette = paletteWith(
|
||||
fakeStorage(stored(["theme light"])),
|
||||
browseIndex(),
|
||||
);
|
||||
|
||||
assert.deepEqual(names(palette.collectPaletteMatches("")), [
|
||||
"status/overview",
|
||||
"status/syslog",
|
||||
"network/iface",
|
||||
"network/firewall",
|
||||
"theme light",
|
||||
]);
|
||||
});
|
||||
|
||||
test("typed queries rank by fuzzy score alone", () => {
|
||||
const palette = paletteWith(
|
||||
fakeStorage(stored(["diag"])),
|
||||
[navPage("dns", "DNS Settings"), navPage("diag", "Diagnostics")],
|
||||
);
|
||||
|
||||
// "Diagnostics" is the recent entry, but the tight title hit must win.
|
||||
assert.deepEqual(names(palette.collectPaletteMatches("dns")), [
|
||||
"dns",
|
||||
"diag",
|
||||
]);
|
||||
});
|
||||
|
||||
test("'>' browsing lists command rows only, unaffected by recents", () => {
|
||||
const palette = paletteWith(
|
||||
fakeStorage(stored(["network/iface"])),
|
||||
browseIndex(),
|
||||
);
|
||||
|
||||
assert.deepEqual(names(palette.collectPaletteMatches(">")), ["theme light"]);
|
||||
});
|
||||
|
||||
// ---- Logout as a ">"-only command ----
|
||||
|
||||
const logoutIndex = () => [
|
||||
navPage("p1", "Alpha"),
|
||||
navPage("p2", "Beta"),
|
||||
navPage("p3", "Gamma"),
|
||||
navPage("p4", "Delta"),
|
||||
navPage("p5", "Epsilon"),
|
||||
navPage("p6", "Zeta"),
|
||||
{ title: "浅色模式", name: "theme light", group: "主题", mode: "light" },
|
||||
{ title: "Logout", name: "logout", group: null, href: "/logout", isLogout: true },
|
||||
];
|
||||
|
||||
test("'>' browsing includes the logout command", () => {
|
||||
const palette = paletteWith(fakeStorage(), logoutIndex());
|
||||
|
||||
assert.deepEqual(names(palette.collectPaletteMatches(">")), [
|
||||
"theme light",
|
||||
"logout",
|
||||
]);
|
||||
});
|
||||
|
||||
test("plain browsing hides the logout command", () => {
|
||||
const palette = paletteWith(fakeStorage(), logoutIndex());
|
||||
|
||||
assert.deepEqual(names(palette.collectPaletteMatches("")), [
|
||||
"p1",
|
||||
"p2",
|
||||
"p3",
|
||||
"p4",
|
||||
"p5",
|
||||
"p6",
|
||||
"theme light",
|
||||
]);
|
||||
});
|
||||
|
||||
test("typed queries still reach logout", () => {
|
||||
const palette = paletteWith(fakeStorage(), logoutIndex());
|
||||
|
||||
assert.deepEqual(names(palette.collectPaletteMatches("logout")), ["logout"]);
|
||||
});
|
||||
|
||||
test("a stored logout name never floats", () => {
|
||||
const palette = paletteWith(
|
||||
fakeStorage(stored(["logout", "p6", "p5", "p4", "p3", "p2"])),
|
||||
logoutIndex(),
|
||||
);
|
||||
|
||||
assert.deepEqual(names(palette.collectPaletteMatches("")), [
|
||||
"p6",
|
||||
"p5",
|
||||
"p4",
|
||||
"p3",
|
||||
"p2",
|
||||
"p1",
|
||||
"theme light",
|
||||
]);
|
||||
});
|
||||
|
||||
test("initPalette appends logout as the last command row", () => {
|
||||
const toggle = {
|
||||
setAttribute() {},
|
||||
addEventListener() {},
|
||||
};
|
||||
const doc = {
|
||||
querySelector: (sel) => (sel === "#cmdk-trigger" ? toggle : null),
|
||||
addEventListener() {},
|
||||
};
|
||||
const palette = loadMenuModule(fakeStorage(), { document: doc });
|
||||
|
||||
palette.initPalette([
|
||||
{
|
||||
name: "status",
|
||||
title: "Status",
|
||||
hasChildren: true,
|
||||
pages: [{ name: "overview", title: "Overview", href: "/overview" }],
|
||||
},
|
||||
{
|
||||
name: "logout",
|
||||
title: "Logout",
|
||||
href: "/logout",
|
||||
hasChildren: false,
|
||||
isLogout: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const last = palette.paletteIndex.at(-1);
|
||||
assert.equal(last.isLogout, true);
|
||||
assert.equal(last.href, "/logout");
|
||||
assert.equal(last.title, "Logout");
|
||||
// After the theme commands, so ">" lists modes first, logout last.
|
||||
assert.ok(palette.paletteIndex.findIndex((page) => page.mode) >= 0);
|
||||
assert.ok(
|
||||
palette.paletteIndex.findIndex((page) => page.mode) <
|
||||
palette.paletteIndex.indexOf(last),
|
||||
);
|
||||
});
|
||||
@@ -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.2.3
|
||||
PKG_RELEASE:=69
|
||||
PKG_VERSION:=1.2.4
|
||||
PKG_RELEASE:=70
|
||||
PKG_LICENSE:=Apache-2.0
|
||||
|
||||
LUCI_MINIFY_CSS:=
|
||||
|
||||
@@ -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 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,r=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)},l=()=>{e.classList.contains("mobile-menu-open")&&(e.classList.remove("mobile-menu-open"),document.body.classList.remove("mobile-navigation-open"),document.body.style.overflow="",this.resetNavigationGroups(a),r(!1))};this.closeMobileNavigation=l;const c=t=>{if(o()){l();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 r(t)}if(i.matches)return l(),void r(!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),r(t)},d=()=>{if(l(),o()){const e="true"===localStorage.getItem(n);return document.body.classList.toggle("sidebar-collapsed",e),void r(!e)}r(!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){this.tree=e,this.renderModeMenu(e),this.renderTabs(e)},renderTabs(e){if(L.env.dispatchpath.length<3)return;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)},syncRoute(){if(!this.tree)return;const e=new URL(L.url(...L.env.dispatchpath.slice(0,3)),location.href).pathname;document.querySelectorAll("#topmenu a, .desktop-menu-canvas a, #sidebar-list a, #mobile-nav-list a").forEach(t=>{const a=t.pathname===e&&!t.getAttribute("href").startsWith("#");t.classList.toggle("is-active-page",a),a?t.setAttribute("aria-current","page"):t.removeAttribute("aria-current")}),document.querySelectorAll(".navigation-group").forEach(e=>{const t=!!e.querySelector("a.is-active-page"),a=e.querySelector(".navigation-group-toggle");e.classList.toggle("is-active-group",t),t?a?.setAttribute("aria-current","location"):a?.removeAttribute("aria-current")});for(const e of[document.querySelector("#sidebar-list"),document.querySelector("#mobile-nav-list")])this.resetNavigationGroups(e),this.expandActiveNavigationGroup(e);this.renderCrumb();const t=document.querySelector("#tabmenu");t&&(t.innerHTML="",t.style.display="none"),this.renderTabs(this.tree)},closeSurfaces(){this.hideDesktopNav(),this.paletteList&&this.closePalette(),this.closeMobileNavigation?.()},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)}Line truncated
|
||||
"use strict";"require baseclass";"require ui";const PALETTE_RECENTS_KEY="aurora.paletteRecents";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"),n=window.matchMedia("(min-width: 768px)"),i="aurora.sidebarCollapsed";let s;const o=()=>n.matches&&"sidebar"===document.body.dataset.navType,r=e=>{const a=o();t.classList.toggle("is-expanded",e&&!n.matches),t.setAttribute("aria-expanded",e?"true":"false"),t.setAttribute("aria-controls",a?"sidebar-panel":"mobile-menu-overlay");const i=e&&!a?_("Close"):_("Navigation");t.setAttribute("title",i),t.setAttribute("aria-label",i)},l=()=>{e.classList.contains("mobile-menu-open")&&(e.classList.remove("mobile-menu-open"),document.body.classList.remove("mobile-navigation-open"),document.body.style.overflow="",this.resetNavigationGroups(a),r(!1))};this.closeMobileNavigation=l;const c=t=>{if(o()){l();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(i,e),void r(t)}if(n.matches)return l(),void r(!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),r(t)},d=()=>{if(l(),o()){const e="true"===localStorage.getItem(i);return document.body.classList.toggle("sidebar-collapsed",e),void r(!e)}r(!1)};t.addEventListener("click",t=>{t.stopPropagation(),c(!(o()?!document.body.classList.contains("sidebar-collapsed"):!n.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)}),n.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){this.tree=e,this.renderModeMenu(e),this.renderTabs(e)},renderTabs(e){if(L.env.dispatchpath.length<3)return;let t=e,a="";for(let e=0;e<3&&t;e++){const n=L.env.dispatchpath[e];t=t.children?.[n],a+=(a?"/":"")+n}t&&this.renderTabMenu(t,a)},syncRoute(){if(!this.tree)return;const e=new URL(L.url(...L.env.dispatchpath.slice(0,3)),location.href).pathname;document.querySelectorAll("#topmenu a, .desktop-menu-canvas a, #sidebar-list a, #mobile-nav-list a").forEach(t=>{const a=t.pathname===e&&!t.getAttribute("href").startsWith("#");t.classList.toggle("is-active-page",a),a?t.setAttribute("aria-current","page"):t.removeAttribute("aria-current")}),document.querySelectorAll(".navigation-group").forEach(e=>{const t=!!e.querySelector("a.is-active-page"),a=e.querySelector(".navigation-group-toggle");e.classList.toggle("is-active-group",t),t?a?.setAttribute("aria-current","location"):a?.removeAttribute("aria-current")});for(const e of[document.querySelector("#sidebar-list"),document.querySelector("#mobile-nav-list")])this.resetNavigationGroups(e),this.expandActiveNavigationGroup(e);this.renderCrumb();const t=document.querySelector("#tabmenu");t&&(t.innerHTML="",t.style.display="none"),this.renderTabs(this.tree)},closeSurfaces(){this.hideDesktopNav(),this.paletteList&&this.closePalette(),this.closeMobileNavigation?.()},renderTabMenu(e,t,a=0){const n=document.querySelector("#tabmenu"),i=E("ul",{class:"tabs"}),s=ui.menu.getChildren(e);let o=null;return s.forEach(e=>{const n=L.env.dispatchpath[3+a]===e.name;i.appendChild(E("li",{class:`tabmenu-item-${e.name}${n?" active":""}`},[E("a",{href:L.url(t,e.name)},[_(e.title)])])),n&&(o=e)}),i.children.length?(n.appendChild(i),n.style.display="",o&&this.renderTabMenu(o,`${t}/${o.name}`,a+1),i):E([])},renderMainMenu(e,t,a=0,n=null){const i=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(n||[]),i||E([]);if(!i||!s.length)return E([]);"mega-menu"===e?Line truncated
|
||||
@@ -17,7 +17,7 @@ LUCI_NAME:=luci-theme-footstrap
|
||||
FOOTSTRAP_VERSION?=
|
||||
ifneq ($(FOOTSTRAP_VERSION),)
|
||||
PKG_VERSION:=$(FOOTSTRAP_VERSION)
|
||||
PKG_RELEASE:=29
|
||||
PKG_RELEASE:=30
|
||||
endif
|
||||
|
||||
LUCI_TITLE:=Footstrap Theme
|
||||
|
||||
@@ -86,11 +86,53 @@ function fittersEnabled() {
|
||||
function run() {
|
||||
if (!fittersEnabled()) return;
|
||||
runAll(_fitters, 'fitter');
|
||||
/* the page is settled now, so this is the position the next mutation must be measured against —
|
||||
* unless a correction is already on its way, which would make this reference the drifted one */
|
||||
/* the page is settled now: this is the height the NEXT tick may not go below, and the position
|
||||
* the next mutation must be measured against — the latter unless a correction is already on its
|
||||
* way, which would make this reference the drifted one */
|
||||
holdFloor();
|
||||
if (!_anchorPending) rememberRest();
|
||||
}
|
||||
|
||||
/* ---- THE DOCUMENT MAY NOT GET SHORTER WHILE A TICK IS IN FLIGHT ----
|
||||
*
|
||||
* The correction below puts a reader back after the engine has moved them. This is the other half,
|
||||
* and it is the half that means they were never moved: `dom.content()` — what every LuCI poll calls
|
||||
* to refresh a section — empties the container before it refills it, and a document that is briefly
|
||||
* shorter than the offset the reader is at is a document the engine clamps the offset into. Nothing
|
||||
* puts that back afterwards, because the shortening was never real.
|
||||
*
|
||||
* So the CONTENT COLUMN keeps a floor: `min-height`, set to the height it had at the last settled
|
||||
* moment and held until the next one. A section emptying inside it takes nothing off the document,
|
||||
* there is no shorter document to clamp into, and the tick is invisible. The floor is re-measured
|
||||
* after every settled batch, so a page that genuinely got shorter is shorter one frame later.
|
||||
*
|
||||
* WHY THE FLOOR IS ON `.fs-content` AND NOT ON THE CONTAINER BEING SWAPPED. A version of this
|
||||
* shipped in fs-overview.js, pinning the container and releasing it in the same statement sequence —
|
||||
* `dom.content()` performs no layout, so no layout ever saw the pin and it did nothing at all
|
||||
* (measured: 1882px still clamped away with the pin in place). The fix reported from the field wraps
|
||||
* `dom.content()` itself and releases two frames later, which does work — at the price of patching
|
||||
* a luci-base API every app on the router shares, and up to seven read/write pairs per call. One
|
||||
* element that the theme owns, measured once per settled batch, is the same protection: measured on
|
||||
* a live router with the corrections switched off, the wrapper and this floor both hold the reader
|
||||
* at 0px against a 337px drift without either, at 16 measurements against 154 wrapped calls.
|
||||
*
|
||||
* NOT WHILE THE READER SCROLLS. Clearing the floor to re-measure is a layout read, and the flick is
|
||||
* where this file spends its comments avoiding those; the floor simply stays where it was, which is
|
||||
* still a floor. */
|
||||
function holdFloor() {
|
||||
/* ONLY WHERE THE THEME IS RESPONSIBLE, same rule as the correction and for a measured reason: an
|
||||
* engine that anchors by itself reads the held height as one more thing that moved. On Chromium
|
||||
* in the sidebar layout at 1440 the reader ended 15px off with the floor held and 0px without it,
|
||||
* reproducibly, while the floor bought that engine nothing — it puts the reader back on its own. */
|
||||
if (ENGINE_ANCHORS || scrolling()) return;
|
||||
const el = document.querySelector('.fs-content');
|
||||
if (!el) return; /* the login page has no content column */
|
||||
/* cleared BEFORE the read, or the floor would measure itself and never come down */
|
||||
el.style.minHeight = '';
|
||||
const h = el.offsetHeight;
|
||||
if (h > 0) el.style.minHeight = h + 'px';
|
||||
}
|
||||
|
||||
/* ---- IS THE PAGE MOVING RIGHT NOW? — ASKED OF THE POSITION, NEVER OF THE EVENTS ----
|
||||
*
|
||||
* A pass that reads layout must not run while the page moves: on a phone every such read forces a
|
||||
@@ -193,7 +235,9 @@ function sampleMotion() {
|
||||
}
|
||||
if (scrolling()) { requestAnimationFrame(sampleMotion); return; }
|
||||
_sampling = false;
|
||||
/* the reader has stopped: this is a still moment, and the reference for the next tick */
|
||||
/* the reader has stopped: this is a still moment, so the floor and the reference both belong to
|
||||
* where the page now stands */
|
||||
holdFloor();
|
||||
rememberRest();
|
||||
/* the page has held still for SCROLL_IDLE: whatever was put off may run now */
|
||||
if (_deferred) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=opera-proxy
|
||||
PKG_VERSION:=1.28.0
|
||||
PKG_RELEASE:=9
|
||||
PKG_VERSION:=1.29.0
|
||||
PKG_RELEASE:=10
|
||||
|
||||
PKG_MAINTAINER:=Konstantine Shevlakov <shevlakov@132lan.ru>
|
||||
PKG_LICENSE:=MIT
|
||||
|
||||
Reference in new issue
Block a user