diff --git a/filebrowser-q/Makefile b/filebrowser-q/Makefile index 3f4d22b5e..ecdb4b8d2 100644 --- a/filebrowser-q/Makefile +++ b/filebrowser-q/Makefile @@ -1,8 +1,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=filebrowser-q -PKG_VERSION:=2.0.2-stable -PKG_RELEASE:=17 +PKG_VERSION:=2.1.5-beta +PKG_RELEASE:=18 PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE_URL:=https://codeload.github.com/gtsteffaniak/filebrowser/tar.gz/v$(PKG_VERSION)? diff --git a/honk/Makefile b/honk/Makefile index 1bb90fc2c..260487b4b 100644 --- a/honk/Makefile +++ b/honk/Makefile @@ -5,11 +5,11 @@ include $(TOPDIR)/rules.mk PKG_NAME:=honk -PKG_VERSION:=2026.11.13~f8bbf4f -PKG_RELEASE:=36 +PKG_VERSION:=2026.11.13~d9d401e +PKG_RELEASE:=37 HONK_SRC_URL:=https://github.com/daeuniverse/honk.git -HONK_SRC_VERSION:=f8bbf4f4b4ab1cdf673c440df316c3c642a31f9c +HONK_SRC_VERSION:=d9d401e8dd4cfcbed84cff147b29fb8b2bb0c9b4 PKG_LICENSE:=AGPL-3.0-only PKG_LICENSE_FILES:=LICENSE diff --git a/luci-theme-7dollar/.gitignore b/luci-theme-7dollar/.gitignore new file mode 100644 index 000000000..4a6b2e380 --- /dev/null +++ b/luci-theme-7dollar/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +*.swp +/tmp/ diff --git a/luci-theme-7dollar/LICENSE b/luci-theme-7dollar/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/luci-theme-7dollar/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/luci-theme-7dollar/Makefile b/luci-theme-7dollar/Makefile new file mode 100644 index 000000000..4fae5fc9f --- /dev/null +++ b/luci-theme-7dollar/Makefile @@ -0,0 +1,40 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# 7Dollar — an elegant, lightweight LuCI theme. +# Drop this directory into feeds/luci/themes/ (or package/) of an OpenWrt +# buildroot / SDK and select it under LuCI -> Themes. + +include $(TOPDIR)/rules.mk + +PKG_NAME:=luci-theme-7dollar +PKG_VERSION:=1.0.16 +PKG_RELEASE:=1 + +LUCI_TITLE:=7Dollar — an elegant, lightweight LuCI theme +LUCI_DESCRIPTION:=A calm, card based LuCI theme with five palettes, light/dark mode, \ + a friendly home dashboard and grouped navigation. Built for OpenWrt 23.05+ (ucode LuCI). +LUCI_DEPENDS:=+luci-base +LUCI_PKGARCH:=all + +PKG_LICENSE:=Apache-2.0 +PKG_MAINTAINER:=itsmaiGe + +define Package/luci-theme-7dollar/conffiles +/etc/config/7dollar +endef + +define Package/luci-theme-7dollar/postrm +#!/bin/sh +[ "$$1" = upgrade ] && exit 0 +[ -n "$${IPKG_INSTROOT}" ] || { + uci -q delete luci.themes.7Dollar + [ "$$(uci -q get luci.main.mediaurlbase)" = "/luci-static/7dollar" ] && \ + uci -q set luci.main.mediaurlbase=/luci-static/bootstrap + uci commit luci + rm -rf /tmp/luci-indexcache* /tmp/luci-modulecache/ +} +endef + +include $(TOPDIR)/feeds/luci/luci.mk + +# call BuildPackage - OpenWrt buildroot signature diff --git a/luci-theme-7dollar/README.md b/luci-theme-7dollar/README.md new file mode 100644 index 000000000..118d2c830 --- /dev/null +++ b/luci-theme-7dollar/README.md @@ -0,0 +1,223 @@ +

+ 7Dollar +

+ +

7Dollar

+ +

+ 不像工程师后台的 OpenWrt 主题
+ An OpenWrt theme that doesn't look like an engineer's console +

+ +

+ OpenWrt + Size + License +

+ +

+ 安装 · 使用 · 卸载 · English +

+ +

+ 首页 +

+ +## 简介 + +OpenWrt 的多数主题只调整了颜色,页面上仍是大量参数、表格和英文缩写,不熟悉网络的用户不容易看懂。7Dollar 重新组织了页面的呈现方式,让普通用户也能直接找到需要的信息和功能。 + +| | 常见主题 | 7Dollar | +| --- | --- | --- | +| 首页 | 参数表 | 网络状态、速率、在线设备集中显示 | +| 菜单 | 按技术模块分类:状态 / 系统 / 服务 / 网络 | 按使用场景分类:设备 / Wi‑Fi / 上网 / 安全 | +| 设备 | MAC 地址和 IP 列表 | 设备名称、类型、信号强度、连接时长 | +| 状态页 | 显示全部字段 | 默认显示常用信息,MAC、BSSID 等在「详情」中查看 | +| 用词 | 2g / 5g、DHCP 租约 | 2.4 GHz / 5 GHz、在线设备 | +| 查找功能 | 逐级打开菜单 | ⌘K 搜索 | + +LuCI 原有功能全部保留,只调整了显示方式。 + +## 名称由来 + +开发这个主题共花费了 **7 美元** 的 AI Token 费用,因此命名为 7Dollar。 + +## 功能 + +- **首页总览**:显示网络状态、实时速率、在线设备、Wi‑Fi、路由器状态、网口和存储空间。卡片可以单独开关,并自动分为两列排列 +- **按用途分组的菜单**:总览 / 设备 / Wi‑Fi / 上网 / 安全 / 应用 / 系统,支持 ⌘K / Ctrl K 搜索 +- **5 套配色**:陶土、青瓷、雾蓝、暮紫、石墨,均有浅色和深色,可跟随系统设置 +- **应用卡片(可选)**:默认关闭。开启后,首页列出正在运行的插件,适用于任意插件。常见插件会额外显示一项关键信息,例如 OpenClash 代理模式(可直接切换)、PassWall 当前节点、文件共享剩余空间。信息只从插件配置中读取,不调用插件接口 +- **自适应布局**:在手机、平板和页面缩放时自动调整排版 +- **轻量**:不加载外部字体,不发起外部请求;CSS 16 KB(gzip),安装包 48 KB +- **兼容原生页面**:LuCI 功能保持不变,原生图标统一为主题风格 +- **信息分层**:Wi‑Fi、接口和状态页默认只显示常用信息,MAC、BSSID、数据包数等内容点击「详情」查看 + +## 截图 + +

五种配色

+ + + + + + + + + + + + + + +
深色模式

深色模式

手机

手机

登录页

登录页

搜索

搜索

外观设置

外观设置

原生页面

原生页面

+ +## 安装 + +安装前请确认路由器系统为 **OpenWrt 23.05 或更新版本**。版本号可在后台「状态 → 概览」中查看。 + +### 方法一:网页上传(推荐,无需命令行) + +适用于 23.05 / 24.10。25.x 请使用方法二。 + +1. 下载安装包:[luci-theme-7dollar_1.0.16-r1_all.ipk](https://github.com/itsmaiGe/luci-theme-7dollar/releases/latest/download/luci-theme-7dollar_1.0.16-r1_all.ipk)(历史版本见 [Releases](https://github.com/itsmaiGe/luci-theme-7dollar/releases)) +2. 在浏览器中打开路由器后台(通常为 `192.168.1.1`),输入密码登录 +3. 打开 **系统 → 软件包 → 上传软件包…**,选择下载的文件,点击 **上传**,再点击 **安装** +4. 刷新页面(Windows 按 F5,Mac 按 ⌘ R) + +> 如果刷新后界面没有变化:打开 **系统 → 系统 → 语言和界面**,将 **主题** 设为 **7Dollar**,点击 **保存并应用**。 + +### 方法二:一行命令 + +适用于所有支持的版本。脚本会根据系统自动选择 ipk(23.05 / 24.10)或 apk(25.x)安装包。 + +1. 打开终端:Mac 上搜索「终端」,Windows 上搜索「PowerShell」 +2. 登录路由器(将 IP 替换为你的路由器地址): + + ```sh + ssh root@192.168.1.1 + ``` + + 首次连接出现 `yes/no` 提示时,输入 `yes` 并回车;然后输入路由器密码并回车。输入密码时屏幕不显示字符,这是正常现象。 + +3. 粘贴下面的命令并回车,出现「完成」后刷新浏览器: + + ```sh + wget -qO- https://raw.githubusercontent.com/itsmaiGe/luci-theme-7dollar/main/install.sh | sh + ``` + +
+下载失败 + +
+ +如果访问 GitHub 不稳定,建议使用方法一:先在电脑上下载安装包,再上传到路由器。 + +也可以使用 GitHub 加速代理(将 `<代理地址>` 替换为实际地址): + +```sh +wget -qO- <代理地址>/https://raw.githubusercontent.com/itsmaiGe/luci-theme-7dollar/main/install.sh | PROXY=<代理地址>/ sh +``` +
+ +
+从源码编译 + +
+ +在 OpenWrt 源码或 SDK 中: + +```sh +git clone https://github.com/itsmaiGe/luci-theme-7dollar.git feeds/luci/themes/luci-theme-7dollar +./scripts/feeds update -i && ./scripts/feeds install luci-theme-7dollar +make menuconfig # LuCI → Themes → luci-theme-7dollar +make package/luci-theme-7dollar/compile V=s +``` + +不使用 SDK 时,可在 macOS / Linux 上运行 `./scripts/build-ipk.sh` 生成 ipk(需要 Node.js,打包时用 esbuild 压缩 JS / CSS,源码不变);apk 由 GitHub Actions 在 OpenWrt 25.12 镜像中构建并测试安装(见 `.github/workflows/apk.yml`)。两者都输出到 `dist/`。 +
+ +## 使用 + +| 操作 | 位置 | +| --- | --- | +| 切换配色 / 深浅色 | 系统 → 外观与首页(也可点击左下角 🌙 切换深浅色) | +| 调整首页卡片 | 系统 → 外观与首页 → 首页卡片 | +| 首页显示正在运行的插件 | 系统 → 外观与首页 → 首页卡片 → 应用 | +| 查看在线设备 | 侧边栏「设备」 | +| 搜索设置 | ⌘K / Ctrl K / / | +| 打开已装插件 | 侧边栏「应用」 | + +主题设置保存在 `/etc/config/7dollar`,只影响界面显示,不会修改网络配置。 + +## 卸载 + +- **切换为其他主题**:打开 系统 → 系统 → 语言和界面 → 主题,选择 Bootstrap 或其他主题,保存并应用 +- **完全卸载**:打开 系统 → 软件包 → 已安装,搜索 `7dollar`,点击 **移除**。卸载后会自动切换为 Bootstrap +- **命令卸载**: + + ```sh + wget -qO- https://raw.githubusercontent.com/itsmaiGe/luci-theme-7dollar/main/uninstall.sh | sh + ``` + +
+后台无法打开时 + +
+ +通过 SSH 登录路由器后执行以下命令,切换为默认主题: + +```sh +uci set luci.main.mediaurlbase=/luci-static/bootstrap && uci commit luci +``` +
+ +## 兼容性 + +| | | +| --- | --- | +| OpenWrt | 23.05、24.10(ipk),25.x(apk);不支持 22.03 及更早版本 | +| 浏览器 | Chrome / Edge 111+、Safari 16.4+、Firefox 113+(2023 年以后的版本) | +| 已测试 | Cudy TR3000 · OpenWrt 24.10.5 · MT7981B | +| 共存 | 可与 Bootstrap、Argon 等主题同时安装 | + +## 目录结构 + +``` +htdocs/luci-static/7dollar/ 样式、图标 +htdocs/luci-static/resources/ 导航与页面脚本 +ucode/ 页面框架、登录页 +root/ 默认配置、菜单、权限 +dist/ ipk / apk 安装包 +install.sh / uninstall.sh 安装 / 卸载脚本 +``` + +## 致谢 + +- [OpenWrt LuCI](https://github.com/openwrt/luci) +- [zashboard](https://github.com/Zephyruso/zashboard):多配色设计参考 +- 默认配色参考 Claude 的橙色 + +## English + +**7Dollar** is a LuCI theme for people who are not network engineers: it shows what matters (online or not, how fast, who's connected) instead of listing every field, groups menus by task (Devices, Wi‑Fi, Internet, Security), and keeps the technical details one *Details* click away. Nothing from LuCI is removed. It is a clean, card-based theme for OpenWrt 23.05+ (ucode LuCI), including apk-based 25.x. The name comes from its cost: **$7** in AI tokens. + +- Home dashboard with connection status, live throughput, devices, Wi‑Fi and system health +- Menus grouped by purpose, with ⌘K search +- 5 palettes (Clay, Celadon, Mist, Dusk, Graphite), each in light and dark +- Optional Apps card (off by default): lists whatever plugins are running. Popular ones get one line that matters (OpenClash proxy-mode switch, PassWall node, file-sharing free space…), read from their config, never from their API. +- Native LuCI pages restyled, icons included +- Status pages (Wi‑Fi, interfaces, overview) show the essentials; MACs, BSSIDs, packet counts and the like sit behind *Details* +- Responsive and zoom-friendly; no external fonts or requests; 48 KB package + +**Install**: upload [the ipk](https://github.com/itsmaiGe/luci-theme-7dollar/releases/latest/download/luci-theme-7dollar_1.0.16-r1_all.ipk) via *System → Software → Upload Package* (23.05 / 24.10), or run over SSH on any version (picks the ipk or the [apk](https://github.com/itsmaiGe/luci-theme-7dollar/tree/main/dist) automatically): + +```sh +wget -qO- https://raw.githubusercontent.com/itsmaiGe/luci-theme-7dollar/main/install.sh | sh +``` + +**Uninstall**: `opkg remove luci-theme-7dollar` / `apk del luci-theme-7dollar`, or the `uninstall.sh` one-liner. + +## License + +[Apache-2.0](LICENSE) © itsmaiGe diff --git a/luci-theme-7dollar/dist/latest-apk.txt b/luci-theme-7dollar/dist/latest-apk.txt new file mode 100644 index 000000000..259872ad8 --- /dev/null +++ b/luci-theme-7dollar/dist/latest-apk.txt @@ -0,0 +1 @@ +luci-theme-7dollar-1.0.16-r1.apk diff --git a/luci-theme-7dollar/dist/latest.txt b/luci-theme-7dollar/dist/latest.txt new file mode 100644 index 000000000..34bc36c86 --- /dev/null +++ b/luci-theme-7dollar/dist/latest.txt @@ -0,0 +1 @@ +luci-theme-7dollar_1.0.16-r1_all.ipk diff --git a/luci-theme-7dollar/dist/luci-theme-7dollar-1.0.16-r1.apk b/luci-theme-7dollar/dist/luci-theme-7dollar-1.0.16-r1.apk new file mode 100644 index 000000000..cd5bee271 Binary files /dev/null and b/luci-theme-7dollar/dist/luci-theme-7dollar-1.0.16-r1.apk differ diff --git a/luci-theme-7dollar/dist/luci-theme-7dollar_1.0.16-r1_all.ipk b/luci-theme-7dollar/dist/luci-theme-7dollar_1.0.16-r1_all.ipk new file mode 100644 index 000000000..a81b6952a Binary files /dev/null and b/luci-theme-7dollar/dist/luci-theme-7dollar_1.0.16-r1_all.ipk differ diff --git a/luci-theme-7dollar/docs/appearance.webp b/luci-theme-7dollar/docs/appearance.webp new file mode 100644 index 000000000..aedbf2b1e Binary files /dev/null and b/luci-theme-7dollar/docs/appearance.webp differ diff --git a/luci-theme-7dollar/docs/home-dark.webp b/luci-theme-7dollar/docs/home-dark.webp new file mode 100644 index 000000000..b7dc79c59 Binary files /dev/null and b/luci-theme-7dollar/docs/home-dark.webp differ diff --git a/luci-theme-7dollar/docs/home-light.webp b/luci-theme-7dollar/docs/home-light.webp new file mode 100644 index 000000000..86a25307a Binary files /dev/null and b/luci-theme-7dollar/docs/home-light.webp differ diff --git a/luci-theme-7dollar/docs/login.webp b/luci-theme-7dollar/docs/login.webp new file mode 100644 index 000000000..2444c43b0 Binary files /dev/null and b/luci-theme-7dollar/docs/login.webp differ diff --git a/luci-theme-7dollar/docs/logo.svg b/luci-theme-7dollar/docs/logo.svg new file mode 100644 index 000000000..1c588287c --- /dev/null +++ b/luci-theme-7dollar/docs/logo.svg @@ -0,0 +1 @@ + diff --git a/luci-theme-7dollar/docs/mobile.webp b/luci-theme-7dollar/docs/mobile.webp new file mode 100644 index 000000000..de542e245 Binary files /dev/null and b/luci-theme-7dollar/docs/mobile.webp differ diff --git a/luci-theme-7dollar/docs/native.webp b/luci-theme-7dollar/docs/native.webp new file mode 100644 index 000000000..9528acc4a Binary files /dev/null and b/luci-theme-7dollar/docs/native.webp differ diff --git a/luci-theme-7dollar/docs/palettes.webp b/luci-theme-7dollar/docs/palettes.webp new file mode 100644 index 000000000..c5f3e83ad Binary files /dev/null and b/luci-theme-7dollar/docs/palettes.webp differ diff --git a/luci-theme-7dollar/docs/search.webp b/luci-theme-7dollar/docs/search.webp new file mode 100644 index 000000000..434b88a3f Binary files /dev/null and b/luci-theme-7dollar/docs/search.webp differ diff --git a/luci-theme-7dollar/htdocs/luci-static/7dollar/cascade.css b/luci-theme-7dollar/htdocs/luci-static/7dollar/cascade.css new file mode 100644 index 000000000..85c3d3f9d --- /dev/null +++ b/luci-theme-7dollar/htdocs/luci-static/7dollar/cascade.css @@ -0,0 +1,842 @@ +/*! + * 7Dollar — an elegant LuCI theme for OpenWrt + * Licensed under the Apache License 2.0 + */ + +/* ============================== tokens ============================== */ +:root{ + --serif:"Iowan Old Style","Songti SC","Noto Serif SC","Source Han Serif SC",Georgia,serif; + --sans:-apple-system,BlinkMacSystemFont,"PingFang SC","HarmonyOS Sans SC","Microsoft YaHei UI","Noto Sans SC","Segoe UI",Roboto,sans-serif; + --mono:ui-monospace,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace; + --ease:cubic-bezier(.2,.7,.2,1);--fast:.18s;--base:.28s;--slow:.45s;--data:.8s; /* motion: feedback / state change / entrance / data */ + --chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23958F83' stroke-width='1.6' stroke-linecap='round'%3E%3Cpath d='m3 4.5 3 3 3-3'/%3E%3C/svg%3E"); + --r-lg:20px;--r-md:14px;--r-sm:10px; + --ok:#5F8A55;--ok-soft:#E1EAD8;--warn:#B98330;--warn-soft:#F4E7CF;--bad:#C0533E;--bad-soft:#F6DDD5; + --shadow:0 1px 1px rgba(20,20,20,.03),0 10px 30px -18px rgba(20,20,20,.18); + --shadow-pop:0 24px 60px -20px rgba(20,20,20,.35),0 2px 6px rgba(20,20,20,.06); + color-scheme:light; +} +[data-theme=dark]{--ok:#93AE82;--ok-soft:#2B3427;--warn:#D6A55A;--warn-soft:#3A3122;--bad:#E3826E;--bad-soft:#3E2621;--shadow:0 1px 1px rgba(0,0,0,.2),0 10px 30px -18px rgba(0,0,0,.6);--shadow-pop:0 24px 60px -20px rgba(0,0,0,.8);color-scheme:dark} + +/* 1 陶土 clay */ +:root,[data-palette=clay]{--canvas:#F3F1EA;--surface:#FBFAF6;--surface-2:#EFECE3;--hover:#E9E5DA;--line:#E2DDD1;--line-soft:#ECE8DE;--ink:#1F1E1B;--ink-2:#5B5850;--ink-3:#726D64;--accent:#D97757;--accent-2:#B74C29;--on-accent:#fff;--accent-soft:#F6E2D7;--tint:rgba(217,119,87,.10);--second:#8094A8} +[data-theme=dark][data-palette=clay],[data-theme=dark] [data-palette=clay]{--canvas:#1D1C1A;--surface:#262522;--surface-2:#2E2D29;--hover:#34322E;--line:#383631;--line-soft:#302F2B;--ink:#EDEAE2;--ink-2:#B4AFA4;--ink-3:#908B81;--accent:#E0886A;--accent-2:#EA9A7E;--on-accent:#1D1C1A;--accent-soft:#3D2C25;--tint:rgba(224,136,106,.12);--second:#93A6B9} +/* 2 青瓷 celadon */ +[data-palette=celadon]{--canvas:#EFF2EE;--surface:#FAFBF8;--surface-2:#E8ECE6;--hover:#E1E6DE;--line:#DAE0D7;--line-soft:#E5E9E2;--ink:#1B201D;--ink-2:#4F5A53;--ink-3:#67706A;--accent:#3F8A78;--accent-2:#2F6F60;--on-accent:#fff;--accent-soft:#DCEBE5;--tint:rgba(63,138,120,.10);--second:#B08A5A} +[data-theme=dark][data-palette=celadon],[data-theme=dark] [data-palette=celadon]{--canvas:#191D1B;--surface:#222724;--surface-2:#2A302C;--hover:#303733;--line:#353D38;--line-soft:#2C332F;--ink:#E6ECE7;--ink-2:#A9B5AD;--ink-3:#848F88;--accent:#63B19E;--accent-2:#7CC3B1;--on-accent:#0F1A16;--accent-soft:#24382F;--tint:rgba(99,177,158,.13);--second:#C9A777} +/* 3 雾蓝 mist */ +[data-palette=mist]{--canvas:#EFF1F5;--surface:#FAFBFD;--surface-2:#E7EAF0;--hover:#E0E4EC;--line:#D9DEE7;--line-soft:#E4E8EF;--ink:#1A1D24;--ink-2:#4E5563;--ink-3:#686E7B;--accent:#4D6FAE;--accent-2:#3A5A96;--on-accent:#fff;--accent-soft:#DEE6F4;--tint:rgba(77,111,174,.10);--second:#C08457} +[data-theme=dark][data-palette=mist],[data-theme=dark] [data-palette=mist]{--canvas:#181A20;--surface:#21242B;--surface-2:#292D35;--hover:#2F343D;--line:#353A45;--line-soft:#2B2F38;--ink:#E7EAF0;--ink-2:#A8AFBD;--ink-3:#848B9A;--accent:#7F9DD8;--accent-2:#98B2E3;--on-accent:#0F1626;--accent-soft:#26324A;--tint:rgba(127,157,216,.14);--second:#D9A27A} +/* 4 暮紫 dusk */ +[data-palette=dusk]{--canvas:#F3F0F2;--surface:#FCFAFB;--surface-2:#ECE7EB;--hover:#E6E0E5;--line:#E0D9DF;--line-soft:#EAE4E9;--ink:#211C20;--ink-2:#5A5058;--ink-3:#756B73;--accent:#9A5F8E;--accent-2:#7F4B75;--on-accent:#fff;--accent-soft:#F0E0EC;--tint:rgba(154,95,142,.10);--second:#6F8FA0} +[data-theme=dark][data-palette=dusk],[data-theme=dark] [data-palette=dusk]{--canvas:#1C181B;--surface:#262124;--surface-2:#2E282C;--hover:#352E33;--line:#3C343A;--line-soft:#322B30;--ink:#EEE7EC;--ink-2:#B6AAB3;--ink-3:#90868E;--accent:#CC8FBE;--accent-2:#DBA6CF;--on-accent:#24121F;--accent-soft:#3D2937;--tint:rgba(204,143,190,.13);--second:#8FB0C1} +/* 5 石墨 graphite */ +[data-palette=graphite]{--canvas:#F1F1EF;--surface:#FBFBFA;--surface-2:#E9E9E6;--hover:#E2E2DF;--line:#DCDCD8;--line-soft:#E7E7E4;--ink:#1C1C1B;--ink-2:#575754;--ink-3:#6E6E6A;--accent:#2B2B29;--accent-2:#000;--on-accent:#fff;--accent-soft:#E4E4E0;--tint:rgba(43,43,41,.08);--second:#9A9A94} +[data-theme=dark][data-palette=graphite],[data-theme=dark] [data-palette=graphite]{--canvas:#171717;--surface:#202020;--surface-2:#282828;--hover:#2E2E2E;--line:#343434;--line-soft:#2A2A2A;--ink:#EDEDEB;--ink-2:#ABABA7;--ink-3:#878784;--accent:#EDEDEB;--accent-2:#fff;--on-accent:#171717;--accent-soft:#333;--tint:rgba(237,237,235,.10);--second:#8A8A86} + +/* ============================== base ============================== */ +*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;border:0} +*{scroll-margin-top:40px} +html{height:100%;-webkit-text-size-adjust:100%} +body{min-height:100%;background:var(--canvas);color:var(--ink);font:14px/1.6 var(--sans);-webkit-font-smoothing:antialiased;font-variant-numeric:tabular-nums} +a{color:var(--accent-2);text-decoration:none} +a:hover{text-decoration:underline;text-underline-offset:3px} +img,svg{vertical-align:middle} +table{border-collapse:collapse;border-spacing:0} +ul,ol{margin:0 0 14px 22px} +li{line-height:1.6} +p{margin-bottom:10px} +strong,b{font-weight:600} +small{font-size:.9em} +hr{height:1px;background:var(--line-soft);margin:18px 0} +code,pre,kbd,samp{font-family:var(--mono);font-size:12.5px} +code{background:var(--surface-2);border-radius:6px;padding:1px 6px} +pre{background:var(--surface-2);border:1px solid var(--line-soft);border-radius:var(--r-sm);padding:12px 14px;white-space:pre-wrap;word-wrap:break-word;margin-bottom:16px;line-height:1.55} +abbr[title]{text-decoration:underline dotted;cursor:help} +::selection{background:var(--accent-soft);color:var(--ink)} +::placeholder{color:var(--ink-3);opacity:1} +:focus-visible{outline:2px solid var(--accent);outline-offset:2px} +.hidden,[hidden]{display:none!important} +.left{text-align:left!important}.right{text-align:right!important}.center{text-align:center!important} +.top{vertical-align:top!important}.middle{vertical-align:middle!important}.bottom{vertical-align:bottom!important} +.nowrap{white-space:nowrap} +.pull-right{float:right}.pull-left{float:left} +svg.i{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round;flex:none} +.td-serif{font-family:var(--serif);font-weight:500;letter-spacing:-.01em} +.td-muted{color:var(--ink-3)} +::view-transition-old(root),::view-transition-new(root){animation-duration:var(--base);animation-timing-function:var(--ease)} +@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms!important;transition-duration:.01ms!important}} + +/* headings: LuCI views put the page title in h2 */ +h1,h2,h3,h4,h5,h6,legend{color:var(--ink);font-weight:500;line-height:1.3} +h1{font:500 clamp(26px,3.2vw,34px)/1.18 var(--serif);letter-spacing:-.015em;margin-bottom:12px} +h2{font:500 clamp(24px,3vw,32px)/1.2 var(--serif);letter-spacing:-.015em;margin:0 0 10px} +.cbi-section h2,.modal h2,.td-card h2{font:500 20px/1.3 var(--serif)} +h3,legend{font:500 18px/1.3 var(--serif);margin:0 0 12px;display:block} +h4{font-size:15px;margin:6px 0 8px} +h5{font-size:14px} +h6{font-size:12px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.06em} +.cbi-map-descr,.cbi-tab-descr{color:var(--ink-2);font-size:14px;margin:0 0 20px;max-width:76ch} +.cbi-section-descr{color:var(--ink-3);font-size:13px;margin:-4px 0 12px;max-width:76ch} +.cbi-section-descr:empty,.cbi-map-descr:empty{display:none} +.td-desc{margin:-4px 0 12px}.td-desc>summary{display:inline-flex;align-items:center;gap:2px;list-style:none;cursor:pointer;color:var(--ink-3);font-size:13px;transition:color var(--fast) var(--ease)} +.td-desc>summary::-webkit-details-marker{display:none}.td-desc>summary:hover{color:var(--ink)} +.td-desc>summary .i{width:14px;height:14px;transform:rotate(90deg);transition:transform var(--base) var(--ease)}.td-desc[open]>summary .i{transform:rotate(-90deg)} +.td-desc>*:not(summary){margin:6px 0 0;animation:td-fade var(--base) var(--ease)} + +/* ============================== app shell ============================== */ +.td-app{display:grid;grid-template-columns:248px minmax(0,1fr);min-height:100vh} +.td-side{position:sticky;top:0;height:100vh;padding:22px 16px;display:flex;flex-direction:column;gap:18px;min-width:0;z-index:20} +.td-brand{display:block;padding:4px 8px;color:var(--ink)!important;text-decoration:none!important;min-width:0} +.td-brand b{display:block;font:600 17px/1.15 var(--serif);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.td-brand small{display:block;color:var(--ink-3);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.td-search{display:flex;align-items:center;gap:8px;width:100%;padding:9px 12px;border:1px solid var(--line);border-radius:12px;background:var(--surface);color:var(--ink-3);text-align:left;cursor:pointer;font:inherit;transition:border-color var(--fast) var(--ease)} +.td-search:hover{border-color:var(--ink-3)} +.td-search span{flex:1} +kbd{font:11px var(--sans);padding:1px 6px;border:1px solid var(--line);border-radius:6px;color:var(--ink-3)} +.td-nav{display:flex;flex-direction:column;gap:2px;margin:0;min-height:300px} +.td-nav a{display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:11px;color:var(--ink-2);text-decoration:none;transition:background var(--fast) var(--ease),color var(--fast) var(--ease);white-space:nowrap;animation:td-fade var(--base) var(--ease) both} +.td-nav a:hover{background:var(--hover);color:var(--ink);text-decoration:none} +.td-nav a.on{background:var(--surface);color:var(--ink);box-shadow:var(--shadow)} +.td-nav a.on svg{color:var(--accent)} +.td-nav a em{margin-left:auto;font-style:normal;font-size:12px;color:var(--ink-3)} +.td-nav .short{display:none} +.td-side-foot{margin-top:auto;display:flex;gap:6px;padding:0 4px} +.td-icon-btn{width:36px;height:36px;display:grid;place-items:center;border-radius:10px;color:var(--ink-2)!important;background:none;cursor:pointer;text-decoration:none!important} +.td-icon-btn:hover{background:var(--hover);color:var(--ink)!important} +.td-main{padding:34px 40px 60px 24px;min-width:0;container:main/inline-size;position:relative} +#maincontent{max-width:1440px;min-width:0} +@keyframes td-fade{from{opacity:0}to{opacity:1}} +@keyframes td-rise{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}} +#view{animation:td-rise var(--slow) var(--ease)} + +/* indicators (unsaved changes, polling) */ +#indicators{display:flex;flex:none;flex-wrap:wrap;justify-content:flex-end;gap:6px;margin-left:auto;max-width:60%} +#indicators:empty{display:none} +.td-top{display:flex;align-items:center;gap:12px;min-width:0;margin:-4px 0 24px} +.td-top>.td-pills{flex:1 1 auto;min-width:0;margin:0} +.td-top:has(>.td-pills:empty){margin:0} +.td-top:has(>.td-pills:empty)>#indicators{position:absolute;top:22px;right:40px;z-index:30} +[data-indicator]{display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 12px;border-radius:99px;font-size:12.5px;font-weight:500;background:var(--surface);border:1px solid var(--line);color:var(--ink-2);box-shadow:var(--shadow);white-space:nowrap} +[data-indicator][data-clickable]{cursor:pointer} +[data-indicator][data-style=active]{background:var(--accent-2);border-color:var(--accent-2);color:var(--on-accent)} +[data-indicator=poll-status]{height:26px;padding:0 10px;box-shadow:none;background:transparent;border-color:transparent;color:var(--ink-3);font-weight:400} +[data-indicator=poll-status]::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--ink-3)} +[data-indicator=poll-status][data-style=active]{background:transparent;border-color:transparent;color:var(--ink-3)} +[data-indicator=poll-status][data-style=active]::before{background:var(--ok);animation:td-breathe 2.4s ease-in-out infinite} +[data-indicator=poll-status]:hover{background:var(--hover)} +@keyframes td-breathe{50%{opacity:.35}} + +/* group pills (built by menu-7dollar.js) */ +.td-pills{display:flex;gap:6px;overflow-x:auto;padding:4px 0;scrollbar-width:none} +.td-pills::-webkit-scrollbar{display:none} +.td-pills.scrolls{-webkit-mask-image:linear-gradient(90deg,#000 calc(100% - 36px),transparent);mask-image:linear-gradient(90deg,#000 calc(100% - 36px),transparent)} +.td-pills.scrolls.end{-webkit-mask-image:none;mask-image:none} +.td-pills:empty{display:none} +.td-pills a{padding:7px 14px;border-radius:99px;color:var(--ink-2);text-decoration:none;white-space:nowrap;font-size:13.5px;transition:background var(--fast) var(--ease),color var(--fast) var(--ease)} +.td-pills a:hover{background:var(--hover);color:var(--ink);text-decoration:none} +.td-pills a.on{background:var(--ink);color:var(--canvas)} +.td-pills :is(.back,.fold){display:inline-flex;align-items:center;gap:4px;color:var(--ink-3)} +.td-pills :is(.back,.fold) svg{width:15px;height:15px;transform:rotate(180deg)} +.td-pills .fold svg{transform:rotate(90deg)} +.td-pills .td-in{animation:td-fade var(--base) var(--ease) both} + +/* rail: narrow windows or zoomed in */ +@media (max-width:1100px){ + .td-app{grid-template-columns:76px minmax(0,1fr)} + .td-side{padding:22px 12px;align-items:center} + .td-brand,.td-search span,.td-search kbd,.td-nav .lbl,.td-nav a em{display:none} + .td-search{width:44px;height:44px;justify-content:center;padding:0} + .td-nav a{padding:12px;justify-content:center} + .td-side-foot{flex-direction:column} + .td-top:has(>.td-pills:empty)>#indicators{right:24px} +} +/* phone: bottom tab bar */ +@media (max-width:760px){ + .td-app{grid-template-columns:minmax(0,1fr)} + .td-side{position:fixed;inset:auto 0 0 0;top:auto;height:auto;flex-direction:row;padding:6px 8px calc(6px + env(safe-area-inset-bottom));background:color-mix(in srgb,var(--surface) 92%,transparent);-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px);border-top:1px solid var(--line);z-index:600} + .td-brand,.td-search,.td-side-foot{display:none} + .td-nav{flex-direction:row;justify-content:space-around;width:100%;min-height:0} + .td-nav a{flex-direction:column;gap:3px;padding:6px 4px;font-size:11px;box-shadow:none!important;background:none!important;flex:1;justify-content:center} + .td-nav a.mob-hide{display:none} + .td-nav .lbl{display:inline}.td-nav .short{display:inline}.td-nav .lbl.has-short{display:none} + .td-main{padding:22px 16px 100px} + .td-top:has(>.td-pills:empty)>#indicators{position:static;margin:0 0 12px auto} +} + +/* command palette / mobile sheet */ +.td-pop{position:fixed;inset:0;z-index:1000;background:color-mix(in srgb,#141413 30%,transparent);-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px);display:flex;align-items:flex-start;justify-content:center;padding:12vh 16px 16px;animation:td-fade var(--base) var(--ease)} +.td-pop-box{width:min(560px,100%);max-height:70vh;display:flex;flex-direction:column;background:var(--surface);border:1px solid var(--line-soft);border-radius:18px;box-shadow:var(--shadow-pop);overflow:hidden;animation:td-rise var(--base) var(--ease)} +/* exits get their own keyframes: reusing td-fade/td-rise would not restart the finished entrance */ +.td-pop.td-out,.td-toast.td-out{animation:td-out var(--fast) var(--ease) both} +.td-out .td-pop-box{animation:td-sink var(--fast) var(--ease) both} +@keyframes td-out{to{opacity:0}} +@keyframes td-sink{to{opacity:0;transform:translateY(6px)}} +.td-pop-in{display:flex;align-items:center;gap:10px;padding:14px 18px;border-bottom:1px solid var(--line-soft);color:var(--ink-3)} +.td-pop-in input{flex:1;border:0!important;background:none!important;box-shadow:none!important;outline:0;font-size:16px!important;height:auto!important;padding:4px 0!important;color:var(--ink);width:auto!important;min-width:0} +.td-pop-list{overflow-y:auto;padding:8px;margin:0;list-style:none} +.td-pop-list a{display:flex;align-items:center;gap:12px;padding:10px 12px;border-radius:11px;color:var(--ink);text-decoration:none} +.td-pop-list a small{margin-left:auto;color:var(--ink-3);font-size:12px;white-space:nowrap} +.td-pop-list a svg{color:var(--ink-3)} +.td-pop-list a.on,.td-pop-list a:hover{background:var(--hover);text-decoration:none} +.td-pop-list a.on svg{color:var(--accent)} +.td-pop-list .grp{padding:12px 12px 4px;font-size:12px;color:var(--ink-3)} +.td-pop-empty{padding:28px;text-align:center;color:var(--ink-3)} +.td-sheet-tools{display:flex;gap:8px;padding:10px 12px 14px;border-top:1px solid var(--line-soft)} +.td-sheet-tools .btn{flex:1} +@media (max-width:760px){ + .td-pop{align-items:flex-end;padding:0} + .td-pop-box{width:100%;max-height:82vh;border-radius:20px 20px 0 0;padding-bottom:env(safe-area-inset-bottom)} +} + +/* ============================== buttons ============================== */ +button,input,select,textarea{font:inherit;color:inherit} +.btn,.cbi-button,:where(input)[type=button],:where(input)[type=submit],:where(input)[type=reset]{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:36px;padding:0 15px;border-radius:11px;border:1px solid var(--line);background:var(--surface);color:var(--ink);font-weight:500;font-size:13.5px;line-height:1.2;white-space:nowrap;cursor:pointer;text-decoration:none!important;transition:background var(--fast) var(--ease),border-color var(--fast) var(--ease),color var(--fast) var(--ease),box-shadow var(--fast) var(--ease);vertical-align:middle;width:auto;height:auto} +.btn:hover,.cbi-button:hover,:where(input)[type=button]:hover,:where(input)[type=submit]:hover,:where(input)[type=reset]:hover{border-color:var(--ink-3)} +.btn:focus-visible,.cbi-button:focus-visible{outline:0;box-shadow:0 0 0 4px var(--tint);border-color:var(--accent)} +.btn:active,.cbi-button:active{transform:translateY(1px)} +.btn[disabled],.cbi-button[disabled],button[disabled],input[disabled][type=button],input[disabled][type=submit]{opacity:.45;pointer-events:none;cursor:default} +.btn.primary,.btn.important,.cbi-button-apply,.cbi-button-action.important,.cbi-button-positive.important,.cbi-section-actions .cbi-button-edit{background:var(--accent-2);border-color:var(--accent-2);color:var(--on-accent)!important} +.btn.primary:hover,.btn.important:hover,.cbi-button-apply:hover,.cbi-button-action.important:hover,.cbi-button-positive.important:hover,.cbi-section-actions .cbi-button-edit:hover{background:color-mix(in srgb,var(--accent-2) 86%,var(--ink));border-color:transparent} +.cbi-button-positive,.cbi-button-save,.cbi-button-add,.cbi-button-fieldadd,.btn.success{color:var(--accent-2);border-color:color-mix(in srgb,var(--accent) 40%,var(--line));background:var(--surface)} +.cbi-button-positive:hover,.cbi-button-save:hover,.cbi-button-add:hover,.cbi-button-fieldadd:hover{background:var(--tint);border-color:var(--accent)} +.cbi-button-negative,.cbi-button-remove,.cbi-button-reset,.cbi-section-remove .cbi-button,.btn.danger,.btn.error{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 30%,var(--line));background:var(--surface)} +.cbi-button-negative:hover,.cbi-button-remove:hover,.cbi-button-reset:hover,.cbi-section-remove .cbi-button:hover,.btn.danger:hover{background:var(--bad-soft);border-color:var(--bad)} +.cbi-button-negative.important,.btn.danger.important{background:var(--bad);border-color:var(--bad);color:#fff} +.cbi-button-up,.cbi-button-down{min-width:36px;padding:0 8px} +.btn.large{min-height:46px;padding:0 20px;font-size:15px;border-radius:14px} +.btn.small,.cbi-button.small{min-height:28px;padding:0 10px;font-size:12px;border-radius:8px} +.btn.quiet{border-color:transparent;background:none} +.btn.quiet:hover{background:var(--hover);border-color:transparent} +.btn.block{width:100%} +.close{float:right;font-size:20px;line-height:1;color:var(--ink-3);cursor:pointer;opacity:.7} +.close:hover{opacity:1;text-decoration:none} +.control-group{display:inline-flex;flex-wrap:wrap;gap:6px;align-items:center} +/* LuCI stacks controls with a bare
(e.g. System → Local time); give that line break room */ +br+:is(.control-group,input,select,textarea,.cbi-button,.btn,.cbi-dropdown,.cbi-select){margin-top:8px} + +/* ============================== form controls ============================== */ +input,textarea,select,.cbi-select,.cbi-dropdown:not(.btn):not(.cbi-button){display:inline-block;width:280px;max-width:100%;background:var(--canvas);color:var(--ink);font-size:14px;line-height:1.4;border:1px solid var(--line);border-radius:var(--r-sm);padding:7px 11px;transition:border-color var(--fast) var(--ease),box-shadow var(--fast) var(--ease);vertical-align:middle} +input,select,.cbi-select{height:38px} +textarea{min-height:90px;font-family:var(--mono);font-size:13px;line-height:1.55;padding:10px 12px;resize:vertical;width:100%} +input:hover,select:hover,textarea:hover,.cbi-dropdown:not(.btn):not(.cbi-button):hover{border-color:color-mix(in srgb,var(--ink-3) 70%,var(--line))} +input:focus,select:focus,textarea:focus,.cbi-select.focus,.cbi-select:focus-within,.cbi-dropdown:not(.btn):not(.cbi-button):focus,.cbi-dropdown[open]:not(.btn):not(.cbi-button){outline:0;border-color:var(--accent);box-shadow:0 0 0 4px var(--tint)} +input[readonly],textarea[readonly]{background:var(--surface-2);color:var(--ink-2)} +input[disabled],select[disabled],textarea[disabled],.cbi-select[disabled],.cbi-dropdown[disabled]:not(.btn):not(.cbi-button){opacity:.5;pointer-events:none;cursor:default} +input[type=file]{height:auto;border:0;background:none;padding:6px 0;width:auto!important} +input[type=file]::file-selector-button{font:inherit;font-weight:500;margin-right:10px;padding:7px 14px;border-radius:10px;border:1px solid var(--line);background:var(--surface);color:var(--ink);cursor:pointer} +input[type=search]{-webkit-appearance:none;appearance:none} +select{-webkit-appearance:none;appearance:none;padding-right:30px;cursor:pointer;background:var(--canvas) var(--chevron) no-repeat right 11px center} +select[multiple]{height:auto;background-image:none;padding-right:11px} +select option{background:var(--surface);color:var(--ink)} +.cbi-select{position:relative;padding:0} +.cbi-select select,.cbi-select select:focus{border:0;box-shadow:none;width:100%;height:100%;background-color:transparent} +input.cbi-input-invalid,textarea.cbi-input-invalid,select.cbi-input-invalid,.cbi-dropdown.cbi-input-invalid,.cbi-select.cbi-input-invalid{border-color:var(--bad)!important;color:var(--bad)} +input.cbi-input-invalid:focus{box-shadow:0 0 0 4px color-mix(in srgb,var(--bad) 15%,transparent)!important} +input.cbi-input-password{width:calc(280px - 46px)} +.control-group>input+.cbi-button,.cbi-input-password+.cbi-button{min-width:38px;padding:0 10px} + +/* checkbox & radio */ +input[type=checkbox],input[type=radio]{-webkit-appearance:none;appearance:none;width:18px;height:18px;min-width:18px;padding:0;border:1.5px solid color-mix(in srgb,var(--ink-3) 70%,var(--line));border-radius:6px;background:var(--surface);display:inline-grid;place-items:center;cursor:pointer;vertical-align:-4px;margin:0 6px 0 0;transition:background var(--fast) var(--ease),border-color var(--fast) var(--ease),box-shadow var(--fast) var(--ease)} +input[type=radio]{border-radius:50%} +input[type=checkbox]::after{content:"";width:10px;height:6px;border:2px solid var(--on-accent);border-top:0;border-right:0;transform:translateY(-1px) rotate(-45deg) scale(0);transition:transform var(--fast) var(--ease)} +input[type=radio]::after{content:"";width:8px;height:8px;border-radius:50%;background:var(--on-accent);transform:scale(0);transition:transform var(--fast) var(--ease)} +input[type=checkbox]:checked,input[type=radio]:checked{background:var(--accent);border-color:var(--accent)} +input[type=checkbox]:checked::after{transform:translateY(-1px) rotate(-45deg) scale(1)} +input[type=radio]:checked::after{transform:scale(1)} +input[type=checkbox]:focus-visible,input[type=radio]:focus-visible{box-shadow:0 0 0 4px var(--tint);outline:0} +input[type=checkbox].cbi-input-invalid,input[type=radio].cbi-input-invalid{border-color:var(--bad)} +.cbi-checkbox,.cbi-radio{display:inline-flex;align-items:center;gap:2px;min-height:38px;cursor:pointer} +.cbi-radio{margin-right:14px} +.cbi-checkbox>label,.cbi-radio>label{cursor:pointer} + +/* ============================== map / section / value ============================== */ +.cbi-map{display:block;margin-bottom:22px} +.cbi-section,.cbi-tblsection{background:var(--surface);border:1px solid var(--line-soft);border-radius:var(--r-lg);box-shadow:var(--shadow);padding:22px 24px;margin:0 0 18px;min-width:0;position:relative} +.cbi-section .cbi-section,.cbi-value .cbi-section,.cbi-value .cbi-tblsection,.cbi-section .cbi-tblsection,.modal .cbi-section,.modal .cbi-tblsection,.cbi-section-node .cbi-section{background:none;border:0;border-radius:0;box-shadow:none;padding:0;margin:0 0 10px} +.cbi-section-node{min-width:0} +.cbi-section-node+.cbi-section-node{margin-top:12px} +.cbi-section-error{border:1px solid var(--bad);background:var(--bad-soft);border-radius:var(--r-sm);padding:10px 14px;margin-bottom:14px;color:var(--bad)} +.cbi-section-error ul{margin:0 0 0 18px} +.cbi-value{display:flex;flex-wrap:wrap;align-items:flex-start;gap:6px 24px;padding:14px 0;border-top:1px solid var(--line-soft);min-width:0} +.cbi-section-node>.cbi-value:first-child,.cbi-section-node>[data-tab]>.cbi-value:first-child,.cbi-section-node>.cbi-tabcontainer>.cbi-value:first-child,.cbi-value.hidden+.cbi-value{border-top:0} +.cbi-value>.cbi-value-title{flex:0 0 220px;max-width:100%;padding-top:8px;font-weight:500;color:var(--ink);font-size:14px;line-height:1.45;text-align:left;overflow-wrap:anywhere} +.cbi-value-field{flex:1 1 300px;min-width:0;color:var(--ink-2);line-height:1.45} +.cbi-value-field>span:only-child,.cbi-value-field>div:not([class]):first-child{display:inline-block;padding-top:8px} +.cbi-value-description{display:block;margin-top:6px;color:var(--ink-3);font-size:12.5px;line-height:1.5;max-width:68ch} +.cbi-value-description:empty{display:none} +.cbi-value-helpicon,.cbi-value-helpicon img{display:none} +.cbi-value>.cbi-section,.cbi-value>.cbi-tblsection{flex:1 1 100%} +div.cbi-value var,.td.cbi-value-field var{font-style:normal;color:var(--accent-2);font-family:var(--mono);font-size:.92em} +div.cbi-value var[data-tooltip],.td.cbi-value-field var[data-tooltip],var.cbi-tooltip-container{cursor:help;border-bottom:1px dotted currentColor} +var.cbi-tooltip-container .cbi-tooltip{white-space:normal;font-family:var(--sans)} /* a tooltip inside (firewall rules) must not inherit its mono font */ +.cbi-value-field>input+.cbi-button,.cbi-value-field>select+.cbi-button{margin-left:6px} +.cbi-optionals,.cbi-section-create{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:12px 0 0} +.cbi-section-create>*{flex:0 1 auto;margin:0} +.cbi-section-create>div>input{width:220px} +.cbi-section-remove{text-align:right;margin:0 0 8px} +.cbi-title-ref{color:var(--accent-2)} +.cbi-title-ref::after{content:" →"} +fieldset{min-width:0} +.cbi-progressbar{position:relative;min-width:160px;height:8px;border-radius:99px;background:var(--surface-2);margin:22px 0 6px} +.cbi-progressbar>div{height:100%;width:0;border-radius:99px;background:var(--accent);transition:width var(--data) var(--ease)} +.cbi-progressbar::before{content:attr(title);position:absolute;bottom:13px;left:0;right:0;white-space:pre;overflow:hidden;text-overflow:ellipsis;font-size:13px;color:var(--ink-2);line-height:1.3} + +/* page actions → floating save bar */ +.cbi-page-actions,.actions{position:sticky;bottom:18px;z-index:40;display:flex;flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:8px;margin:18px 0 0;padding:12px 14px;border-radius:16px;background:color-mix(in srgb,var(--surface) 88%,transparent);-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px);border:1px solid var(--line-soft);box-shadow:var(--shadow-pop)} +.cbi-page-actions>form{display:inline-flex;margin:0} +.cbi-page-actions:empty{display:none} +.cbi-page-actions>*{margin:0!important;float:none!important} +.cbi-page-actions .cbi-button-apply{order:3} +.cbi-page-actions .cbi-button-save{order:2} +.cbi-page-actions .cbi-button-reset{order:1;margin-right:auto!important} +@media (max-width:760px){.cbi-page-actions,.actions{bottom:84px}.cbi-page-actions>*{flex:1 1 auto}} + +/* ============================== tabs ============================== */ +.cbi-tabmenu,.tabs{display:flex;flex-wrap:nowrap;gap:4px;list-style:none;margin:0 0 20px;padding:0;overflow-x:auto;scrollbar-width:none;border-bottom:1px solid var(--line-soft);background:none} +.cbi-tabmenu::-webkit-scrollbar,.tabs::-webkit-scrollbar{display:none} +.cbi-tabmenu>li,.tabs>li{flex:none;display:flex;margin:0;padding:0;background:none!important;border:0;border-radius:0;height:auto;max-width:none;color:inherit} +.cbi-tabmenu>li>a,.tabs>li>a{display:block;padding:9px 12px 11px;color:var(--ink-3);font-size:13.5px;font-weight:500;text-decoration:none!important;white-space:nowrap;border-bottom:2px solid transparent;margin-bottom:-1px;transition:color var(--fast) var(--ease),border-color var(--fast) var(--ease);line-height:1.3} +.cbi-tabmenu>li>a:hover,.tabs>li>a:hover{color:var(--ink)} +.cbi-tabmenu>li.cbi-tab>a,.tabs>li.active>a{color:var(--ink);border-bottom-color:var(--accent)} +.cbi-tab-disabled[data-errors]::after{content:attr(data-errors);align-self:center;min-width:18px;height:18px;border-radius:9px;background:var(--bad);color:#fff;font-size:11px;font-weight:600;display:inline-grid;place-items:center;padding:0 5px;margin:0 6px 0 -4px} +#tabmenu .tabs{margin-bottom:22px} +.cbi-tabmenu.map{margin:0 0 18px} +.cbi-tabmenu.map>li>a{font:500 16px var(--serif)} +.cbi-section>.cbi-tabmenu:first-child{margin-top:-8px} +/* a mouse can't scroll sideways: there, tab rows and pills wrap instead of hiding what doesn't fit */ +@media (hover:hover) and (pointer:fine){.cbi-tabmenu,.tabs,.td-pills{flex-wrap:wrap;overflow-x:visible}} +.cbi-tabcontainer{min-width:0} +[data-tab-title]:not([data-tab-active=true]){display:none} +[data-tab-active=true]{animation:td-fade var(--base) var(--ease)} +.tab-content>.tab-pane,.tab-content>div{display:none} +.tab-content>.active{display:block} + +/* ============================== tables ============================== */ +.table{display:table;width:100%;border-collapse:collapse;margin:0 0 14px;font-size:13.5px;position:relative} +.tr{display:table-row} +.th,.td{display:table-cell;vertical-align:middle;text-align:left;padding:11px 12px;border-top:1px solid var(--line-soft);line-height:1.45;overflow-wrap:break-word} +.table .tr:first-child>.th,.table .tr:first-child>.td{border-top:0} +.th,.tr.table-titles .th,.tr.cbi-section-table-titles .th{font-weight:500;font-size:12.5px;color:var(--ink-3);vertical-align:bottom} +.td>.th{color:inherit} +.tr.table-titles,.tr.cbi-section-table-titles,.cbi-rowstyle-2{background:none} +.table .tr:not(.table-titles):not(.cbi-section-table-titles):not(.placeholder):hover>.td{background:var(--tint)} +.table[width="33%"],.th[width="33%"],.td[width="33%"]{width:33%} +.table[width="100%"],.th[width="100%"],.td[width="100%"]{width:100%} +.tr.placeholder{height:calc(3em + 22px)} +.tr.placeholder>.td{position:absolute;left:0;right:0;bottom:0;text-align:center;line-height:3em;color:var(--ink-3);border:0;font-style:normal} +.tr.drag-over-above{box-shadow:inset 0 2px 0 var(--accent)} +.tr.drag-over-below{box-shadow:inset 0 -2px 0 var(--accent)} +.cbi-section-table .tr.cbi-section-table-descr .th{font-weight:400;color:var(--ink-3)} +.cbi-section-table-titles.named::before,.cbi-section-table-descr.named::before,.cbi-section-table-row[data-title]::before{content:attr(data-title) " ";display:table-cell;font-weight:500;vertical-align:middle;border-top:1px solid var(--line-soft);padding:11px 12px;color:var(--ink)} +.cbi-section-table-titles.named::before{border-top:0;color:var(--ink-3);font-size:12.5px} +/* editable grids (one control per cell): tighter gutters, controls shrink to a usable minimum */ +.table.cbi-section-table .th,.table.cbi-section-table .td{padding-left:8px;padding-right:8px} +.table.cbi-section-table input[type=password],.table.cbi-section-table input[type=text],.table.cbi-section-table textarea,.table.cbi-section-table select,.table.cbi-section-table .cbi-select{width:100%;min-width:64px} +/* action buttons keep to one row while there is room and wrap instead of pushing out of the card */ +:is(.th,.td).cbi-section-actions{text-align:right;white-space:normal} +.td.cbi-section-actions>*{display:inline-flex;justify-content:flex-end;align-items:center;gap:6px;white-space:nowrap} +.td.cbi-section-actions>*>form{display:inline-flex;gap:6px;margin:0} +.td.cbi-section-actions .cbi-button{min-height:32px;padding:0 12px;font-size:13px} +.td.cbi-section-actions .drag-handle{width:24px;padding:0;border:0;background:none;box-shadow:none;color:var(--ink-3);cursor:grab} +.td.cbi-section-actions .drag-handle:hover{background:var(--hover);color:var(--ink)} +.td>input[type=text],.td>input[type=password],.td>select,.td>.cbi-dropdown:not(.btn):not(.cbi-button){width:100%} +th[data-sortable-row]{cursor:pointer} +th[data-sort-direction=asc]::after{content:"\a0\25B4"} +th[data-sort-direction=desc]::after{content:"\a0\25BE"} +table table td,.cbi-value-field table td{border:0} +.cbi-section>.table:last-child,.cbi-section-node>.table:last-child{margin-bottom:0} + +/* ============================== dropdown (ui.Dropdown) ============================== */ +.cbi-dropdown{display:inline-flex!important;position:relative;cursor:pointer;height:auto;min-height:38px;padding:0!important;align-items:stretch} +.cbi-dropdown:not(.btn):not(.cbi-button){min-width:220px;height:38px} +.cbi-dropdown:not(.btn):not(.cbi-button)>ul:not(.dropdown)>li{align-self:stretch;padding-block:0} +.cbi-dropdown:not(.btn):not(.cbi-button)>.open{width:30px;padding:0;font-size:0;background:var(--chevron) no-repeat center} +.cbi-dropdown:not(.btn):not(.cbi-button)>.more{text-align:left;padding:0 11px} +.cbi-dropdown>ul{list-style:none;display:flex;overflow:hidden;width:100%;margin:0!important;padding:0} +.cbi-dropdown.btn>ul:not(.dropdown),.cbi-dropdown.cbi-button>ul:not(.dropdown){margin:0 0 0 2px!important} +.cbi-dropdown.btn.spinning>ul:not(.dropdown),.cbi-dropdown.cbi-button.spinning>ul:not(.dropdown){margin:0!important} +.cbi-dropdown>.open,.cbi-dropdown>.more{flex:0 0 auto;display:flex;flex-direction:column;justify-content:center;text-align:center;padding:0 10px;color:var(--ink-3);font-size:12px} +.cbi-dropdown.btn>.open,.cbi-dropdown.cbi-button>.open{margin-left:8px;border-left:1px solid color-mix(in srgb,currentColor 30%,transparent);padding:0 0 0 10px;color:inherit} +.cbi-dropdown>.more,.cbi-dropdown:not(.btn):not(.cbi-button)>ul>li[placeholder]{color:var(--ink-3);display:none} +.cbi-dropdown>ul>li{display:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1 1 auto;align-items:center;align-self:center;color:inherit;gap:6px} +.cbi-dropdown>ul.dropdown>li,.cbi-dropdown:not(.btn):not(.cbi-button)>ul>li{min-height:22px;color:var(--ink);padding:7px 11px} +.cbi-dropdown>ul>li[display]:not([display="0"]){border-left:1px solid var(--line-soft)} +.cbi-dropdown[empty]>ul{max-width:1px} +.cbi-dropdown>ul>li>form{display:none;pointer-events:none;margin:0;padding:0} +.cbi-dropdown>ul>li img{vertical-align:middle;margin-right:4px;max-height:1.4em;width:auto} +.cbi-dropdown>ul>li>form>input[type=checkbox]{margin:0 6px 0 0} +.cbi-dropdown>ul>li input[type=text]{height:30px} +.cbi-dropdown[open]{position:relative} +.cbi-dropdown[open]>ul.dropdown{display:block;position:absolute;z-index:1100;min-width:100%;width:auto;max-width:none;overflow-y:auto;background:var(--surface);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow-pop);padding:4px!important;transition:max-height var(--fast) var(--ease)} +.cbi-dropdown>ul>li[display],.cbi-dropdown[open]>ul.preview,.cbi-dropdown[open]>ul.dropdown>li,.cbi-dropdown[multiple]>ul>li>label,.cbi-dropdown[multiple][open]>ul.dropdown>li,.cbi-dropdown[multiple][more]>.more,.cbi-dropdown[multiple][empty]>.more{flex-grow:1;display:flex!important} +.cbi-dropdown[empty]>ul>li,.cbi-dropdown[optional][open]>ul.dropdown>li[placeholder]{display:block!important} +.cbi-dropdown[multiple][open]>ul.dropdown>li>form{display:flex!important} +.cbi-dropdown[open]>ul.dropdown>li{border-radius:8px;border:0} +.cbi-dropdown[open]>ul.dropdown>li.focus,.cbi-dropdown[open]>ul.dropdown>li:hover{background:var(--hover);color:var(--ink)} +.cbi-dropdown[open]>ul.dropdown>li[selected]{background:var(--accent-soft);color:var(--ink)} +.cbi-dropdown[open]>ul.dropdown>li[unselectable]{opacity:.6} +.cbi-dropdown>ul.preview,.cbi-dropdown>ul>li .hide-close,.cbi-dropdown[open]>ul.dropdown>li .hide-open{display:none} +.cbi-dropdown>ul>li .hide-open,.cbi-dropdown[open]>ul.dropdown>li .hide-close{display:initial} +.cbi-dropdown[open]>ul.dropdown>li>input.create-item-input:first-child:last-child{width:100%} +.cbi-dropdown.btn,.cbi-dropdown.cbi-button{padding:0 12px!important;min-height:36px} +.cbi-dropdown.btn>ul>li,.cbi-dropdown.cbi-button>ul>li{padding:0} +.cbi-dropdown.btn[open]>ul.dropdown>li,.cbi-dropdown.cbi-button[open]>ul.dropdown>li{padding:7px 11px;color:var(--ink)} + +/* dynamic list */ +.cbi-dynlist{display:inline-flex;flex-direction:column;gap:6px;min-width:220px;width:280px;max-width:100%;height:auto;min-height:38px;line-height:1.4} +.cbi-dynlist>.item{position:relative;display:flex;align-items:center;min-height:36px;padding:7px 38px 7px 11px;border:1px solid var(--line);border-radius:var(--r-sm);background:var(--surface);color:var(--ink);word-break:break-all;cursor:move;user-select:text;font-size:13.5px} +.cbi-dynlist>.item::after{content:"×";position:absolute;top:0;right:0;bottom:0;width:34px;display:grid;place-items:center;color:var(--ink-3);font-size:17px;cursor:pointer;border-left:1px solid var(--line-soft);pointer-events:auto} +.cbi-dynlist>.item:hover::after{color:var(--bad)} +.cbi-dynlist>.item.dragging{opacity:.5} +.cbi-dynlist>.item>span{pointer-events:none} +.cbi-dynlist>.add-item{display:flex;gap:6px} +.cbi-dynlist>.add-item>input,.cbi-dynlist>.add-item>.cbi-dropdown{flex:1 1 auto;min-width:0;width:auto} +.cbi-dynlist>.add-item>.cbi-button{flex:0 0 auto} +.cbi-dynlist>.item.drag-over,.cbi-dynlist>.add-item>.cbi-input-text.drag-over,.cbi-dynlist>.add-item>.cbi-button-add.drag-over{border-top:2px solid var(--accent)} +[data-page=admin-system-admin-sshkeys] .cbi-dynlist{width:100%} + +/* file browser */ +.cbi-filebrowser{min-width:220px;max-width:100%;border:1px solid var(--line);border-radius:var(--r-sm);display:flex;flex-direction:column;opacity:0;height:0;overflow:hidden;background:var(--surface);margin-top:6px} +.cbi-filebrowser.open{height:auto;opacity:1;overflow:visible;transition:opacity var(--base) var(--ease)} +.cbi-filebrowser>*{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;border-bottom:1px solid var(--line-soft);margin:6px 8px 0;padding:0 0 6px} +.cbi-filebrowser>div{border-bottom:0} +.cbi-filebrowser>ul{list-style:none} +.cbi-filebrowser>ul>li{display:flex;align-items:center;padding:3px 4px;border-radius:6px} +.cbi-filebrowser>ul>li:hover{background:var(--hover)} +.cbi-filebrowser>ul>li>div:first-child{flex:10;overflow:hidden;text-overflow:ellipsis} +.cbi-filebrowser>ul>li>div:last-child{flex:3;text-align:right} +.cbi-filebrowser>ul>li>div:last-child>button{margin-left:4px;min-height:26px;padding:0 8px;font-size:12px} +.cbi-filebrowser .upload{display:flex;flex-wrap:wrap;gap:6px} +.cbi-filebrowser .upload>*{flex:1} +.cbi-filebrowser .upload>div{flex:10;min-width:150px} +.cbi-filebrowser .upload>div>input{width:100%} + +/* ============================== modal ============================== */ +#modal_overlay{position:fixed;inset:0;z-index:900;overflow:auto;background:rgba(20,20,19,.45);-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);opacity:0;visibility:hidden;transition:opacity var(--fast) var(--ease),visibility 0s var(--fast)} +body.modal-overlay-active{overflow:hidden;height:100vh} +body.modal-overlay-active #modal_overlay{opacity:1;visibility:visible;transition:opacity var(--base) var(--ease)} +.modal{width:92%;max-width:600px;min-width:270px;margin:9vh auto 5vh;padding:26px 28px 22px;background:var(--surface);color:var(--ink);border:1px solid var(--line-soft);border-radius:22px;box-shadow:var(--shadow-pop);transform:translateY(6px);transition:transform var(--fast) var(--ease);line-height:1.6;container:main/inline-size} +/* the dialog stays in the DOM, so it eases in and out with transitions rather than a one-shot animation */ +body.modal-overlay-active .modal{transform:none;transition-duration:var(--base)} +.modal>*{margin-bottom:12px;max-width:100%} +.modal>*:last-child{margin-bottom:0} +.modal>h4{font:500 22px/1.25 var(--serif);margin:0 0 14px} +.modal>pre,.modal>textarea{white-space:pre-wrap;overflow:auto;width:100%;max-height:50vh} +.modal .button-row,.modal>.right{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px;margin-top:20px} +.modal .button-row>button:first-of-type{margin-right:auto} +.modal>.right>*{margin:0!important} +.modal .cbi-value>.cbi-value-title{flex-basis:160px} +#modal_overlay>.modal.uci-dialog,#modal_overlay>.modal.cbi-modal{max-width:900px} +#modal_overlay>.modal.login>button.important{width:100%;min-height:46px;font-size:15px;margin-top:10px} +.modal.alert-message{color:var(--ink)} + +/* uci changes dialog */ +.uci-change-legend{display:flex;flex-wrap:wrap;gap:8px 18px;margin-bottom:14px} +.uci-change-legend-label{display:flex;align-items:center;gap:6px;font-size:12.5px;color:var(--ink-2)} +.uci-change-legend-label>ins,.uci-change-legend-label>del,.uci-change-legend-label>var{width:14px;height:14px;border-radius:4px;display:inline-block} +.uci-change-list{line-height:1.7;white-space:pre;overflow:auto;max-height:55vh;font-family:var(--mono);font-size:12.5px} +.uci-change-list>var>*{overflow:hidden;text-overflow:ellipsis} +.uci-dialog del,.uci-dialog ins,.uci-dialog var{text-decoration:none;font-family:var(--mono);font-style:normal;color:var(--ink);display:block} +.uci-dialog div>del,.uci-dialog div>ins,.uci-dialog div>var{margin-bottom:3px;border:1px solid var(--line);border-radius:6px;padding:3px 8px;background:var(--canvas);overflow:hidden;text-overflow:ellipsis} +.uci-dialog div>ins,.uci-change-legend-label>ins{background:var(--ok-soft);border-color:color-mix(in srgb,var(--ok) 40%,transparent)} +.uci-dialog div>del,.uci-change-legend-label>del{background:var(--bad-soft);border-color:color-mix(in srgb,var(--bad) 40%,transparent)} +.uci-change-legend-label>var{background:var(--surface-2);border:1px solid var(--line)} +.uci-dialog var>ins{background:var(--ok-soft)} +.uci-dialog var>del{background:var(--bad-soft)} + +/* ============================== alerts, labels, tooltips ============================== */ +.alert-message{position:relative;margin:0 0 16px;padding:14px 18px;border-radius:var(--r-md);background:var(--warn-soft);color:var(--ink);border:1px solid color-mix(in srgb,var(--warn) 35%,transparent);line-height:1.55;white-space:unset;animation:td-rise var(--base) var(--ease)} +.alert-message h4,.alert-message h5,.alert-message p,.alert-message pre,.alert-message ul,.alert-message li{color:inherit;background:transparent;border:0;margin:4px 0;padding:0} +.alert-message h4{font:500 16px var(--serif)} +.alert-message pre{white-space:pre-wrap;font-size:12px} +.alert-message .right{display:flex;justify-content:flex-end;gap:8px;margin-top:8px} +.alert-message.success{background:var(--ok-soft);border-color:color-mix(in srgb,var(--ok) 35%,transparent)} +.alert-message.info,.alert-message.notice{background:var(--accent-soft);border-color:color-mix(in srgb,var(--accent) 30%,transparent)} +.alert-message.error,.alert-message.danger{background:var(--bad-soft);border-color:color-mix(in srgb,var(--bad) 35%,transparent)} +.label{display:inline-flex;align-items:center;gap:4px;height:22px;padding:0 9px;border-radius:99px;font-size:11.5px;font-weight:500;background:var(--surface-2);color:var(--ink-2);white-space:nowrap;margin:2px 0 2px 4px;vertical-align:middle;text-transform:none;text-decoration:none!important} +.label.important,.label.notice{background:var(--accent-soft);color:var(--accent-2)} +.label.warning{background:var(--warn-soft);color:var(--warn)} +.label.success{background:var(--ok-soft);color:var(--ok)} +.label.error,.label.danger{background:var(--bad-soft);color:var(--bad)} +.cbi-tooltip-container{cursor:help} +.cbi-tooltip{position:absolute;z-index:1000;left:-10000px;padding:8px 12px;border-radius:10px;background:var(--ink);color:var(--canvas);font-size:12.5px;line-height:1.5;white-space:pre;box-shadow:var(--shadow-pop);opacity:0;transition:opacity var(--fast) var(--ease);max-width:min(420px,90vw)} +.cbi-tooltip-container:hover .cbi-tooltip:not(:empty){left:auto;opacity:1} +.cbi-tooltip.error{background:var(--bad);color:#fff} +.cbi-tooltip.success{background:var(--ok);color:#fff} +.cbi-tooltip.info,.cbi-tooltip.notice{background:var(--accent-2);color:var(--on-accent)} + +/* spinner */ +.spinning{position:relative;padding-left:32px!important} +.spinning::before{content:"";position:absolute;top:calc(50% - 9px);left:7px;width:18px;height:18px;border-radius:50%;border:2px solid color-mix(in srgb,currentColor 25%,transparent);border-top-color:currentColor;animation:td-spin .8s linear infinite} +#view>div.spinning:first-child{display:table;margin:16vh auto;color:var(--ink-3)} +@keyframes td-spin{to{transform:rotate(360deg)}} +.flash{animation:td-flash var(--slow) var(--ease)} +@keyframes td-flash{50%{opacity:.5}} +/* LuCI drops a dismissed notice on transitionend, so this must be a transition */ +.fade-in,.fade-out{transition:opacity var(--base) var(--ease)} +.fade-out{opacity:0} + +/* ============================== network badges ============================== */ +.ifacebox{display:inline-flex;flex-direction:column;min-width:110px;margin:0 8px;text-align:center;white-space:nowrap;line-height:1.3;background:var(--surface);border:1px solid var(--line);border-radius:12px;overflow:hidden} +.ifacebox .ifacebox-head{padding:5px 8px;background:var(--surface-2);color:var(--ink);font-weight:500;border-bottom:1px solid var(--line-soft)} +.ifacebox .ifacebox-head.active{background:var(--accent-2);color:var(--on-accent)} +.ifacebox .ifacebox-body{padding:8px;font-size:12.5px;color:var(--ink-2)} +.ifacebox-body img{width:20px;height:20px;vertical-align:middle;margin:0 1px} +#cbi-network-interface .ifacebox-body{white-space:normal} +.ifacebadge{display:inline-flex;align-items:center;gap:6px;padding:4px 9px;border-radius:9px;border:1px solid var(--line);background:var(--surface);color:var(--ink);white-space:nowrap;cursor:default;line-height:1.3;font-size:12.5px} +.ifacebadge img{width:18px!important;height:18px!important;flex:none} /* LuCI sets 32px inline on some */ +.ifacebadge-active{border-color:var(--accent);font-weight:500} +.network-status-table{display:flex;flex-wrap:wrap;gap:12px} +.network-status-table .ifacebox{flex:1 1 260px;margin:0;text-align:left} +.network-status-table .ifacebox-body{display:flex;flex-direction:column;height:100%;text-align:left;gap:6px;padding:10px 12px} +.network-status-table .ifacebox-body>span{flex:10 10 auto;height:100%;white-space:normal} +.network-status-table .ifacebox-body>div{display:flex;flex-wrap:wrap;gap:4px} +.network-status-table .ifacebox-body .ifacebadge{display:flex;flex:1;min-width:200px;padding:6px 8px} +.network-status-table .ifacebox-body .ifacebadge>span{overflow:hidden;text-overflow:ellipsis} +.ifacebadge.large{display:inline-flex;flex:1;min-width:200px;padding:6px 8px} +#dsl_status_table .ifacebox-body span>strong{display:inline-block;min-width:35%} +.zonebadge{display:inline-flex;align-items:center;gap:4px;padding:3px 9px;border-radius:9px;white-space:nowrap;color:var(--ink);font-size:12.5px;line-height:1.4} +.zonebadge>em,.zonebadge>strong{display:inline-block;margin:0 2px} +.zonebadge input{width:6em} +.zonebadge>.ifacebadge{margin-left:2px;padding:1px 6px} +.zonebadge-empty{border:1px dashed var(--line);color:var(--ink-3);font-style:italic} +.zonebadge .cbi-tooltip{background:inherit;color:inherit;padding:2px;margin:-1.6em 0 0 -5px;pointer-events:none} +.zonebadge .cbi-tooltip>*{position:relative;z-index:2;margin:1px} +/* firewall zone colours are LuCI's saturated pastels; tone them into the palette in both modes */ +.zonebadge[style*="--zone-color-rgb"],.ifacebox-head[style*="--zone-color-rgb"]{background:rgba(var(--zone-color-rgb),.4)!important;color:var(--ink)} +.zone-forwards{display:flex;flex-wrap:nowrap;align-items:center;gap:4px} +.zone-forwards>*{flex:1 1 40%;text-wrap:wrap} +.zone-forwards>span{flex-basis:10%;text-align:center;color:var(--ink-3)} +.zone-forwards .zone-src,.zone-forwards .zone-dest{display:flex;flex-direction:column;gap:3px} + +/* status text LuCI writes as "Label: value", "title", "note" and " | " */ +:is(.td[data-name=_stat],.td[data-name=_ifacestat],.ifacebox-body,.ifacebadge) strong{font-weight:400;color:var(--ink-3)} +:is(.td[data-name=_stat],.td[data-name=_ifacestat],.ifacebox-body,.ifacebadge,.assoclist) em{font-style:normal;color:var(--ink-3)} +.td[data-name=_stat]{font-size:13px;line-height:1.7} +.td[data-name=_stat]>div{color:color-mix(in srgb,var(--ink-3) 45%,transparent)} +.td[data-name=_stat] .nowrap{color:var(--ink-2);margin-right:4px} +.td[data-name=_stat] big{display:block;font-size:14.5px;line-height:1.5} +.td[data-name=_stat] big strong{font-weight:500;color:var(--ink)} +.td[data-name=_badge] .ifacebadge{min-width:96px;justify-content:center} + +/* associated stations: badge text wraps only between words */ +.assoclist .ifacebadge{white-space:normal;word-break:keep-all;text-align:left;max-width:100%} +.assoclist .ifacebadge small{display:none} /* interface name; the badge title still has it */ +.assoclist .th,.assoclist .td{padding-left:8px;padding-right:8px} +.assoclist .td:nth-of-type(5){width:28%} +.assoclist .td:nth-of-type(2){white-space:nowrap;font-variant-numeric:tabular-nums} +/* details folding (7dollar/tidy.js): engineering fields stay hidden until the section is opened */ +.td-sec:not(.td-open) .td-x{display:none!important} +.td-y,.td-sec.td-open .td-y{display:none}.td-sec:not(.td-open) .td-y{display:inline} +.td-sec:not(.td-open) .ifacebadge[data-ssid]>span{display:none} +.td-sec:not(.td-open) .ifacebadge[data-ssid]::after{content:attr(data-ssid)} +.td-sec-h{display:flex;align-items:center;gap:8px} +.td-detail{margin-left:auto;display:inline-flex;align-items:center;gap:2px;height:28px;padding:0 6px 0 10px;border:0;border-radius:99px;background:none;color:var(--ink-3);font:500 12.5px/1 var(--sans);cursor:pointer;transition:background var(--fast) var(--ease),color var(--fast) var(--ease)} +.td-detail:hover{background:var(--surface-2);color:var(--ink)} +.td-detail .i{width:14px;height:14px;transform:rotate(90deg);transition:transform var(--base) var(--ease)} +.td-detail[aria-expanded=true] .i{transform:rotate(-90deg)} +/* tidy.js: details fade out before the section shrinks; whatever appears as it resizes fades in */ +.td-showing :is(.td-x,.td-y){animation:td-fade var(--base) var(--ease) both} +.td-hiding .td-x{animation:td-out var(--fast) var(--ease) both} + +/* native LuCI icons (): hide the bitmap, paint a palette-tinted line icon */ +@supports (mask-size:contain) or (-webkit-mask-size:contain){ + img[src*="/resources/icons/"]{object-position:-99em 0;background:var(--ink-2);-webkit-mask:var(--ic) center/contain no-repeat;mask:var(--ic) center/contain no-repeat} + img[src*="/icons/ethernet"],img[src*="/icons/port_"]{--ic:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 5h14a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1h-3v3H8v-3H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1z'/%3E%3Cpath d='M8.5 9v2.5M12 9v2.5M15.5 9v2.5'/%3E%3C/svg%3E")} + img[src*="/icons/wifi"]{--ic:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2.5 9.5a14 14 0 0 1 19 0M5.8 13a9.3 9.3 0 0 1 12.4 0M9.2 16.4a4.6 4.6 0 0 1 5.6 0'/%3E%3Ccircle cx='12' cy='19.6' r='.6' fill='%23000'/%3E%3C/svg%3E")} + img[src*="/icons/bridge"]{--ic:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 18h18M3 10c3 0 6-3.5 9-3.5s6 3.5 9 3.5M6 9.4V18M12 6.5V18M18 9.4V18'/%3E%3C/svg%3E")} + img[src*="/icons/switch"]{--ic:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='7' width='18' height='10' rx='2.5'/%3E%3Cpath d='M7 12h0M10.3 12h0M13.7 12h0M17 12h0' stroke-width='2.4'/%3E%3C/svg%3E")} + img[src*="/icons/vlan"]{--ic:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3.5 12.2V5a1.5 1.5 0 0 1 1.5-1.5h7.2L20.5 12 12 20.5z'/%3E%3Ccircle cx='8.2' cy='8.2' r='1.3'/%3E%3C/svg%3E")} + img[src*="/icons/alias"]{--ic:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 14a4 4 0 0 0 5.7 0l3-3A4 4 0 0 0 13 5.3l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1'/%3E%3C/svg%3E")} + img[src*="/icons/tunnel"]{--ic:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 20v-8a9 9 0 0 1 18 0v8M7.5 20v-7.5a4.5 4.5 0 0 1 9 0V20M2 20h20'/%3E%3C/svg%3E")} + img[src*="/icons/wireguard"]{--ic:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 3l7.5 3v5.5c0 4.6-3.2 8-7.5 9.5-4.3-1.5-7.5-4.9-7.5-9.5V6z'/%3E%3Cpath d='M9.2 12.2h5.6v4H9.2zM10.4 12.2v-1.4a1.6 1.6 0 0 1 3.2 0v1.4'/%3E%3C/svg%3E")} + img[src*="/icons/vrf"]{--ic:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 3.5l8.5 4.5L12 12.5 3.5 8zM3.5 12.5 12 17l8.5-4.5M3.5 16.5 12 21l8.5-4.5'/%3E%3C/svg%3E")} + img[src*="/icons/loading"]{--ic:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 3a9 9 0 1 0 9 9' stroke-width='2.2'/%3E%3C/svg%3E")} + img[src*="/icons/signal-"]{--ic:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cg fill='%23000' stroke='none'%3E%3Crect x='3' y='15' width='3.4' height='5' rx='1'/%3E%3Crect x='8.2' y='11.5' width='3.4' height='8.5' rx='1'/%3E%3Crect x='13.4' y='8' width='3.4' height='12' rx='1'/%3E%3Crect x='18.6' y='4' width='3.4' height='16' rx='1'/%3E%3C/g%3E%3C/svg%3E")} + img[src*="/icons/wifi."],img[src*="/icons/port_up"]{background:var(--accent)} + img[src*="_disabled."],img[src*="/icons/port_down"]{background:var(--ink-3);opacity:.55} + img[src*="/icons/signal-"]{background:linear-gradient(90deg,var(--accent) var(--lv,0%),color-mix(in srgb,var(--ink-3) 35%,transparent) 0)} + img[src*="signal-000-025"]{--lv:30%}img[src*="signal-025-050"]{--lv:52%}img[src*="signal-050-075"]{--lv:74%}img[src*="signal-075-100"]{--lv:100%} + img[src*="/icons/loading"]{background:var(--accent);animation:td-spin .8s linear infinite} +} +#view>.table{display:table;border-collapse:separate;border-spacing:0;background:var(--surface);border:1px solid var(--line-soft);border-radius:var(--r-lg);box-shadow:var(--shadow);padding:10px 14px;margin-bottom:18px} + +/* logs, graphs, misc */ +#syslog{width:100%;min-height:60vh;font-size:12.5px;background:var(--surface);border:1px solid var(--line-soft);color:var(--ink);border-radius:var(--r-md)} +[data-theme=dark] [data-page=admin-statistics-graphs] [data-plugin] img{filter:invert(100%) hue-rotate(150deg)} +[data-page^=admin-status-realtime] #view svg line[style],[data-page=admin-status-channel_analysis] #view svg line[style]{stroke:var(--line)!important} +[data-page^=admin-status-realtime] #view div[style*="background"],[data-page=admin-status-channel_analysis] #view div[style*="background"]{background:var(--surface)!important;border-radius:var(--r-md)} +[data-page^=admin-status-realtime] #view svg text,[data-page=admin-status-channel_analysis] #view svg text{fill:var(--ink-3)} +#applyreboot-section{line-height:300%} +/* diagnostics: LuCI spaces host + action with an inline 5px margin sized for its own, shorter controls */ +[data-page=admin-network-diagnostics] .td>input{display:block;width:100%;margin:0 0 8px!important} +.breadcrumb,#modemenu{display:none!important} +.td-footer{margin-top:40px;padding-top:18px;border-top:1px solid var(--line-soft);font-size:12px;color:var(--ink-3);display:flex;flex-wrap:wrap;gap:6px 14px;justify-content:space-between} +.td-footer a{color:var(--ink-3)} + +/* ============================== narrow content: LuCI tables & values ============================== + Keyed to the width of the content area, not the viewport: the icon rail at 761–1100px leaves + less room than a phone, so tables turn into cards wherever they would not fit. Modals are + containers too, so dialogs follow their own width. */ +@container main (max-width:700px){ + .cbi-section,.cbi-tblsection{padding:18px 16px;border-radius:18px} + .table,#view>.table{display:flex;flex-direction:column} + .tr{display:flex;flex-wrap:wrap;align-items:flex-end;border-top:1px solid var(--line-soft);padding:8px 0} + .table .tr:first-child{border-top:0} + .table .th,.table .td,.table .tr::before{flex:1 1 50%;align-self:flex-start;display:inline-block;border-top:0!important;padding:4px 6px;overflow:hidden;text-overflow:ellipsis} + .table .td.cbi-dropdown-open{overflow:visible} + .tr.table-titles,.tr.cbi-section-table-titles,.cbi-section-table-descr{display:none} + .tr[data-title]::before{display:block;flex:1 1 100%;font-size:15px;font-weight:500;padding:4px 6px} + .td[data-title]::before{content:attr(data-title);display:block;font-size:12px;color:var(--ink-3);font-weight:400;margin-bottom:2px;white-space:pre-line} + .td.cbi-section-actions{flex-basis:100%;max-width:100%;width:auto;text-align:left;padding:6px} + .td.cbi-section-actions>*{display:flex;justify-content:flex-start} + .td.cbi-section-actions .cbi-button:not(.drag-handle){flex:1 1 auto} + .table .tr:hover>.td{background:none!important} + .col-1{flex:1 1 30px!important}.col-2{flex:2 2 60px!important}.col-3{flex:3 3 90px!important}.col-4{flex:4 4 120px!important}.col-5{flex:5 5 150px!important}.col-6{flex:6 6 180px!important}.col-7{flex:7 7 210px!important}.col-8{flex:8 8 240px!important}.col-9{flex:9 9 270px!important}.col-10{flex:10 10 300px!important} + .hide-sm,.hide-xs{display:none!important} + #wifi_assoclist_table .td,[data-page=admin-status-processes] .td{flex-basis:50%!important} + [data-page=admin-network-network] .td[data-name=_ifacebox]{flex-basis:40%} + [data-page=admin-network-network] .td[data-name=_ifacestat]{flex-basis:60%} + [data-page=admin-status-overview] .td[width="33%"]{flex-basis:33%} +} +@container main (max-width:560px){ + .table .td.cbi-value-field{flex-basis:100%;width:auto} /* flex-basis sizes cells; not the form-row width below */ + .td.cbi-value-field[data-widget="CBI.DummyValue"],.td.cbi-value-field[data-widget="CBI.Button"],.td.cbi-value-field[data-widget="CBI.FlagValue"],.td[data-widget=dvalue],.td[data-widget=button],.td[data-widget=fvalue]{flex-basis:50%} + .table .td[data-name=_badge]{flex:0 0 auto} + .table .td[data-name=_stat]{flex:1 1 0} + .cbi-value{display:block;padding:12px 0} + .cbi-value>.cbi-value-title{display:block;padding:0 0 6px} + .cbi-value-field,input,select,textarea,.cbi-select,.cbi-dynlist,.cbi-dropdown:not(.btn):not(.cbi-button){width:100%} + input[type=checkbox],input[type=radio]{width:18px} + input.cbi-input-password{width:calc(100% - 46px)} +} +@media (max-width:760px){ + input,select,textarea,.cbi-dropdown:not(.btn):not(.cbi-button)>ul>li{font-size:16px} + .modal{margin:4vh auto;padding:22px 18px 18px;border-radius:20px;width:94%} + .modal .button-row>button:first-of-type{margin-right:0} + .modal .button-row>*{flex:1 1 auto} +} + +/* ============================== 7Dollar pages ============================== */ +.td-head{display:flex;flex-wrap:wrap;align-items:flex-end;justify-content:space-between;gap:16px 24px;margin-bottom:26px} +.td-head>div{min-width:0;flex:1 1 320px} +.td-eyebrow{color:var(--ink-3);font-size:13px;margin:0 0 6px} +.td-head h1{margin:0} +.td-sub{color:var(--ink-2);margin:8px 0 0;font-size:15px} +/* Home: connection and live traffic share the top row (same height: the chart stretches). + Below, two columns stack cards at their own height (home.js balances them), so no card is stretched + to match a neighbour and left half empty. Phones get one column in reading order (--i). */ +.td-grid{display:flex;flex-direction:column;gap:18px} +.td-pair,.td-cols{display:grid;gap:18px;min-width:0} +.td-col{display:flex;flex-direction:column;gap:18px;min-width:0} +@container main (min-width:640px){.td-cols{grid-template-columns:1fr 1fr;align-items:start}.td-col:only-child{grid-column:1/-1}} +@container main (min-width:980px){.td-pair,.td-cols{grid-template-columns:7fr 5fr}.td-pair>:only-child{grid-column:1/-1}} +@container main (max-width:639.98px){ + .td-cols{display:flex;flex-direction:column}.td-col{display:contents}.td-col>.td-card{order:var(--i)} +} +.td-card{background:var(--surface);border:1px solid var(--line-soft);border-radius:var(--r-lg);box-shadow:var(--shadow);padding:22px 24px;position:relative;overflow:hidden;min-width:0;container-type:inline-size;animation:td-rise var(--slow) var(--ease) both} +.td-grid .td-card{animation-delay:min(calc(var(--i,0) * 40ms),.24s)} +.td-card-h{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:18px;min-width:0} +.td-card-h h3{font:500 13.5px/1.3 var(--sans);color:var(--ink-2);margin:0} +.td-card-h a{color:var(--ink-3)!important;font-size:13px;text-decoration:none!important;display:flex;align-items:center;gap:2px;white-space:nowrap} +.td-card-h a:hover{color:var(--accent)!important} +.td-card-h a svg{width:14px;height:14px} +.td-card-h .td-muted{font-size:12.5px} +.td-chip{display:inline-flex;align-items:center;gap:6px;height:24px;padding:0 10px;border-radius:99px;font-size:12px;font-weight:500;background:var(--ok-soft);color:var(--ok);white-space:nowrap} +.td-chip.mute{background:var(--surface-2);color:var(--ink-3)} +.td-chip.acc{background:var(--accent-soft);color:var(--accent-2)} +.td-chip.warn{background:var(--warn-soft);color:var(--warn)} +.td-chip.bad{background:var(--bad-soft);color:var(--bad)} +.td-dot{width:7px;height:7px;border-radius:50%;background:currentColor;flex:none} +.td-dot.live{animation:td-pulse 2.4s infinite} +@keyframes td-pulse{0%{box-shadow:0 0 0 0 color-mix(in srgb,var(--ok) 45%,transparent)}70%,100%{box-shadow:0 0 0 7px transparent}} +.td-big{font:500 clamp(30px,9cqi,40px)/1 var(--serif);letter-spacing:-.02em} +.td-unit{font-size:14px;color:var(--ink-3);margin-left:6px} + +/* topology: pure flex, reflows on zoom */ +.td-topo{display:flex;align-items:flex-start;gap:4px} +.td-node{flex:0 0 auto;width:clamp(64px,22cqi,120px);display:flex;flex-direction:column;align-items:center;text-align:center;min-width:0} +.td-node .ic{--s:clamp(46px,13cqi,58px);width:var(--s);height:var(--s);border-radius:30%;display:grid;place-items:center;background:var(--surface-2);color:var(--ink-2)} +.td-node.me .ic{background:var(--accent);color:var(--on-accent);box-shadow:0 10px 24px -12px var(--accent)} +.td-node.bad .ic{background:var(--bad-soft);color:var(--bad)} +.td-node .ic svg{width:24px;height:24px} +.td-node b{font-weight:500;margin-top:10px;font-size:14px;max-width:100%;overflow-wrap:anywhere} +.td-node small{color:var(--ink-3);font-size:12.5px} +.td-link{flex:1 1 0;min-width:18px;display:flex;flex-direction:column;align-items:center;gap:6px;padding-top:clamp(6px,2.6cqi,12px)} +.td-link .t{font-size:12px;color:var(--ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%;line-height:17px} +.td-link .ln{position:relative;width:100%;height:2px;background:repeating-linear-gradient(90deg,var(--line) 0 6px,transparent 6px 11px)} +.td-link .ln i{position:absolute;top:-3px;width:8px;height:8px;border-radius:50%;background:var(--accent);animation:td-flow 2.6s linear infinite} +.td-link .ln i+i{animation-delay:1.3s;background:var(--second)} +.td-link.down .ln i{display:none} +.td-link.down .ln{background:repeating-linear-gradient(90deg,var(--bad) 0 6px,transparent 6px 11px);opacity:.6} +@keyframes td-flow{from{left:0;opacity:0}15%,85%{opacity:1}to{left:calc(100% - 8px);opacity:0}} +@container (max-width:430px){.td-link .t{visibility:hidden}.td-node small{display:none}} +.td-facts{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:14px 8px;margin:22px 0 0;padding-top:18px;border-top:1px solid var(--line-soft)} +.td-facts dt{font-size:12.5px;color:var(--ink-3);font-weight:400} +.td-facts dd{font-size:14.5px;margin:3px 0 0;overflow-wrap:anywhere;color:var(--ink)} +.td-lines{display:grid;gap:6px;margin-top:14px}.td-lines:empty{display:none} +.td-lines p{display:flex;align-items:center;gap:8px;margin:0;font-size:13px;color:var(--ink-3);min-width:0} +.td-lines .td-dot{color:var(--ok)}.td-lines b{font-weight:500;color:var(--ink-2)} +.td-lines span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + +/* rate */ +.k-rate{display:flex;flex-direction:column} +.td-rates{display:flex;flex-wrap:wrap;gap:12px 34px;align-items:flex-start} +.td-rate small{display:flex;align-items:center;gap:6px;color:var(--ink-3);font-size:12.5px;margin-bottom:8px} +.td-rate small i{width:8px;height:8px;border-radius:3px;background:var(--accent)} +.td-rate.up small i{background:var(--second)} +.td-rate.boot{margin-left:auto;text-align:right}.td-rate.boot small{justify-content:flex-end}.td-rate.boot span{font-size:14px;color:var(--ink-2);white-space:pre} +/* the chart takes whatever height the card has, so a taller neighbour never leaves a gap above it */ +.td-chart{display:block;margin:0 -24px -22px;padding-top:18px;width:calc(100% + 48px);flex:1 0 136px} + +/* device rows */ +.td-dev{display:flex;align-items:center;gap:13px;padding:10px 0;border-top:1px solid var(--line-soft);min-width:0} +.td-dev:first-child{border-top:0;padding-top:0} +.td-av{width:36px;height:36px;border-radius:11px;background:var(--surface-2);display:grid;place-items:center;color:var(--ink-2);flex:none} +.td-dev .nm{flex:1;min-width:0} +.td-dev .nm b{font-weight:500;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.td-dev .nm small{color:var(--ink-3);font-size:12.5px;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.td-dev .rt{text-align:right;font-size:13px;width:clamp(72px,24cqi,120px);flex:none;color:var(--ink);white-space:nowrap} +.td-dev .rt.idle{color:var(--ink-3)} +.td-run .nm a{color:inherit;text-decoration:none}.td-run .nm a:hover b{color:var(--accent)} +.td-run .rt{width:auto;color:var(--ink-2)}a.rt{display:inline-flex;align-items:center;gap:4px;text-decoration:none!important}a.rt:hover{color:var(--accent)} +.td-run .td-seg{padding:2px;border-radius:9px}.td-run .td-seg button{min-height:26px;padding:0 10px;font-size:12px;border-radius:7px} +@container (max-width:420px){.td-run{flex-wrap:wrap}.td-run :is(.rt,.td-seg){flex-basis:calc(100% - 49px);margin-left:49px;text-align:left}.td-run .td-seg button{flex:1}} +/* A wide device card lists two per row instead of stretching each row across the screen. */ +@container (min-width:760px){#td-devs{display:grid;grid-template-columns:1fr 1fr;column-gap:40px}#td-devs>:not(.td-dev){grid-column:1/-1}#td-devs>.td-dev:nth-child(2){border-top:0;padding-top:0}} +.td-bar{height:4px;border-radius:4px;background:var(--surface-2);margin-top:5px;overflow:hidden} +.td-bar i{display:block;height:100%;border-radius:4px;background:var(--accent);transition:width var(--data) var(--ease)} +.td-empty-row{padding:18px 0;color:var(--ink-3);text-align:center;font-size:13px} +.td-more{display:block;text-align:center;font-size:13px;color:var(--ink-3)!important;padding-top:12px;border-top:1px solid var(--line-soft);margin-top:4px;text-decoration:none!important} +.td-more:hover{color:var(--accent)!important} + +/* wifi */ +.td-ssid{font:500 34px/1.05 var(--serif);margin:2px 0 10px;overflow-wrap:anywhere} +.td-ssid-s{font-size:13px;margin:0 0 16px} + +/* plugin card */ +.td-seg{display:inline-flex;flex-wrap:wrap;padding:3px;border-radius:11px;background:var(--surface-2);gap:2px;max-width:100%} +.td-seg button{min-height:30px;padding:0 14px;border-radius:9px;color:var(--ink-2);font-size:13px;white-space:nowrap;background:none;border:0;cursor:pointer} +.td-seg button:hover{color:var(--ink)} +.td-seg button.on{background:var(--surface);color:var(--ink);box-shadow:0 1px 3px rgba(0,0,0,.08);font-weight:500} +.td-kv{display:grid;grid-template-columns:repeat(auto-fit,minmax(90px,1fr));gap:10px;margin:0 0 16px} +.td-kv>div{background:var(--canvas);border-radius:12px;padding:10px 12px;min-width:0} +.td-kv dt{font-size:12px;color:var(--ink-3)} +.td-kv dd{margin:2px 0 0;font-size:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.td-kv dt{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.td-kv dd+dd{font-size:12px;color:var(--ink-3);margin-top:1px} +.td-kv .off dd{color:var(--ink-3)} +.td-card>.td-kv:last-child,#td-wifi>.td-kv,#td-ports{margin-bottom:0} +/* storage */ +.td-meter+.td-meter{margin-top:16px} +.td-meter p{display:flex;justify-content:space-between;align-items:baseline;gap:10px;margin:0 0 7px;font-size:13.5px} +.td-meter p b{font-weight:500} +.td-meter p span{color:var(--ink-3);font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.td-meter .td-bar{height:6px} +.td-bar.full i{background:var(--warn)} + +/* system rings */ +.td-rings{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(0,1fr);gap:14px} +.td-ring{display:flex;align-items:center;gap:12px;min-width:0} +.td-ring svg{width:58px;height:58px;transform:rotate(-90deg);flex:none} +.td-ring circle{fill:none;stroke-width:5} +.td-ring .bg{stroke:var(--surface-2)} +.td-ring .fg{stroke:var(--accent);stroke-linecap:round;transition:stroke-dashoffset var(--data) var(--ease)} +.td-ring .fg.warm{stroke:var(--warn)} +.td-ring .fg.hot{stroke:var(--bad)} +.td-ring b{font:500 20px/1 var(--serif);display:block} +.td-ring small{color:var(--ink-3);font-size:12.5px;white-space:nowrap} +@container (max-width:470px){.td-ring{flex-direction:column;text-align:center;gap:6px}} +.td-tags{display:flex;flex-wrap:wrap;gap:8px;margin-top:20px}.td-tags:empty{display:none} +.td-tag{display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border-radius:99px;border:1px solid var(--line);font-size:12.5px;color:var(--ink-2)} +.td-tag svg{width:14px;height:14px;color:var(--ok)} +.td-acts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px} +@container (min-width:620px){.td-acts{grid-template-columns:repeat(4,minmax(0,1fr))}} +@container (max-width:300px){.td-acts{grid-template-columns:1fr}} +.td-act{display:flex;gap:12px;align-items:center;padding:14px;border-radius:var(--r-md);background:var(--canvas);text-align:left;transition:background var(--fast) var(--ease),transform var(--fast) var(--ease);min-width:0;color:var(--ink)!important;text-decoration:none!important} +.td-act:hover{background:var(--hover);transform:translateY(-1px)} +.td-act svg{color:var(--accent);flex:none} +.td-act b{font-weight:500;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + +/* settings rows */ +.td-sect{padding:6px 24px;margin-bottom:18px} +.td-sect-t{font:500 18px var(--serif);padding:18px 0 2px;margin:0} +.td-sect-d{color:var(--ink-3);font-size:13px;padding-bottom:8px;margin:0} +.td-row{display:flex;flex-wrap:wrap;align-items:center;gap:12px 24px;padding:16px 0;border-top:1px solid var(--line-soft)} +.td-sect-t+.td-row,.td-sect-d+.td-row,.td-rows>.td-row:first-child{border-top:0} +.td-row .lb{flex:1 1 220px;min-width:0} +.td-row .lb b{font-weight:500;display:flex;align-items:center;gap:8px;flex-wrap:wrap} +.td-row .lb small{color:var(--ink-3);font-size:12.5px;display:block} +.td-row.off{opacity:.55} +.td-switch{width:44px;height:26px;border-radius:99px;background:var(--line);position:relative;transition:background var(--base) var(--ease);flex:none;border:0;cursor:pointer;padding:0} +.td-switch::after{content:"";position:absolute;top:3px;left:3px;width:20px;height:20px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.2);transition:transform var(--base) var(--ease)} +.td-switch.on{background:var(--accent)} +.td-switch.on::after{transform:translateX(18px);background:var(--on-accent)} +[data-palette=graphite] .td-switch.on::after{background:var(--surface)} +.td-switch[disabled]{cursor:not-allowed} +.td-palettes{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px;padding:6px 0 22px} +.td-pal{border-radius:16px;padding:8px;border:1.5px solid var(--line-soft);background:var(--surface);text-align:left;transition:border-color var(--fast) var(--ease),transform var(--fast) var(--ease);color:var(--ink);cursor:pointer;font:inherit} +.td-pal:hover{transform:translateY(-2px)} +.td-pal.on{border-color:var(--accent)} +.td-pal .pv{height:86px;border-radius:11px;background:var(--canvas);padding:10px;display:flex;gap:8px;overflow:hidden} +.td-pal .pv .sb{width:22%;display:flex;flex-direction:column;gap:5px} +.td-pal .pv .sb i{height:6px;border-radius:3px;background:var(--line)} +.td-pal .pv .sb i:first-child{background:var(--accent)} +.td-pal .pv .cd{flex:1;border-radius:8px;background:var(--surface);border:1px solid var(--line-soft);padding:8px;display:flex;flex-direction:column;gap:6px} +.td-pal .pv .cd i{height:5px;border-radius:3px;background:var(--surface-2)} +.td-pal .pv .cd i:first-child{width:45%;height:9px;background:var(--ink);opacity:.85} +.td-pal .pv .cd u{margin-top:auto;align-self:flex-start;width:42%;height:12px;border-radius:5px;background:var(--accent)} +.td-pal .nm{display:flex;align-items:center;justify-content:space-between;padding:10px 6px 4px;font-weight:500} +.td-pal .ck{width:20px;height:20px;border-radius:50%;background:var(--accent);color:var(--on-accent);display:grid;place-items:center;opacity:0;transition:opacity var(--fast) var(--ease)} +.td-pal.on .ck{opacity:1} +.td-pal .ck svg{width:12px;height:12px;stroke-width:2.4} +.td-hint{display:flex;gap:10px;padding:14px 16px;margin:4px 0 20px;border-radius:var(--r-md);background:var(--canvas);color:var(--ink-2);font-size:13px} +.td-hint svg{color:var(--ink-3);margin-top:1px} +.td-toast{position:fixed;left:50%;bottom:28px;transform:translateX(-50%);z-index:1200;padding:10px 18px;border-radius:99px;background:var(--ink);color:var(--canvas);font-size:13px;box-shadow:var(--shadow-pop);animation:td-fade var(--base) var(--ease)} +@media (max-width:760px){.td-toast{bottom:92px}} + +/* apps */ +.td-apps{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:14px} +.td-app-t{display:flex;gap:14px;align-items:center;padding:18px;border-radius:var(--r-lg);background:var(--surface);border:1px solid var(--line-soft);box-shadow:var(--shadow);text-decoration:none!important;min-width:0;color:var(--ink)!important;transition:transform var(--fast) var(--ease),border-color var(--fast) var(--ease);animation:td-rise var(--slow) var(--ease) both} +.td-app-t:hover{transform:translateY(-2px);border-color:var(--line)} +.td-app-t .td-av{width:44px;height:44px;border-radius:13px} +.td-app-t:hover .td-av{color:var(--accent)} +.td-app-t div{min-width:0} +.td-app-t b{font-weight:500;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.td-app-t small{color:var(--ink-3);font-size:12.5px;display:block} +.td-app-t.add{border-style:dashed;box-shadow:none;background:none;color:var(--ink-3)!important} +.td-app-t.add .td-av{background:none;border:1px dashed var(--line)} + +/* devices page */ +.td-devlist .td-dev{padding:14px 0} +.td-devlist .td-dev:first-child{padding-top:0} +.td-dev .meta{display:flex;flex-wrap:wrap;gap:4px 16px;font-size:12.5px;color:var(--ink-3);justify-content:flex-end;flex:0 1 auto;min-width:0} +.td-dev .meta span{white-space:nowrap} +.td-dev .meta b{font-weight:500;color:var(--ink-2)} +@container (max-width:560px){.td-devlist .td-dev{flex-wrap:wrap}.td-dev .meta{flex-basis:100%;justify-content:flex-start;padding-left:49px}} +.td-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:18px} +.td-stat{background:var(--surface);border:1px solid var(--line-soft);border-radius:var(--r-md);padding:14px 16px;box-shadow:var(--shadow);width:100%;margin:0;text-align:left;font:inherit;color:inherit;cursor:pointer;transition:background var(--fast) var(--ease),border-color var(--fast) var(--ease)} +.td-stat:hover{border-color:var(--line)} +.td-stat[aria-pressed=true]{background:var(--accent-soft);border-color:color-mix(in srgb,var(--accent) 45%,var(--line))}.td-stat[aria-pressed=true] small{color:var(--accent-2)} +.td-stat small{display:block;color:var(--ink-3);font-size:12.5px} +.td-stat b{font:500 26px/1.2 var(--serif)} + +/* ============================== login ============================== */ +.td-login{position:relative;min-height:100vh;display:grid;place-items:center;overflow:hidden;background:var(--canvas);padding:24px} +.td-login .glow{position:absolute;width:900px;height:900px;right:-260px;bottom:-420px;border-radius:50%;background:radial-gradient(closest-side,color-mix(in srgb,var(--accent) 22%,transparent),transparent);pointer-events:none} +.td-login .arcs{position:absolute;right:-120px;bottom:-120px;width:620px;height:620px;color:var(--accent);opacity:.22;pointer-events:none} +.td-lbox{position:relative;z-index:1;width:min(360px,100%);text-align:center;animation:td-rise var(--slow) var(--ease)} +.td-lbox h1{font-size:40px;margin:0 0 8px} +.td-lbox .td-sub{margin:0 0 36px} +.td-field{display:flex;align-items:center;gap:10px;width:100%;height:50px;padding:0 16px;border:1px solid var(--line);border-radius:14px;background:var(--surface);transition:border-color var(--fast) var(--ease),box-shadow var(--fast) var(--ease);margin-bottom:10px;color:var(--ink-3);text-align:left;cursor:text} +.td-field:focus-within{border-color:var(--accent);box-shadow:0 0 0 4px var(--tint)} +.td-field input{flex:1;border:0!important;background:none!important;box-shadow:none!important;outline:0;height:100%!important;padding:0!important;font-size:15px;min-width:0;width:auto;color:var(--ink)} +.td-field .eye{background:none;border:0;color:var(--ink-3);cursor:pointer;display:grid;place-items:center;padding:4px;border-radius:8px} +.td-field .eye:hover{color:var(--ink)} +.td-lbox .btn{width:100%;height:50px;border-radius:14px;margin-top:4px;font-size:15px} +.td-lerr{margin:0 0 12px;padding:10px 14px;border-radius:12px;background:var(--bad-soft);color:var(--bad);font-size:13px;text-align:left;animation:td-shake var(--slow) var(--ease)} +@keyframes td-shake{20%,60%{transform:translateX(-5px)}40%,80%{transform:translateX(5px)}} +.td-luser{font-size:12.5px;color:var(--ink-3);margin-top:16px} +.td-luser button{background:none;border:0;color:var(--ink-2);text-decoration:underline;text-underline-offset:3px;cursor:pointer;font:inherit} +.td-lfoot{position:absolute;bottom:28px;left:0;right:0;text-align:center;color:var(--ink-3);font-size:12.5px;z-index:1;padding:0 16px} diff --git a/luci-theme-7dollar/htdocs/luci-static/7dollar/icons.svg b/luci-theme-7dollar/htdocs/luci-static/7dollar/icons.svg new file mode 100644 index 000000000..0fa11df63 --- /dev/null +++ b/luci-theme-7dollar/htdocs/luci-static/7dollar/icons.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/luci-theme-7dollar/htdocs/luci-static/7dollar/logo.svg b/luci-theme-7dollar/htdocs/luci-static/7dollar/logo.svg new file mode 100644 index 000000000..1c588287c --- /dev/null +++ b/luci-theme-7dollar/htdocs/luci-static/7dollar/logo.svg @@ -0,0 +1 @@ + diff --git a/luci-theme-7dollar/htdocs/luci-static/resources/7dollar/common.js b/luci-theme-7dollar/htdocs/luci-static/resources/7dollar/common.js new file mode 100644 index 000000000..267a6a47d --- /dev/null +++ b/luci-theme-7dollar/htdocs/luci-static/resources/7dollar/common.js @@ -0,0 +1,695 @@ +'use strict'; +'require baseclass'; +'require rpc'; +'require fs'; +'require ui'; + +/* + * Shared helpers for the 7Dollar theme: menu grouping, settings storage, + * formatting and the device list used by the home and devices pages. + */ + +const ZH = /^zh/i.test(document.documentElement.getAttribute('lang') || ''); +const T = (zh, en) => ZH ? zh : en; +const SPRITE = '/luci-static/7dollar/icons.svg'; +const ACTIVE = /\/7dollar$/.test(L.env.media || ''); + +const callUciGet = rpc.declare({ object: 'uci', method: 'get', params: [ 'config', 'section' ], expect: { values: {} } }); +const callUciGetType = rpc.declare({ object: 'uci', method: 'get', params: [ 'config', 'type' ], expect: { values: {} } }); +const callUciSet = rpc.declare({ object: 'uci', method: 'set', params: [ 'config', 'section', 'values' ] }); +const callUciAdd = rpc.declare({ object: 'uci', method: 'add', params: [ 'config', 'type', 'name', 'values' ] }); +const callUciDelete = rpc.declare({ object: 'uci', method: 'delete', params: [ 'config', 'section', 'options' ] }); +const callUciCommit = rpc.declare({ object: 'uci', method: 'commit', params: [ 'config' ] }); +const callFileRead64 = rpc.declare({ object: 'file', method: 'read', params: [ 'path', 'base64' ], expect: { data: '' } }); +const callLeases = rpc.declare({ object: 'luci-rpc', method: 'getDHCPLeases', expect: { '': {} } }); +const callHints = rpc.declare({ object: 'luci-rpc', method: 'getHostHints', expect: { '': {} } }); +const callWireless = rpc.declare({ object: 'luci-rpc', method: 'getWirelessDevices', expect: { '': {} } }); +const callAssoc = rpc.declare({ object: 'iwinfo', method: 'assoclist', params: [ 'device' ], expect: { results: [] } }); +const callDevices = rpc.declare({ object: 'luci-rpc', method: 'getNetworkDevices', expect: { '': {} } }); +const callIfDump = rpc.declare({ object: 'network.interface', method: 'dump', expect: { interface: [] } }); +const callBoard = rpc.declare({ object: 'system', method: 'board' }); +const callInfo = rpc.declare({ object: 'system', method: 'info' }); +const callServices = rpc.declare({ object: 'service', method: 'list', expect: { '': {} } }); +const callMounts = rpc.declare({ object: 'luci', method: 'getMountPoints', expect: { result: [] } }); + +const DEFAULTS = { palette: 'clay', mode: 'auto', hidden_cards: '', shown_cards: '' }; +const OPT_IN = [ 'apps' ]; // cards that are off until switched on (they show plugin information) + +const words = s => String(s || '').split(/[\s,]+/).filter(Boolean); + +/* Whether a home card is on: normal cards unless hidden, opt-in cards only when shown. */ +function cardOn(conf, key) { + return OPT_IN.indexOf(key) > -1 ? words(conf.shown_cards).indexOf(key) > -1 : words(conf.hidden_cards).indexOf(key) < 0; +} + +const PALETTES = [ + [ 'clay', T('陶土', 'Clay') ], + [ 'celadon', T('青瓷', 'Celadon') ], + [ 'mist', T('雾蓝', 'Mist') ], + [ 'dusk', T('暮紫', 'Dusk') ], + [ 'graphite', T('石墨', 'Graphite') ] +]; + +/* Menu regrouping: native LuCI pages -> seven plain-language sections. `pages` are the everyday + * ones, shown as pills; `more` and anything matched by `prefix` sit behind the group's "More" pill. + * Plugins also put themselves under System or Status (other themes' settings, file transfer, disk + * tools ...); those are apps. So System's prefixes only take LuCI's own pages, told apart by the + * view they open (`own`), which also covers native pages added in newer LuCI versions. */ +const GROUPS = [ + { id: 'home', icon: 'home', label: T('总览', 'Overview'), pages: [ 'admin/home' ] }, + { id: 'devices', icon: 'devices', label: T('设备', 'Devices'), pages: [ 'admin/clients' ] }, + { id: 'wifi', icon: 'wifi', label: 'Wi‑Fi', pages: [ 'admin/network/wireless', 'admin/status/channel_analysis' ] }, + { id: 'internet', icon: 'globe', label: T('上网', 'Internet'), + pages: [ 'admin/network/network', 'admin/network/dhcp', 'admin/network/dns', 'admin/network/diagnostics' ], + more: [ 'admin/network/routes', 'admin/status/routes', 'admin/status/routesj', 'admin/network/switch' ], + prefix: [ 'admin/network' ] }, + { id: 'security', icon: 'shield', label: T('安全', 'Security'), mob: false, + pages: [ 'admin/network/firewall', 'admin/status/nftables', 'admin/status/iptables', 'admin/system/admin' ] }, + { id: 'apps', icon: 'grid', label: T('应用', 'Apps'), mob: false, pages: [ 'admin/apps' ], + prefix: [ 'admin/services', 'admin/vpn', 'admin/nas', 'admin/docker', 'admin/statistics' ] }, + { id: 'system', icon: 'gear', label: T('系统', 'System'), short: T('更多', 'More'), + pages: [ 'admin/system/system', 'admin/system/appearance', 'admin/status/overview', 'admin/status/logs', + 'admin/system/package-manager', 'admin/system/opkg', 'admin/system/flash', 'admin/system/reboot' ], + more: [ 'admin/dashboard', 'admin/status/realtime', 'admin/system/crontab', 'admin/system/startup', 'admin/status/processes', 'admin/system/mounts', + 'admin/system/leds', 'admin/status/syslog', 'admin/status/dmesg', 'admin/status/dashboard' ], + prefix: [ 'admin/system', 'admin/status' ], own: true } +]; + +/* A page LuCI itself ships: it opens one of LuCI's status/system views, or points into them. + * (The patterns sit in constants: the buildroot's jsmin misreads a regex that follows `return`.) */ +const OWN_VIEW = /^(status|system|admin_status)\//, OWN_ALIAS = /^admin\/(status|system)\//; + +function own(node) { + const a = (node && node.action) || {}, path = a.path || ''; + return OWN_VIEW.test(path) || (a.type == 'alias' && OWN_ALIAS.test(path)); +} + +const LABELS = { + 'admin/home': T('总览', 'Overview'), + 'admin/dashboard': T('仪表盘', 'Dashboard'), + 'admin/clients': T('设备', 'Devices'), + 'admin/apps': T('全部应用', 'All apps'), + 'admin/network/wireless': T('无线网络', 'Wireless'), + 'admin/status/channel_analysis': T('信道分析', 'Channel analysis'), + 'admin/network/network': T('网络接口', 'Interfaces'), + 'admin/network/diagnostics': T('网络诊断', 'Diagnostics'), + 'admin/network/routes': T('静态路由', 'Static routes'), + 'admin/status/routes': T('路由表', 'Routing table'), + 'admin/status/routesj': T('路由表', 'Routing table'), + 'admin/network/switch': T('交换机', 'Switch'), + 'admin/network/firewall': T('防火墙', 'Firewall'), + 'admin/status/nftables': T('防火墙规则', 'Firewall status'), + 'admin/status/iptables': T('防火墙规则', 'Firewall status'), + 'admin/system/admin': T('管理员', 'Administration'), + 'admin/system/system': T('通用', 'General'), + 'admin/system/appearance': T('外观与首页', 'Appearance'), + 'admin/status/overview': T('运行状态', 'Status'), + 'admin/status/realtime': T('实时监控', 'Realtime'), + 'admin/status/logs': T('日志', 'Logs'), + 'admin/status/processes': T('进程', 'Processes'), + 'admin/system/package-manager': T('软件包', 'Software'), + 'admin/system/opkg': T('软件包', 'Software'), + 'admin/system/flash': T('备份与升级', 'Backup & upgrade'), + 'admin/system/startup': T('启动项', 'Startup'), + 'admin/system/crontab': T('计划任务', 'Scheduled tasks'), + 'admin/system/mounts': T('挂载点', 'Mount points'), + 'admin/system/leds': T('指示灯', 'LEDs'), + 'admin/system/reboot': T('重启', 'Reboot'), + 'admin/services/openclash': 'OpenClash', + 'admin/services/ksmbd': T('文件共享', 'File sharing') +}; + +const APP_ICONS = [ + [ /clash|passwall|homeproxy|ssr|v2ray|xray|sing|mihomo|proxy|nikki|daed/i, 'route' ], + [ /smb|samba|ksmbd|nfs|ftp|disk|share|alist|aria|transmission|qbit|minidlna/i, 'folder' ], + [ /vpn|wireguard|openvpn|zerotier|tailscale|frp|ipsec/i, 'vpn' ], + [ /ddns|dns|adguard|mosdns|smartdns/i, 'globe' ], + [ /docker|dockerman/i, 'box' ], + [ /stat|nlbw|bandwidth|netdata/i, 'pulse' ], + [ /upnp|qos|sqm|eqos/i, 'sliders' ], + [ /wol|wake/i, 'power' ], + [ /cron|sched|time/i, 'clock' ] +]; + +function esc(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); +} + +function icon(id, cls) { + return ``; +} + +function html(s) { + const t = document.createElement('template'); + t.innerHTML = s.trim(); + return t.content.childNodes.length == 1 ? t.content.firstChild : t.content; +} + +function title(path, node) { + if (LABELS[path]) + return LABELS[path]; + return node && node.title ? _(node.title) : path.split('/').pop(); +} + +/* ui.menu.getChildren() replaces alias nodes (e.g. Firewall → Zones) with a copy of + * their target, which drops the alias's own sub-pages. Always keep the real node. */ +function realNode(parent, child) { + return (parent.children && parent.children[child.name]) || child; +} + +/* True if the menu has this page. LuCI only lists pages whose dependencies are met, so this + * doubles as a free feature check (e.g. Wireless needs a wifi-device, OpenClash needs the app). */ +function menuHas(tree, path) { + let n = tree; + for (const k of ('admin/' + path).split('/')) n = n && n.children && n.children[k]; + return !!n; +} + +/* The software page: package-manager since 24.10, opkg on 23.05; null when neither app is installed. */ +function software(tree) { + return [ 'system/package-manager', 'system/opkg' ].find(p => menuHas(tree, p)) || null; +} + +let menuPromise; + +/* A menu cached before switching themes can omit our home page. Refresh it once and share the + * result between navigation and views; never invent entries that the server did not authorize. */ +function loadMenu() { + if (!menuPromise) + menuPromise = ui.menu.load().then(tree => { + const home = tree && tree.children && tree.children.admin && tree.children.admin.children && tree.children.admin.children.home; + if (!ACTIVE || (home && home.satisfied && home.action && home.action.path == '7dollar/home')) + return tree; + + ui.menu.flushCache(); + ui.menu.menu = null; + return ui.menu.load(); + }); + + return menuPromise; +} + +function flatten(tree) { + /* Returns [{ path, node, title, group }] for every navigable page. */ + const admin = tree && tree.children && tree.children.admin; + const out = []; + + if (!admin) + return out; + + ui.menu.getChildren(admin).forEach(t => { + const top = realNode(admin, t); + + if (top.name == 'logout') + return; + + const tpath = 'admin/' + top.name; + const kids = ui.menu.getChildren(top); + + if (!kids.length) + out.push({ path: tpath, node: top }); + else + kids.forEach(k => out.push({ path: tpath + '/' + k.name, node: realNode(top, k) })); + }); + + out.forEach(p => { + p.title = title(p.path, p.node); + p.group = groupOf(p.path, p.node); + }); + + return out; +} + +function groupOf(path, node) { + for (const g of GROUPS) + if (g.pages.concat(g.more || []).indexOf(path) > -1) + return g.id; + + for (const g of GROUPS) + for (const pre of (g.prefix || [])) + if ((path == pre || path.indexOf(pre + '/') == 0) && (!g.own || own(node))) + return g.id; + + return 'apps'; +} + +function grouped(pages) { + return GROUPS.map(g => { + const order = g.pages.concat(g.more || []), list = pages.filter(p => p.group == g.id); + list.sort((a, b) => { + const ia = order.indexOf(a.path), ib = order.indexOf(b.path); + return (ia < 0 ? 999 : ia) - (ib < 0 ? 999 : ib); + }); + return Object.assign({}, g, { list: list }); + }).filter(g => g.list.length); +} + +function appIcon(path) { + for (const [ re, id ] of APP_ICONS) + if (re.test(path)) + return id; + return 'grid'; +} + +/* The procd service behind an app page, matched by name (services/openclash ↔ openclash, + * nas/ksmbd ↔ ksmbd, services/upnp ↔ miniupnpd): { name, running }, or null if none fits. */ +function service(services, path) { + const leaf = path.split('/').pop().toLowerCase().replace(/[^a-z0-9]/g, ''); + const name = Object.keys(services || {}).find(k => { + const s = k.toLowerCase().replace(/[^a-z0-9]/g, ''); + return s == leaf || (leaf.length > 3 && s.indexOf(leaf) > -1) || (s.length > 3 && leaf.indexOf(s) > -1); + }); + if (!name) return null; + const inst = services[name].instances || {}; + return { name: name, running: Object.keys(inst).some(k => inst[k].running) }; +} + +/* ---------------- settings (stored in /etc/config/7dollar) ---------------- */ + +function loadSettings() { + return L.resolveDefault(callUciGet('7dollar', 'main'), {}).then(v => Object.assign({}, DEFAULTS, v || {})); +} + +function saveSettings(values) { + const set = {}, del = []; + + for (const k in values) { + if (values[k] === '' || values[k] == null) + del.push(k); + else + set[k] = String(values[k]); + } + + return L.resolveDefault(callUciGet('7dollar', 'main'), null).then(exists => { + const steps = []; + + if (exists == null || !Object.keys(exists).length) + steps.push(() => callUciAdd('7dollar', 'theme', 'main', set)); + else if (Object.keys(set).length) + steps.push(() => callUciSet('7dollar', 'main', set)); + + if (del.length) + steps.push(() => L.resolveDefault(callUciDelete('7dollar', 'main', del), null)); + + steps.push(() => callUciCommit('7dollar')); + + return steps.reduce((p, fn) => p.then(fn), Promise.resolve()); + }); +} + +/* ---------------- motion ---------------- */ + +/* Durations live in cascade.css (--fast / --base / --slow); scripts read them so CSS and JS move alike. */ +const calm = matchMedia('(prefers-reduced-motion: reduce)'); +function motion(name) { + return calm.matches ? 0 : parseFloat(getComputedStyle(document.documentElement).getPropertyValue(`--${name}`)) * 1000 || 0; +} + +/* Exits mirror entrances: fade (and sink) out, then go. */ +function leave(el) { + if (el.classList.contains('td-out')) return; + el.classList.add('td-out'); + setTimeout(() => el.remove(), motion('fast')); +} + +function applyLook(s) { + const r = document.documentElement; + const dark = s.mode == 'auto' ? matchMedia('(prefers-color-scheme: dark)').matches : s.mode == 'dark'; + const paint = () => { + r.setAttribute('data-palette', s.palette); + r.setAttribute('data-mode', s.mode); + r.setAttribute('data-theme', dark ? 'dark' : 'light'); + r.setAttribute('data-darkmode', dark ? 'true' : 'false'); + document.dispatchEvent(new CustomEvent('td-look', { detail: s })); + }; + /* one cross-fade of the whole page instead of every colour easing on its own */ + if (document.startViewTransition && motion('base')) document.startViewTransition(paint); + else paint(); +} + +let toastTimer; +function toast(msg) { + let t = document.querySelector('.td-toast:not(.td-out)'); + if (!t) document.body.appendChild(t = E('div', { 'class': 'td-toast', 'role': 'status' })); + t.textContent = msg; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => leave(t), 1800); +} + +/* ---------------- formatting ---------------- */ + +function rate(bytesPerSec) { + const bits = Math.max(0, bytesPerSec * 8); + if (bits >= 1e9) return [ (bits / 1e9).toFixed(2), 'Gbps' ]; + if (bits >= 1e6) return [ (bits / 1e6).toFixed(bits >= 1e8 ? 0 : 1), 'Mbps' ]; + if (bits >= 1e3) return [ (bits / 1e3).toFixed(0), 'Kbps' ]; + return [ bits.toFixed(0), 'bps' ]; +} + +function bytes(n) { + n = +n || 0; + if (n >= 1099511627776) return (n / 1099511627776).toFixed(1) + ' TB'; + if (n >= 1073741824) return (n / 1073741824).toFixed(1) + ' GB'; + if (n >= 1048576) return (n / 1048576).toFixed(0) + ' MB'; + if (n >= 1024) return (n / 1024).toFixed(0) + ' KB'; + return n + ' B'; +} + +function duration(sec) { + sec = Math.max(0, Math.floor(sec || 0)); + const d = Math.floor(sec / 86400), h = Math.floor(sec % 86400 / 3600), m = Math.floor(sec % 3600 / 60); + if (ZH) { + if (d) return `${d} 天 ${h} 小时`; + if (h) return `${h} 小时 ${m} 分钟`; + return `${Math.max(m, 1)} 分钟`; + } + if (d) return `${d}d ${h}h`; + if (h) return `${h}h ${m}m`; + return `${Math.max(m, 1)}m`; +} + +function signal(dbm) { + if (dbm == null || dbm == 0) return [ T('未知', 'Unknown'), 0 ]; + if (dbm >= -55) return [ T('信号很好', 'Excellent'), 4 ]; + if (dbm >= -65) return [ T('信号良好', 'Good'), 3 ]; + if (dbm >= -75) return [ T('信号一般', 'Fair'), 2 ]; + return [ T('信号较弱', 'Weak'), 1 ]; +} + +function quality(dbm) { + if (!dbm) return 0; + return Math.max(4, Math.min(100, Math.round((dbm + 92) / 50 * 100))); +} + +function bandName(b) { + return ({ '2g': '2.4 GHz', '5g': '5 GHz', '6g': '6 GHz', '60g': '60 GHz' })[b] || b || 'Wi‑Fi'; +} + +function kind(name) { + name = name || ''; + if (/watch/i.test(name)) return 'watch'; + if (/ipad|pad|tab|kindle/i.test(name)) return 'tablet'; + if (/book\d*\b|laptop|thinkpad|surface/i.test(name)) return 'laptop'; // MacBook, MateBook, Vivobook, Galaxy Book3 + if (/iphone|pixel|android|phone|oppo|vivo|xiaomi|redmi|huawei|honor|galaxy|oneplus|realme|meizu|nokia|mi-/i.test(name)) return 'phone'; + if (/tv\b|roku|chromecast|fire-?stick|\bshield\b|x?box\b/i.test(name)) return 'tv'; + if (/\b(mac|imac|mini|pc|desktop|win|ubuntu|linux|nas|server)\b/i.test(name)) return 'desktop'; + return 'devices'; +} + +function pretty(name) { + if (!name) return ''; + return name.replace(/[-_]+/g, ' ').replace(/\s+/g, ' ').trim(); +} + +/* ---------------- data ---------------- */ + +function readFile(path) { + return L.resolveDefault(fs.read(path), null); +} + +/* MACs a bridge has heard from in the last few minutes: its forwarding table forgets a port after + * 300 s of silence, while an ARP entry can outlive a device by hours. Entries are 16 bytes + * (6 of MAC) in the binary brforward file. null when none of the devices is a bridge. */ +function heard(bridges) { + return Promise.all(bridges.map(b => L.resolveDefault(callFileRead64(`/sys/class/net/${b}/brforward`, true), null))).then(datas => { + let macs = null; + datas.forEach(d => { + if (d == null) return; + const bin = atob(d); + macs = macs || {}; + for (let i = 0; i + 16 <= bin.length; i += 16) + macs[Array.from(bin.slice(i, i + 6), c => ('0' + c.charCodeAt(0).toString(16)).slice(-2)).join(':').toUpperCase()] = true; + }); + return macs; + }); +} + +/* CPU temperature lives in different places per platform (thermal_zone0 on most SoCs, + * coretemp/k10temp hwmon on x86). Pick the best sensor once; later reads cost one file. */ +const TEMP_PREF = [ /^coretemp/, /^k10temp/, /^zenpower/, /^x86_pkg_temp/, /^cpu[-_]?thermal/, /^soc[-_]?thermal/, /^acpitz/ ]; +let tempPath; + +function tempSensor() { + if (tempPath !== undefined) + return Promise.resolve(tempPath); + + const list = dir => L.resolveDefault(fs.list(dir), []).then(l => l.map(e => dir + '/' + e.name)); + const rank = name => { const i = TEMP_PREF.findIndex(r => r.test(name)); return i < 0 ? TEMP_PREF.length : i }; + + return Promise.all([ list('/sys/class/thermal'), list('/sys/class/hwmon') ]).then(([ zones, hwmons ]) => Promise.all([].concat( + zones.filter(d => /thermal_zone\d+$/.test(d)).map(d => readFile(d + '/type').then(n => ({ name: n, path: d + '/temp' }))), + hwmons.map(d => readFile(d + '/name').then(n => ({ name: n, path: d + '/temp1_input' }))) + ))).then(found => { + /* Known CPU sensors first, then any thermal zone; never radio/PHY/disk sensors. */ + const cands = found.filter(c => c.name).map(c => ({ path: c.path, r: rank(c.name.trim()) })) + .filter(c => c.r < TEMP_PREF.length || /thermal_zone/.test(c.path)) + .sort((a, b) => a.r - b.r || (a.path < b.path ? -1 : 1)); + + const next = i => i >= cands.length ? null + : readFile(cands[i].path).then(v => +v > 0 ? cands[i].path : next(i + 1)); + return next(0); + }).then(p => (tempPath = p)); +} + +function temperature() { + return tempSensor().then(p => p ? readFile(p) : null).then(v => +v > 0 ? +v / 1000 : null); +} + +function wifiInfo() { + return L.resolveDefault(callWireless(), {}).then(radios => { + const aps = []; + + Object.keys(radios || {}).sort().forEach(rname => { + const r = radios[rname] || {}; + (r.interfaces || []).forEach(i => { + if ((i.config || {}).mode == 'ap' && i.ifname) + aps.push({ radio: rname, band: (r.config || {}).band, ifname: i.ifname, ssid: (i.iwinfo || {}).ssid || (i.config || {}).ssid, + network: (i.config || {}).network || [], up: !!r.up && !(i.config || {}).disabled }); + }); + }); + + return Promise.all(aps.map(ap => ap.up ? L.resolveDefault(callAssoc(ap.ifname), []) : Promise.resolve([]))) + .then(lists => { + aps.forEach((ap, i) => ap.stations = lists[i] || []); + return { radios: radios || {}, aps: aps }; + }); + }); +} + +function clients(wifi, uplink) { + return Promise.all([ + L.resolveDefault(callLeases(), {}), + L.resolveDefault(callHints(), {}), + wifi ? Promise.resolve(wifi) : wifiInfo(), + readFile('/proc/net/arp'), + uplink !== undefined ? uplink : wan() + ]).then(([ leases, hints, wi, arp, up ]) => { + up = up || {}; + /* neighbours on the uplink side (a main router's WAN port, a repeater's Wi-Fi link) are not + * our devices; a side router's uplink is the LAN itself, so there only the gateway is skipped. + * What a side router sees on its LAN port may well be a phone on the main router's Wi-Fi, + * so those count as "lan" rather than "wired". The same goes for a box with no Wi-Fi of its + * own (x86, radios off): its Wi-Fi devices arrive through an access point on a LAN port. */ + const side = up.mode == 'side', skip = side ? [] : [ up.device, up.port ]; + const lan = side || !(wi.aps || []).some(ap => ap.up); + const byMac = {}; + const get = mac => { + mac = String(mac).toUpperCase(); + return byMac[mac] || (byMac[mac] = { mac: mac, online: false }); + }; + + (leases.dhcp_leases || []).forEach(l => { + if (!l.macaddr) return; + const c = get(l.macaddr); + c.name = c.name || l.hostname; + c.ip = l.ipaddr; + c.expires = l.expires; + c.lease = true; + }); + + const wifiMacs = {}; + + (wi.aps || []).forEach(ap => ap.stations.forEach(s => { + const c = get(s.mac); + const h = (hints || {})[c.mac] || {}; + c.online = true; + c.conn = 'wifi'; + c.band = ap.band; + c.ssid = ap.ssid; + c.signal = s.signal; + c.link = Math.max((s.tx || {}).rate || 0, (s.rx || {}).rate || 0) / 1000; + c.since = s.connected_time; + c.name = c.name || h.name; + c.ip = c.ip || (h.ipaddrs || [])[0]; + wifiMacs[c.mac] = true; + })); + + /* Wired devices are whatever answers ARP on our side, leased or not (static IPs, or a side + * router whose DHCP is off), whatever the LAN is called (br-lan, eth0, br-lan.10 ...). + * The gateway is the upstream router; containers and loopback are not devices either. + * The bridge's own table then drops whoever has gone quiet (a Wi-Fi device that left). */ + const rows = String(arp || '').split('\n').slice(1).map(line => line.trim().split(/\s+/)).filter(f => + f.length >= 6 && f[2] == '0x2' && f[0] != up.gw && !skip.includes(f[5]) && !/^(lo|docker|veth)|^br-[0-9a-f]{12}$/.test(f[5]) && !wifiMacs[f[3].toUpperCase()]); + const bridges = [ ...new Set(rows.map(f => f[5].replace(/\.\d+$/, ''))) ]; + + return heard(bridges).then(macs => { + rows.forEach(f => { + if (macs && !macs[f[3].toUpperCase()]) return; + const c = get(f[3]); + c.online = true; + c.conn = lan ? 'lan' : 'wired'; + c.ip = c.ip || f[0]; + }); + + for (const mac in (hints || {})) { + const h = hints[mac], c = byMac[mac.toUpperCase()]; + if (c) { + c.name = c.name || h.name; + c.ip = c.ip || (h.ipaddrs || h.ipv4 || [])[0]; + } + } + + const list = Object.values(byMac).map(c => { + c.display = pretty(c.name) || c.ip || c.mac; + c.kind = kind(c.name); + return c; + }); + + list.sort((a, b) => (b.online - a.online) || ((b.link || 0) - (a.link || 0)) || a.display.localeCompare(b.display)); + + return list; + }); + }); +} + +/* The uplink is whichever device carries the kernel's default route. Asking the kernel instead of + * netifd covers every layout (main router, bypass/side router, routes added by scripts). */ +function defaultRoute(text) { + let best = null; + String(text || '').split('\n').slice(1).forEach(line => { + const f = line.trim().split(/\s+/); + if (f[1] != '00000000' || f[7] != '00000000' || !(parseInt(f[3], 16) & 1)) return; + if (!best || +f[6] < best.metric) + best = { dev: f[0], metric: +f[6], gw: f[2].match(/../g).reverse().map(h => parseInt(h, 16)).join('.') }; + }); + return best; +} + +function wan() { + return Promise.all([ L.resolveDefault(callIfDump(), []), readFile('/proc/net/route') ]).then(([ ifs, rt ]) => { + const def = defaultRoute(rt); + const dev = i => i.l3_device || i.device; + const list = ifs || []; + const lan = list.find(i => i.interface == 'lan'); + const v4 = i => ((i['ipv4-address'] || [])[0] || {}).address; + const v6 = i => ((i['ipv6-address'] || []).find(a => !/^fe80:/i.test(a.address)) || {}).address; // never the link-local one + + if (!def) { + const w = list.find(i => i.interface == 'wan'); + return w ? { up: false, mode: 'wan', proto: w.proto, device: dev(w) } : null; + } + + const i = list.filter(i => i.up && dev(i) == def.dev) + .sort((a, b) => !!(b['ipv4-address'] || []).length - !!(a['ipv4-address'] || []).length)[0] || {}; + + /* Any other interface with a default route of its own (IPTV, a backup WAN) is another line. + * The IPv6 half of the WAN (wan6) is not: it rides on the WAN's device or port with no IPv4 route. */ + const deflt = (x, re) => (x.route || []).some(r => r.mask == 0 && re.test(r.target)); + const port = n => String(n || '').split('.')[0]; + const seen = new Set([ def.dev, lan && dev(lan) ]); + const more = list.filter(x => x.up && x.interface != 'loopback' && deflt(x, /^(0\.0\.0\.0|::)$/) && !seen.has(dev(x)) + && (deflt(x, /^0\.0\.0\.0$/) || port(x.device) != port(i.device)) && seen.add(dev(x))) + .map(x => ({ name: x.interface.toUpperCase(), proto: x.proto, device: dev(x), port: x.device, ip: v4(x) || v6(x), uptime: x.uptime })); + + return { + up: true, + /* side: uplink shares the LAN (bypass router / AP); sta: Wi-Fi uplink (repeater) */ + mode: lan && dev(lan) == def.dev ? 'side' : /-sta\d+$/.test(def.dev) ? 'sta' : 'wan', + proto: i.proto, + device: def.dev, + port: i.device, + ip: v4(i), + ip6: v6(i), + gw: def.gw != '0.0.0.0' ? def.gw : null, + dns: i['dns-server'] || [], + uptime: i.uptime, + more: more + }; + }); +} + +/* Physical Ethernet ports in use: the uplink ports, named after their line (WAN, IPTV, ...), then + * whatever sits in a bridge (lan1…lan4, eth1, …). Switch ports are "dsa" devices, a plain NIC is + * "ethernet". A VLAN on top (eth0.7, wan.10) counts as its port. Empty on swconfig boxes, where + * the kernel only sees the CPU port. */ +function ports(up) { + const base = n => String(n || '').split('.')[0]; + const line = new Map(); + const tag = (n, name) => (n = base(n)) && !line.has(n) && line.set(n, name); + if (up && up.mode != 'side') { tag(up.port, 'WAN'); tag(up.device, 'WAN'); } + ((up && up.more) || []).forEach(u => { tag(u.port, u.name); tag(u.device, u.name); }); + const order = [ ...line.keys() ], rank = n => { const k = order.indexOf(n); return k < 0 ? order.length : k }; + return L.resolveDefault(callDevices(), {}).then(devs => Object.values(devs || {}) + .filter(d => /^(ethernet|dsa)$/.test(d.devtype) && !d.wireless && d.name.indexOf('.') < 0 && !/^(docker|veth|br-)/.test(d.name) + && (d.master || line.has(d.name))) + .map(d => ({ name: d.name, line: line.get(d.name), up: !!(d.link || {}).carrier, speed: +(d.link || {}).speed || 0 })) + .sort((a, b) => (rank(a.name) - rank(b.name)) || a.name.localeCompare(b.name, 'en', { numeric: true }))); +} + +function connName(c) { + return c.conn == 'lan' ? T('局域网', 'LAN') : T('有线连接', 'Wired'); +} + +const DNS_NAMES = { + '223.5.5.5': T('阿里', 'AliDNS'), '223.6.6.6': T('阿里', 'AliDNS'), + '119.29.29.29': T('腾讯', 'DNSPod'), '119.28.28.28': T('腾讯', 'DNSPod'), '182.254.116.116': T('腾讯', 'DNSPod'), + '114.114.114.114': '114DNS', '114.114.115.115': '114DNS', + '180.76.76.76': T('百度', 'Baidu'), '1.1.1.1': 'Cloudflare', '1.0.0.1': 'Cloudflare', + '8.8.8.8': 'Google', '8.8.4.4': 'Google', '9.9.9.9': 'Quad9' +}; + +function dnsName(list, gw, gwName) { + const names = []; + (list || []).forEach(ip => { + const n = DNS_NAMES[ip] || (ip == gw ? gwName || T('上级路由', 'Upstream') : ip); + if (names.indexOf(n) < 0) names.push(n); + }); + return names.slice(0, 2).join(' · ') || '—'; +} + +function netdev(text) { + const out = {}; + String(text || '').split('\n').slice(2).forEach(line => { + const m = line.match(/^\s*([^:]+):\s*(.*)$/); + if (!m) return; + const f = m[2].trim().split(/\s+/).map(Number); + out[m[1]] = { rx: f[0], tx: f[8] }; + }); + return out; +} + +function cpuStat(text) { + const f = String(text || '').split('\n')[0].trim().split(/\s+/).slice(1).map(Number); + if (f.length < 4) return null; + const idle = f[3] + (f[4] || 0); + return { idle: idle, total: f.reduce((a, b) => a + b, 0) }; +} + +const uciType = (config, type) => L.resolveDefault(callUciGetType(config, type), {}); +const uciSection = (config, section) => L.resolveDefault(callUciGet(config, section), {}); + +return baseclass.extend({ + ZH: ZH, T: T, + active: ACTIVE, + PALETTES: PALETTES, OPT_IN: OPT_IN, words: words, cardOn: cardOn, + esc: esc, icon: icon, html: html, + loadMenu: loadMenu, flatten: flatten, realNode: realNode, menuHas: menuHas, software: software, grouped: grouped, appIcon: appIcon, service: service, + loadSettings: loadSettings, saveSettings: saveSettings, applyLook: applyLook, toast: toast, motion: motion, leave: leave, + rate: rate, bytes: bytes, duration: duration, signal: signal, quality: quality, bandName: bandName, connName: connName, + readFile: readFile, temperature: temperature, wifiInfo: wifiInfo, clients: clients, wan: wan, ports: ports, dnsName: dnsName, netdev: netdev, cpuStat: cpuStat, + board: () => L.resolveDefault(callBoard(), {}), + sysinfo: () => L.resolveDefault(callInfo(), {}), + services: () => L.resolveDefault(callServices(), {}), mounts: () => L.resolveDefault(callMounts(), []), + uciType: uciType, uciSection: uciSection, + uciSet: (config, section, values) => callUciSet(config, section, values).then(() => callUciCommit(config)) +}); diff --git a/luci-theme-7dollar/htdocs/luci-static/resources/7dollar/tidy.js b/luci-theme-7dollar/htdocs/luci-static/resources/7dollar/tidy.js new file mode 100644 index 000000000..5e5556946 --- /dev/null +++ b/luci-theme-7dollar/htdocs/luci-static/resources/7dollar/tidy.js @@ -0,0 +1,221 @@ +'use strict'; +'require baseclass'; +'require uci'; +'require 7dollar.common as td'; + +/* + * Plain status by default. Native LuCI status blocks (interfaces, Wi-Fi, overview) list every + * field they know; this marks the engineering ones (MAC, BSSID, packet counts, MCS, noise, ...) + * as .td-x and gives their section a "Details" button. Nothing is removed: one click shows all. + * Labels are matched through LuCI's own translations, so it works in every language. + * Runs in the browser only, after each re-render of #view; the router does no extra work. + */ + +const T = td.T, icon = td.icon; +const BAND = { '2g': '2.4 GHz', '5g': '5 GHz', '6g': '6 GHz', '60g': '60 GHz' }; +let W; + +function words(list) { return new Set(list.map(s => _(s))); } + +function dict() { + return W ??= { + item: words([ 'MAC', 'MAC address', 'BSSID', 'Mode', 'RX', 'TX', 'Gateway', 'DNS', 'Expires', 'Device', 'Type', + 'Tx-Power', 'Noise', 'Country', 'Prefix Delegated' ]), + v6: words([ 'IPv6', 'IPv6-PD' ]), // hidden only when the same list already has an IPv4 address + v4: words([ 'IPv4', 'Address' ]), + row: words([ 'Architecture', 'Target Platform', 'Kernel Version', 'Load Average', 'Buffered', 'Cached' ]), + col: words([ 'MAC address', 'DUID' ]), + host: words([ 'Host', 'Hostname' ]), + fw: _('Firmware Version'), + noise: new RegExp(`\\s*/\\s*${_('Noise')}\\s*$`) // the "Signal / Noise" header + }; +} + +/* Wrap the part of a direct text child that matches re (or its first group) in .td-x. */ +function fold(el, re) { + for (const n of el.childNodes) { + const m = n.nodeType == 3 && re.exec(n.data); + if (!m) continue; + const part = m[1] ?? m[0]; + const mid = n.splitText(m.index + m[0].indexOf(part)); + mid.splitText(part.length); + const s = E('span', { class: 'td-x' }); + mid.replaceWith(s); + s.append(mid); + return s; + } +} + +function hide(el) { + el.classList.add('td-x'); + return el; +} + +const shown = n => n.nodeType == 1 && n.matches('span.nowrap') && !n.classList.contains('td-x'); + +/* L.itemlist() output: Label: value, separated by " | " or
. */ +function list(box, w) { + const nodes = [ ...box.childNodes ]; + const items = nodes.filter(n => n.nodeType == 1 && n.matches('span.nowrap')); + const label = e => e.firstChild?.nodeName == 'STRONG' ? e.firstChild.textContent.replace(/:\s*$/, '') : ''; + const has4 = items.some(e => w.v4.has(label(e))); + + items.forEach(e => { + const l = label(e); + if (w.item.has(l) || (has4 && w.v6.has(l))) hide(e); + else if (l) fold(e, /\s+\([^()]*\)\s*$/); // "149 (5.745 GHz)", "WPA2 PSK (CCMP)" + }); + + /* A separator goes with the item before it, and also when nothing visible follows. */ + let prev = null; + nodes.forEach((n, i) => { + if (items.includes(n)) return void (prev = n); + if (!prev || (shown(prev) && nodes.slice(i + 1).some(shown))) return; + if (n.nodeType == 3) { const s = E('span', { class: 'td-x' }); n.replaceWith(s); s.append(n); } + else if (n.nodeType == 1) hide(n); + }); + + const badge = box.closest('.ifacebadge'); + if (badge && items.length && !items.some(shown)) hide(badge); +} + +function scan(view) { + const w = dict(), out = []; + const mark = el => el && out.push(el); + + new Set([ ...view.querySelectorAll('span.nowrap') ].map(e => e.parentNode)).forEach(box => box.closest('.cbi-tooltip') || list(box, w)); + view.querySelectorAll('span.nowrap.td-x, .ifacebadge.td-x').forEach(mark); + + /* two-column status tables (overview): fold whole rows */ + view.querySelectorAll('.tr').forEach(tr => { + const c = tr.children; + if (c.length != 2) return; + const k = c[0].textContent.trim(); + if (w.row.has(k)) mark(hide(tr)); + else if (k == w.fw) mark(fold(c[1], /^\S+\s+\S+(\s.+)$/s)); // keep "OpenWrt 24.10.5" + }); + + /* device tables (clients, leases): MAC / DUID columns, host suffixes and addresses, Wi-Fi rate details, noise. + * Tables without a host column (neighbours, device config) are about those addresses, so they stay. */ + const named = new Set([ ...view.querySelectorAll('.th') ].filter(th => w.host.has(th.textContent.trim())).map(th => th.closest('.table'))); + view.querySelectorAll('.th').forEach(th => named.has(th.closest('.table')) && w.col.has(th.textContent.trim()) ? mark(hide(th)) : mark(fold(th, w.noise))); + view.querySelectorAll('.td[data-title]').forEach(c => { + const t = c.getAttribute('data-title'); + if (w.col.has(t)) return named.has(c.closest('.table')) && mark(hide(c)); + if (w.noise.test(t)) c.setAttribute('data-title', t.replace(w.noise, '')); // label of the phone layout + if (!w.host.has(t)) return; + c.querySelectorAll('.hide-xs').forEach(e => mark(hide(e))); + mark(fold(c, /\s+\([^()]*\)\s*$/)); + mark(fold(c, /^[^\s.:]*[a-z][^\s.:]*(\.[a-z][\w.-]*)/i)); // "Pixel.lan" -> "Pixel"; IPs stay + }); + view.querySelectorAll('.assoclist .td > span:not(.ifacebadge) > span').forEach(s => mark(fold(s, /,\s.*$/s))); + view.querySelectorAll('[data-noise] > span').forEach(s => mark(fold(s, /\/-?\d+/))); + view.querySelectorAll('.assoclist .ifacebadge[data-ssid]').forEach(mark); + + /* an empty secondary device table (e.g. no DHCPv6 leases) folds away with its title */ + view.querySelectorAll('.table .placeholder').forEach(p => { + const t = p.closest('.table'), h = t.previousElementSibling; + if (named.has(t) && h?.matches('h3') && h != head(t)[1]) { mark(hide(t)); hide(h); } + }); + + /* radio rows on the Wi-Fi page: "5 GHz" instead of the chipset name */ + view.querySelectorAll('[data-sid] [data-name=_stat] big > strong').forEach(s => { + if (s.querySelector('.td-y')) return; + const b = BAND[uci.get('wireless', s.closest('[data-sid]').getAttribute('data-sid'), 'band')]; + if (!b) return; + s.replaceChildren(E('span', { class: 'td-x' }, s.textContent), E('span', { class: 'td-y' }, b)); + mark(s); + }); + + /* long section help (DHCP, firewall, ...) folds behind "说明" instead of a wall of text above the form */ + view.querySelectorAll('.cbi-section-descr').forEach(d => { + if (d.parentNode.matches('details') || d.textContent.trim().length <= 160) return; + const box = E('details', { class: 'td-desc' }, E('summary', {}, [ T('说明', 'Help'), td.html(icon('chev')) ])); + d.replaceWith(box); + box.append(d); + }); + + out.forEach(arm); +} + +/* The section an element belongs to, and the heading that carries its Details button. */ +function head(el) { + let sec = el.closest('.cbi-section'); + if (sec) + return [ sec, sec.querySelector(':scope > h3') ?? sec.querySelector('h3') ]; + sec = el.closest('table'); + return [ sec, sec?.previousElementSibling?.matches('h3') ? sec.previousElementSibling : null ]; +} + +function arm(el) { + const [ sec, h ] = head(el); + if (!h) return; + + sec.classList.add('td-sec'); + if (h.querySelector('.td-detail')) return; + + const btn = E('button', { type: 'button', class: 'td-detail' }); + const paint = open => { + btn.setAttribute('aria-expanded', open); + btn.innerHTML = `${T(open ? '收起' : '详情', open ? 'Less' : 'Details')}${icon('chev')}`; + }; + btn.addEventListener('click', () => paint(flip(sec))); + paint(sec.classList.contains('td-open')); + h.classList.add('td-sec-h'); + h.append(btn); +} + +/* Open or close a section. Opening: the box eases to its new height while the details fade in. + * Closing mirrors it: the details fade out first, then the box settles, so nothing jumps at the end. + * Returns the new state. */ +function flip(sec) { + sec._end?.(); + const open = !sec.classList.contains('td-open'), ms = td.motion('base'); + if (!ms) return sec.classList.toggle('td-open', open); + + let timer, anim; + const end = sec._end = () => { + sec._end = null; + clearTimeout(timer); + anim?.cancel(); + sec.style.overflow = ''; + sec.classList.remove('td-showing', 'td-hiding'); + sec.classList.toggle('td-open', open); + }; + /* Cells that stay, and whatever sits below the tables, glide from where they were instead of jumping + * when the lines and columns around them come or go. A cell is anchored where its content sits: + * left, and middle in a table row or top when stacked on phones. */ + const settle = () => { + const easing = getComputedStyle(sec).getPropertyValue('--ease').trim(), from = sec.offsetHeight; + const rows = [ ...sec.querySelectorAll('.th,.td'), ...[ ...sec.children ].filter(c => !c.querySelector('.td')) ]; + const at = r => { const b = r.getBoundingClientRect(); return b.height ? [ b.left, b.top + (getComputedStyle(r).display == 'table-cell' ? b.height / 2 : 0) ] : null }; + const was = rows.map(at); + sec.classList.remove('td-hiding'); + sec.classList.toggle('td-open', open); + sec.classList.add('td-showing'); + anim = sec.animate({ height: [ `${from}px`, `${sec.offsetHeight}px` ] }, { duration: ms, fill: 'forwards', easing }); + if (getComputedStyle(sec).display != 'table') sec.style.overflow = 'hidden'; // a table only ever shrinks: its rows ease shorter + rows.forEach((r, i) => { + const now = at(r), dx = was[i] && now && was[i][0] - now[0], dy = was[i] && now && was[i][1] - now[1]; + if (Math.abs(dx) + Math.abs(dy) > 1) r.animate({ transform: [ `translate(${dx}px,${dy}px)`, 'none' ] }, { duration: ms, easing }); + }); + timer = setTimeout(end, ms); + }; + + if (open) settle(); + else { sec.classList.add('td-hiding'); timer = setTimeout(settle, td.motion('fast')); } + return open; +} + +return baseclass.extend({ + __init__() { + const view = document.getElementById('view'); + /* the theme's own pages are already plain; the home page repaints every 2 s and need not be rescanned */ + if (!view || /^admin-(home|clients|apps|system-appearance)$/.test(document.body.dataset.page || '')) return; + let queued = false; + const run = () => { queued = false; scan(view); }; + new MutationObserver(() => queued || (queued = true, requestAnimationFrame(run))) + .observe(view, { childList: true, subtree: true }); + run(); + } +}); diff --git a/luci-theme-7dollar/htdocs/luci-static/resources/menu-7dollar.js b/luci-theme-7dollar/htdocs/luci-static/resources/menu-7dollar.js new file mode 100644 index 000000000..3d272b53d --- /dev/null +++ b/luci-theme-7dollar/htdocs/luci-static/resources/menu-7dollar.js @@ -0,0 +1,279 @@ +'use strict'; +'require baseclass'; +'require ui'; +'require poll'; +'require 7dollar.common as td'; +'require 7dollar.tidy as tidy'; + +/* + * Navigation for the 7Dollar theme. + * Native LuCI pages are regrouped into seven plain-language sections; + * anything unknown falls back to "Apps" so third-party plugins always show up. + */ + +const T = td.T, icon = td.icon, esc = td.esc; +const mqPhone = window.matchMedia('(max-width:760px)'); + +return baseclass.extend({ + __init__() { + td.loadMenu().then(L.bind(this.render, this)); + this.bindKeys(); + this.bindMode(); + this.bindVisibility(); + }, + + /* Every LuCI page refreshes through one shared poll loop; stop it while the tab is hidden so a + * forgotten tab costs the router nothing. Coming back restarts it, which refreshes at once. */ + bindVisibility() { + let paused = false; + document.addEventListener('visibilitychange', () => { + if (document.hidden) { + if (poll.active()) { poll.stop(); paused = true; } + } + else if (paused) { + paused = false; + poll.start(); + } + }); + }, + + render(tree) { + const pages = td.flatten(tree); + const groups = td.grouped(pages); + const here = L.env.dispatchpath.join('/'); + let current = null; + + pages.forEach(p => { + if ((here == p.path || here.indexOf(p.path + '/') == 0) && (!current || p.path.length > current.path.length)) + current = p; + }); + + this.pages = pages; + this.groups = groups; + this.current = current; + + this.renderNav(groups, current); + this.renderPills(groups, current); + + if (current) + this.renderTabs(current.node, current.path, current.path.split('/').length); + + const btn = document.getElementById('td-search'); + if (btn) btn.addEventListener('click', () => this.openSearch()); + }, + + href(group) { + const first = group.list[0]; + return L.url(first.path); + }, + + renderNav(groups, current) { + const nav = document.getElementById('td-nav'); + if (!nav) return; + + nav.innerHTML = groups.map((g, i) => { + const on = current && current.group == g.id; + const short = g.short ? `${esc(g.label)}${esc(g.short)}` + : `${esc(g.label)}`; + return `${icon(g.icon)}${short}`; + }).join(''); + + const sys = nav.querySelector('[data-g="system"]'); + if (sys) + sys.addEventListener('click', ev => { + if (!mqPhone.matches) return; + ev.preventDefault(); + this.openSheet(); + }); + }, + + renderPills(groups, current) { + const box = document.getElementById('td-pills'); + if (!box || !current) return; + + const g = groups.find(x => x.id == current.group); + let items = []; + + if (g.id == 'apps') { + if (current.path == 'admin/apps') return; + const all = g.list.find(p => p.path == 'admin/apps'); + if (all) + box.innerHTML = `${icon('chev')}${esc(all.title)}`; + box.innerHTML += `${esc(current.title)}`; + return; + } + + items = g.list; + if (items.length < 2) return; + + /* everyday pages first; the rest wait behind "More" unless one of them is open */ + const pill = (p, cls) => `${esc(p.title)}`; + const rest = items.filter(p => g.pages.indexOf(p.path) < 0); + const fold = rest.length > 1 && rest.indexOf(current) < 0; + box.innerHTML = (fold ? items.filter(p => rest.indexOf(p) < 0) : items).map(p => pill(p)).join('') + + (fold ? `${esc(T('更多', 'More'))}${icon('chev')}` : ''); + if (fold) + box.lastChild.addEventListener('click', ev => { + ev.preventDefault(); + ev.currentTarget.outerHTML = rest.map(p => pill(p, 'td-in')).join(''); + edge(); + }); + const on = box.querySelector('.on'); + if (on && on.scrollIntoView) box.scrollLeft = Math.max(0, on.offsetLeft - 40); + + const edge = () => { + box.classList.toggle('scrolls', box.scrollWidth > box.clientWidth + 2); + box.classList.toggle('end', box.scrollLeft + box.clientWidth >= box.scrollWidth - 2); + }; + box.addEventListener('scroll', edge, { passive: true }); + window.addEventListener('resize', edge); + edge(); + }, + + renderTabs(node, url, level) { + const container = document.getElementById('tabmenu'); + const children = ui.menu.getChildren(node); + + if (!container || !children.length) return; + + const ul = E('ul', { 'class': 'tabs' }); + let active = null; + + children.forEach(child => { + const isActive = (L.env.dispatchpath[level] == child.name); + ul.appendChild(E('li', { 'class': 'tabmenu-item-%s%s'.format(child.name, isActive ? ' active' : '') }, [ + E('a', { 'href': L.url(url, child.name) }, [ _(child.title) ]) + ])); + if (isActive) active = child; + }); + + container.appendChild(ul); + container.style.display = ''; + + if (active) + this.renderTabs(td.realNode(node, active), url + '/' + active.name, level + 1); + }, + + /* ---------- search (⌘K) ---------- */ + + entries() { + if (this._entries) return this._entries; + const out = []; + (this.groups || []).forEach(g => g.list.forEach(p => { + out.push({ path: p.path, title: p.title, group: g.label, icon: g.icon, en: p.node.title || '' }); + ui.menu.getChildren(p.node).forEach(c => out.push({ + path: p.path + '/' + c.name, title: p.title + ' · ' + _(c.title), group: g.label, icon: g.icon, en: c.title || '' + })); + })); + return (this._entries = out); + }, + + openSearch() { + if (document.querySelector('.td-pop:not(.td-out)')) return; + + const pop = td.html(``); + document.body.appendChild(pop); + + const input = pop.querySelector('input'), list = pop.querySelector('.td-pop-list'); + const all = this.entries(); + let sel = 0, shown = []; + + const draw = () => { + const q = input.value.trim().toLowerCase(); + shown = !q ? all.filter(e => e.path.split('/').length <= 3).slice(0, 40) + : all.filter(e => (e.title + ' ' + e.en + ' ' + e.group + ' ' + e.path).toLowerCase().indexOf(q) > -1).slice(0, 40); + sel = Math.min(sel, Math.max(0, shown.length - 1)); + list.innerHTML = shown.length ? shown.map((e, i) => + `${icon(e.icon)}${esc(e.title)}${esc(e.group)}`).join('') + : `
${esc(T('没有找到相关功能', 'Nothing found'))}
`; + const on = list.querySelector('.on'); + if (on) on.scrollIntoView({ block: 'nearest' }); + }; + + const close = () => td.leave(pop); + + input.addEventListener('input', () => { sel = 0; draw() }); + input.addEventListener('keydown', ev => { + if (ev.key == 'ArrowDown') { sel = Math.min(sel + 1, shown.length - 1); draw(); ev.preventDefault() } + else if (ev.key == 'ArrowUp') { sel = Math.max(sel - 1, 0); draw(); ev.preventDefault() } + else if (ev.key == 'Enter' && shown[sel]) { location.href = L.url(shown[sel].path) } + else if (ev.key == 'Escape') close(); + }); + list.addEventListener('mousemove', ev => { + const a = ev.target.closest('a[data-i]'); + if (a && +a.dataset.i != sel) { sel = +a.dataset.i; list.querySelectorAll('a').forEach(x => x.classList.toggle('on', x === a)) } + }); + pop.addEventListener('click', ev => { if (ev.target === pop) close() }); + + draw(); + input.focus(); + }, + + /* ---------- phone: "More" sheet ---------- */ + + openSheet() { + if (document.querySelector('.td-pop:not(.td-out)')) return; + + const rest = (this.groups || []).filter(g => g.mob === false || g.id == 'system'); + const body = rest.map(g => `
${esc(g.label)}
` + + g.list.map(p => `${icon(g.id == 'apps' ? td.appIcon(p.path) : g.icon)}${esc(p.title)}`).join('')).join(''); + + const pop = td.html(``); + + document.body.appendChild(pop); + pop.addEventListener('click', ev => { + const b = ev.target.closest('[data-act]'); + if (ev.target === pop) td.leave(pop); + else if (b && b.dataset.act == 'search') { pop.remove(); this.openSearch() } + else if (b && b.dataset.act == 'mode') this.toggleMode(); + }); + }, + + /* ---------- keyboard & dark mode ---------- */ + + bindKeys() { + const kbd = document.querySelector('.td-search kbd'); + if (kbd && !/Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent)) kbd.textContent = 'Ctrl K'; + + document.addEventListener('keydown', ev => { + const typing = /^(INPUT|TEXTAREA|SELECT)$/.test((ev.target || {}).tagName || '') || (ev.target || {}).isContentEditable; + if ((ev.key == 'k' || ev.key == 'K') && (ev.metaKey || ev.ctrlKey)) { ev.preventDefault(); this.openSearch() } + else if (ev.key == '/' && !typing && !document.body.classList.contains('modal-overlay-active')) { ev.preventDefault(); this.openSearch() } + }); + }, + + bindMode() { + const btn = document.getElementById('td-mode'); + const sync = () => { + if (!btn) return; + const dark = document.documentElement.getAttribute('data-theme') == 'dark'; + btn.innerHTML = icon(dark ? 'sun' : 'moon'); + btn.title = dark ? T('切换到浅色', 'Light mode') : T('切换到深色', 'Dark mode'); + }; + + if (btn) btn.addEventListener('click', () => this.toggleMode()); + document.addEventListener('td-look', sync); + matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { + if (document.documentElement.getAttribute('data-mode') == 'auto') + td.applyLook({ palette: document.documentElement.getAttribute('data-palette'), mode: 'auto' }); + }); + sync(); + }, + + toggleMode() { + const r = document.documentElement; + const mode = r.getAttribute('data-theme') == 'dark' ? 'light' : 'dark'; + td.applyLook({ palette: r.getAttribute('data-palette') || 'clay', mode: mode }); + td.saveSettings({ mode: mode }).catch(() => {}); + } +}); diff --git a/luci-theme-7dollar/htdocs/luci-static/resources/view/7dollar/appearance.js b/luci-theme-7dollar/htdocs/luci-static/resources/view/7dollar/appearance.js new file mode 100644 index 000000000..9397b8494 --- /dev/null +++ b/luci-theme-7dollar/htdocs/luci-static/resources/view/7dollar/appearance.js @@ -0,0 +1,110 @@ +'use strict'; +'require view'; +'require ui'; +'require 7dollar.common as td'; + +/* Appearance & home preferences. Saved to /etc/config/7dollar immediately; never touches networking. */ + +const T = td.T, icon = td.icon, esc = td.esc; + +const CARDS = [ + [ 'rate', T('实时速率', 'Live traffic'), T('上传和下载速度曲线', 'Upload and download chart') ], + [ 'dev', T('在线设备', 'Online devices'), T('正在使用网络的设备', 'Devices currently online') ], + [ 'wifi', 'Wi‑Fi', T('名称、频段和连接数', 'Name, bands and clients'), t => td.menuHas(t, 'network/wireless'), T('这台设备没有无线网卡', 'This device has no wireless hardware') ], + [ 'sys', T('路由器状态', 'Router status'), T('处理器、内存、温度', 'CPU, memory, temperature') ], + [ 'ports', T('网口', 'Ports'), T('哪些网口接了网线、速率多少', 'Which ports are plugged in, and how fast') ], + [ 'disk', T('存储空间', 'Storage'), T('还剩多少空间装插件', 'Space left for apps and settings') ], + [ 'actions', T('常用操作', 'Quick actions'), T('备份、软件包、诊断、日志', 'Backup, software, diagnostics, logs') ], + [ 'apps', T('应用', 'Apps'), T('正在运行的插件', 'Plugins that are running'), t => td.flatten(t).some(p => p.group == 'apps' && p.path != 'admin/apps'), T('还没有安装插件', 'No plugins installed yet') ] +]; + +return view.extend({ + load() { + return Promise.all([ td.loadSettings(), td.loadMenu(), td.uciSection('luci', 'main') ]); + }, + + render([ conf, tree, luci ]) { + this.conf = conf; + const usingUs = /\/7dollar$/.test((luci || {}).mediaurlbase || L.env.media || ''); + + const pal = td.PALETTES.map(([ id, name ]) => ``).join(''); + + const cards = CARDS.map(([ k, n, d, need, why ]) => need && !need(tree) + ? `
${esc(n)} ${esc(T('未检测到', 'Not found'))}${esc(why)}
` + : `
${esc(n)}${esc(d)}
`).join(''); + + + const root = td.html(`
+
+

${esc(T('系统', 'System'))}

+

${esc(T('外观与首页', 'Appearance'))}

+

${esc(T('只影响界面显示,立即生效,不会改动网络设置。', 'Only changes how the interface looks. Applies instantly and never touches your network.'))}

+
+ + ${usingUs ? '' : `

${esc(T('当前没有使用 7Dollar 主题', '7Dollar is not the active theme'))}

${esc(T('下面的设置只在 7Dollar 主题下生效。', 'The settings below only apply to the 7Dollar theme.'))}

`} + +
+

${esc(T('配色', 'Palette'))}

+
${pal}
+
${esc(T('显示模式', 'Mode'))}
+
+
+ +
+

${esc(T('首页卡片', 'Home cards'))}

+

${esc(T('“网络连接”卡片始终显示。', 'The Connection card is always shown.'))}

+
${cards}
+
+ +
${icon('info')}${esc(T('换回原主题:系统 → 通用 → 语言和界面', 'Switch back: System → General → Language and Style'))}
+
`); + + this.root = root; + + root.querySelectorAll('[data-pick]').forEach(b => b.addEventListener('click', () => this.update({ palette: b.dataset.pick }))); + root.querySelectorAll('#td-modes button').forEach(b => b.addEventListener('click', () => this.update({ mode: b.dataset.m }))); + root.querySelectorAll('[data-card]').forEach(b => b.addEventListener('click', () => { + const k = b.dataset.card, field = td.OPT_IN.indexOf(k) > -1 ? 'shown_cards' : 'hidden_cards'; + const list = td.words(this.conf[field]), i = list.indexOf(k); + i > -1 ? list.splice(i, 1) : list.push(k); + this.update({ [field]: list.join(' ') }); + })); + + const use = root.querySelector('#td-use'); + if (use) use.addEventListener('click', () => { + use.disabled = true; + td.uciSet('luci', 'main', { mediaurlbase: '/luci-static/7dollar' }).then(() => location.reload()) + .catch(e => { use.disabled = false; ui.addNotification(null, E('p', e.message)) }); + }); + + this.sync(); + return root; + }, + + sync() { + const c = this.conf; + this.root.querySelectorAll('.td-pal').forEach(b => b.classList.toggle('on', b.dataset.pick == c.palette)); + this.root.querySelectorAll('#td-modes button').forEach(b => b.classList.toggle('on', b.dataset.m == c.mode)); + this.root.querySelectorAll('[data-card]').forEach(b => b.classList.toggle('on', td.cardOn(c, b.dataset.card))); + }, + + update(patch) { + Object.assign(this.conf, patch); + this.sync(); + if (td.active && ('palette' in patch || 'mode' in patch)) + td.applyLook(this.conf); + + const save = {}; + for (const k in patch) save[k] = patch[k]; + + return td.saveSettings(save) + .then(() => td.toast(T('已保存', 'Saved'))) + .catch(e => ui.addNotification(null, E('p', T('保存失败:', 'Could not save: ') + (e.message || e)), 'error')); + }, + + handleSave: null, + handleSaveApply: null, + handleReset: null +}); diff --git a/luci-theme-7dollar/htdocs/luci-static/resources/view/7dollar/apps.js b/luci-theme-7dollar/htdocs/luci-static/resources/view/7dollar/apps.js new file mode 100644 index 000000000..8c9e9728d --- /dev/null +++ b/luci-theme-7dollar/htdocs/luci-static/resources/view/7dollar/apps.js @@ -0,0 +1,46 @@ +'use strict'; +'require view'; +'require ui'; +'require 7dollar.common as td'; + +/* Every plugin page the router has, as tiles. The theme depends on none of them. */ + +const T = td.T, icon = td.icon, esc = td.esc; + +return view.extend({ + load() { + if (!td.active) { + location.replace(L.url('admin/services')); + return new Promise(() => {}); + } + return td.loadMenu(); + }, + + render(tree) { + const pages = td.flatten(tree).filter(p => p.group == 'apps' && p.path != 'admin/apps'); + const CAT = { services: T('服务', 'Service'), vpn: 'VPN', nas: T('存储', 'Storage'), docker: T('容器', 'Container'), statistics: T('统计', 'Statistics') }; + + const tiles = pages.map((p, i) => { + const cat = CAT[p.path.split('/')[1]] || T('插件', 'Plugin'); + return `
${icon(td.appIcon(p.path))}
${esc(p.title)}${esc(cat)}
`; + }).join(''); + + const add = td.software(tree); + + return td.html(`
+
+

${esc(T('应用', 'Apps'))}

+

${esc(T('已安装的应用', 'Installed apps'))}

+

${esc(pages.length ? T(`共 ${pages.length} 个`, `${pages.length} installed`) + : T('还没有安装插件。', 'No plugins installed yet.'))}

+
+ +
`); + }, + + handleSave: null, + handleSaveApply: null, + handleReset: null +}); diff --git a/luci-theme-7dollar/htdocs/luci-static/resources/view/7dollar/devices.js b/luci-theme-7dollar/htdocs/luci-static/resources/view/7dollar/devices.js new file mode 100644 index 000000000..de99c39b2 --- /dev/null +++ b/luci-theme-7dollar/htdocs/luci-static/resources/view/7dollar/devices.js @@ -0,0 +1,92 @@ +'use strict'; +'require view'; +'require poll'; +'require 7dollar.common as td'; + +/* All devices: online (Wi-Fi + wired) and recently seen DHCP clients. The summary tiles filter the list. */ + +const T = td.T, icon = td.icon, esc = td.esc; +const VIEWS = [ + [ 'on', T('在线', 'Online'), c => c.online ], + [ 'wifi', 'Wi‑Fi', c => c.online && c.conn == 'wifi' ], + [ 'wired', T('有线', 'Wired'), c => c.online && c.conn != 'wifi', T('局域网', 'LAN') ], // a side router sees the whole LAN here + [ 'off', T('最近连接过', 'Recently seen'), c => !c.online ] +]; + +return view.extend({ + load() { + if (!td.active) { + location.replace(L.url('admin/status/overview')); + return new Promise(() => {}); + } + return td.clients(); + }, + + render(list) { + const root = td.html(`
+
+

${esc(T('设备', 'Devices'))}

+

${esc(T('谁在用网络', 'Who is online'))}

+

 

+
+ ${icon('lock')}${esc(T('给设备固定 IP', 'Reserve an IP'))}
+
${VIEWS.map(([ k, n ]) => ``).join('')}
+
+
`); + + this.root = root; + this.view = 'on'; + root.querySelector('#td-dstats').addEventListener('click', ev => { + const b = ev.target.closest('[data-v]'); + if (b) { this.view = b.dataset.v; this.paint(this.list); } + }); + this.paint(list); + poll.add(() => td.clients().then(l => this.paint(l)), 5); + return root; + }, + + row(c) { + const meta = []; + if (c.ip) meta.push(`${esc(c.ip)}`); + meta.push(`${esc(c.mac)}`); + + let sub; + if (!c.online) sub = c.expires > 0 ? T('租约剩余 ', 'Lease ') + td.duration(c.expires) : T('离线', 'Offline'); + else if (c.conn == 'wifi') { + const [ s ] = td.signal(c.signal); + sub = `Wi‑Fi ${td.bandName(c.band)} · ${s}${c.signal ? ` (${c.signal} dBm)` : ''}`; + if (c.link) meta.unshift(`${Math.round(c.link)} Mbps`); + if (c.since) meta.push(`${esc(T('已连接 ', 'Connected ') + td.duration(c.since))}`); + } + else sub = td.connName(c); + + return `
${icon(c.kind)}
${esc(c.display)}${esc(sub)}
${meta.join('')}
`; + }, + + paint(list) { + this.list = list; + const on = list.filter(c => c.online); + const $ = id => this.root.querySelector('#' + id); + + $('td-dsum').textContent = on.length + ? T(`现在有 ${on.length} 台设备在线。名字来自设备自己上报的主机名。`, `${on.length} device${on.length == 1 ? '' : 's'} online right now.`) + : T('现在没有设备在线。', 'No devices online right now.'); + + let rows = []; + const lan = list.some(c => c.conn == 'lan'); + VIEWS.forEach(([ k, n, match, alt ]) => { + const b = $('td-dstats').querySelector(`[data-v="${k}"]`), sel = list.filter(match); + b.firstChild.textContent = lan && alt || n; + b.lastChild.textContent = sel.length; + b.hidden = lan && k == 'wifi' && !sel.length; // no Wi-Fi of our own to count + b.setAttribute('aria-pressed', k == this.view); + if (k == this.view) rows = sel; + }); + const box = $('td-dlist'), html = rows.length ? rows.map(c => this.row(c)).join('') : `
${esc(T('这里暂时没有设备', 'No devices here'))}
`; + if (box._html !== html) box.innerHTML = box._html = html; // every 5 s, but the DOM only moves when something changed + }, + + handleSave: null, + handleSaveApply: null, + handleReset: null +}); diff --git a/luci-theme-7dollar/htdocs/luci-static/resources/view/7dollar/home.js b/luci-theme-7dollar/htdocs/luci-static/resources/view/7dollar/home.js new file mode 100644 index 000000000..2301a6e9a --- /dev/null +++ b/luci-theme-7dollar/htdocs/luci-static/resources/view/7dollar/home.js @@ -0,0 +1,556 @@ +'use strict'; +'require view'; +'require fs'; +'require poll'; +'require request'; +'require ui'; +'require 7dollar.common as td'; + +/* + * 7Dollar home dashboard. Only router data by default. The opt-in apps card lists whatever + * plugins are running: name from the menu, state from procd, no polling of the plugins. + */ + +const T = td.T, icon = td.icon, esc = td.esc; +const N = 60; +const RING = 2 * Math.PI * 24; + +/* One line of what matters most for the popular apps, read from their config (never their API); + * every other app shows name and state only. Keyed by procd service name. */ +const MODE = { + rule: T('按规则', 'Rule'), global: T('全局', 'Global'), direct: T('直连', 'Direct'), all: T('全局', 'Global'), custom: T('自定义', 'Custom'), + gfw: T('GFW 列表', 'GFW list'), gfwlist: T('GFW 列表', 'GFW list'), router: T('绕过大陆', 'Bypass mainland'), bypass_mainland_china: T('绕过大陆', 'Bypass mainland'), + proxy_mainland_china: T('只代理大陆', 'Mainland only'), oversea: T('海外模式', 'Overseas'), + none: T('未接管 DNS', 'DNS not taken over'), redirect: T('接管 53 端口', 'Takes over port 53'), 'dnsmasq-upstream': T('dnsmasq 上游', 'dnsmasq upstream'), exchange: T('替换 dnsmasq', 'Replaces dnsmasq') +}; +const sec = (cfg, s) => s[0] == '@' ? td.uciType(cfg, s.slice(1)).then(a => Object.values(a)[0] || {}) : td.uciSection(cfg, s); +const plain = (cfg, s, f) => () => sec(cfg, s).then(c => ({ sub: f(c) })); +/* current node: the section named in `key`, shown by its `label` field */ +const node = (cfg, s, key, label, more) => () => sec(cfg, s).then(c => (c[key] && c[key] != 'nil' ? td.uciSection(cfg, c[key]) : Promise.resolve({})) + .then(n => ({ sub: [ n[label] || (c[key] != 'nil' && c[key]) || T('未选节点', 'No node'), more && more(c) ].filter(Boolean).join(' · ') }))); +const panel = port => ({ link: `http://${location.hostname}:${port}/` }); +const shares = (cfg, type) => () => Promise.all([ td.uciType(cfg, type), td.mounts() ]).then(([ s, m ]) => { + const list = Object.values(s).filter(x => x.path); + const disk = p => m.filter(x => x.mount == '/' || x.mount == p || p.indexOf(x.mount + '/') == 0).sort((a, b) => b.mount.length - a.mount.length)[0]; + const d = list.map(x => disk(x.path)).find(Boolean); + return { sub: list.map(x => x.name).join(' · '), rt: d && d.size ? T(`剩余 ${td.bytes(d.avail)} / 共 ${td.bytes(d.size)}`, `${td.bytes(d.avail)} free of ${td.bytes(d.size)}`) : '' }; +}); +const APPS = { + openclash: () => td.uciSection('openclash', 'config').then(c => ({ + sub: String(c.en_mode || c.operation_mode || '').replace(/^fake-ip/, 'Fake-IP').replace(/^redir-host/, 'Redir-Host').replace(/-(tun|mix)$/, s => ' · ' + s.slice(1).toUpperCase()), + seg: c.proxy_mode || 'rule' })), + passwall: node('passwall', '@global', 'tcp_node', 'remarks'), + passwall2: node('passwall2', '@global', 'node', 'remarks'), + shadowsocksr: node('shadowsocksr', '@global', 'global_server', 'alias', c => MODE[c.run_mode]), + homeproxy: node('homeproxy', 'config', 'main_node', 'label', c => MODE[c.routing_mode]), + nikki: plain('nikki', 'mixin', c => MODE[c.mode]), + adguardhome: () => td.uciSection('AdGuardHome', 'AdGuardHome').then(c => Object.assign({ sub: MODE[c.redirect] }, panel(c.httpport || 3000))), + openbox: () => td.readFile('/opt/open-box/data/panel-port').then(p => panel(+String(p || '').trim() || 2026)), + mosdns: plain('mosdns', 'config', c => c.listen_port && T('端口 ', 'Port ') + c.listen_port), + smartdns: plain('smartdns', '@smartdns', c => c.port && T('端口 ', 'Port ') + c.port), + bandix: plain('bandix', 'general', c => c.iface && T('监控 ', 'Watching ') + c.iface), + ksmbd: shares('ksmbd', 'share'), + samba4: shares('samba4', 'sambashare') +}; +const PROTO = { dhcp: T('自动获取', 'DHCP'), pppoe: T('宽带拨号', 'PPPoE'), static: T('静态地址', 'Static'), dhcpv6: 'DHCPv6', qmi: T('移动网络', 'Cellular'), ncm: T('移动网络', 'Cellular'), modemmanager: T('移动网络', 'Cellular') }; + +return view.extend({ + load() { + if (!td.active) { + location.replace(L.url('admin/status/overview')); + return new Promise(() => {}); + } + + return Promise.all([ + td.loadSettings(), + td.board(), + td.sysinfo(), + td.wan(), + td.loadMenu().then(menu => Promise.all([ menu, td.menuHas(menu, 'network/wireless') ? td.wifiInfo() : null ])), + td.uciType('firewall', 'defaults'), + Promise.all([ 'mt7915e', 'mt7996e' ].map(m => td.readFile(`/sys/module/${m}/parameters/wed_enable`))).then(l => l.find(Boolean)), // Wi-Fi 6 / Wi-Fi 7 MediaTek + td.readFile('/proc/sys/net/ipv4/tcp_congestion_control'), + td.readFile('/proc/stat'), + td.readFile('/proc/net/dev') + ]); + }, + + render([ conf, board, info, wan, [ menu, wifi ], fw, wed, cc, stat, dev ]) { + const apps = td.flatten(menu).filter(p => p.group == 'apps' && p.path != 'admin/apps'); + const mode = wan && wan.up ? wan.mode : 'wan'; + const software = td.software(menu); + + this.s = { + down: new Array(N).fill(0), up: new Array(N).fill(0), + last: null, cpu: td.cpuStat(stat), cpuPct: null, wan: wan, wifi: wifi || { radios: {}, aps: [] }, info: info, board: board, + clients: [], ping: null, apps: apps, hasWifi: !!(wifi && Object.keys(wifi.radios).length), mode: mode + }; + + const fwd = Object.values(fw || {})[0] || {}; + const tags = []; + if (fwd.flow_offloading_hw == '1') tags.push(T('硬件流量卸载', 'Hardware offload')); + else if (fwd.flow_offloading == '1') tags.push(T('软件流量卸载', 'Software offload')); + if (/^Y/.test(wed || '')) tags.push(T('无线加速 WED', 'Wireless offload (WED)')); + if (/bbr/.test(cc || '')) tags.push(T('BBR 拥塞控制', 'BBR congestion control')); + this.s.tags = tags; + + let n = 1; + const card = (key, cls, inner) => !td.cardOn(conf, key) ? '' : `
${inner}
`; + const col = c => c.length ? `
${c.join('')}
` : ''; + + /* Topology follows how this box is wired: main router (upstream → this → devices) or + * side router / AP, which hangs off the main router next to the other devices. */ + const UP = { wan: [ 'modem', T('上级网络', 'Upstream') ], sta: [ 'wifi', T('上级 Wi‑Fi', 'Upstream Wi‑Fi') ], side: [ 'router', T('主路由', 'Main router') ] }[mode]; + const link = (attr, tid) => ``; + const nUp = `
${icon(UP[0])}
${esc(UP[1])}—
`; + const nMe = `
${icon('router')}
${esc(board.hostname || 'OpenWrt')}${esc(mode == 'side' ? T('旁路由', 'Side router') : T('这台路由器', 'This router'))}
`; + const nDev = `
${icon('devices')}
— 
`; + const lUp = link(' id="td-link-up"', 'td-lat'), lLan = link('', 'td-lan-t'); + const topo = mode == 'side' ? nMe + lUp + nUp + lLan + nDev : nUp + lUp + nMe + lLan + nDev; + /* A side router receives and resends the same packets on one LAN port, so "down/up" would just + * be the same number twice: show one "forwarded" figure instead. */ + const rate = (id, label, cls) => `
${esc(label)}0Kbps
`; + const rates = (mode == 'side' ? rate('down', T('经过本机', 'Through this box')) + : rate('down', T('下载', 'Download')) + rate('up', T('上传', 'Upload'), ' up')) + + `
${esc(T('本次开机', 'Since boot'))}—
`; + const os = board.release ? `${board.release.distribution} ${board.release.version}` : 'OpenWrt'; + + const rateCard = card('rate', 'k-rate', ` +

${esc(T('实时速率', 'Live traffic'))}

${esc(T('过去 2 分钟', 'Last 2 minutes'))}
+
${rates}
+ `); + + /* Cards below the top row, in reading order. Each goes into whichever column is shorter so far + * (rough heights), so the two columns stay balanced whatever is hidden or missing. */ + const H = { dev: 6, wifi: 4, sys: 3.5, apps: 3, ports: 2, disk: 2, actions: 3 }, cols = [ [], [] ], h = [ 0, 0 ]; + for (const [ k, html ] of [ + [ 'dev', card('dev', 'k-dev', ` +

${esc(T('在线设备', 'Online devices'))}

${esc(T('全部', 'All'))}${icon('chev')}
+
${esc(T('正在读取…', 'Loading…'))}
`) ], + [ 'wifi', this.s.hasWifi ? card('wifi', 'k-wifi', ` + +
`) : '' ], + [ 'sys', card('sys', 'k-sys', ` +

${esc(T('路由器状态', 'Router status'))}

${esc(os)}
+
+ ${this.ring('cpu', T('处理器', 'CPU'))} + ${this.ring('mem', T('内存', 'Memory'))} + ${this.ring('temp', T('温度', 'Temperature'), 'warm')} +
+
`) ], + [ 'apps', apps.length ? card('apps', 'k-apps', ` + +
${esc(T('正在读取…', 'Loading…'))}
`) : '' ], + [ 'ports', card('ports', 'k-ports', ` +

${esc(T('网口', 'Ports'))}

+
`) ], + [ 'disk', card('disk', 'k-disk', ` +

${esc(T('存储空间', 'Storage'))}

+
`) ], + [ 'actions', card('actions', 'k-actions', ` +

${esc(T('常用操作', 'Quick actions'))}

+
+ ${this.act('admin/system/flash', 'backup', T('备份配置', 'Back up'))} + ${software ? this.act('admin/' + software, 'box', T('软件包', 'Software')) : ''} + ${this.act('admin/network/diagnostics', 'pulse', T('网络诊断', 'Diagnostics'))} + ${this.act('admin/status/logs', 'doc', T('查看日志', 'Logs'))} +
`) ] + ]) if (html) { const c = h[1] < h[0] ? 1 : 0; cols[c].push(html); h[c] += H[k]; } + + const root = td.html(`
+
+
+

+

${esc(T('你好', 'Hello'))}

+

 

+
+ ${icon('refresh')}${esc(T('重启路由器', 'Reboot'))} +
+ +
+
+

${esc(T('网络连接', 'Connection'))}

+
${topo}
+
+
${esc(mode == 'side' ? T('本机地址', 'Address') : T('上网地址', 'WAN address'))}
—
+
${esc(T('网关延迟', 'Gateway latency'))}
—
+
DNS
—
+
${esc(T('已连接', 'Connected for'))}
—
+
+
+
+ + ${rateCard} +
${col(cols[0])}${col(cols[1])}
+
`); + + this.root = root; + this.$ = id => root.querySelector('#' + id); + + this.greet(); + this.paintStatic(); + this.paintWifi(this.s.wifi); + this.paintDisk(); + this.refreshPorts(); + this.ticks = 0; + this.sample(dev, info); + this.refreshClients(); + this.pingGateway(); + + poll.add(L.bind(this.tick, this), 2); + poll.add(L.bind(this.slow, this), 10); + + this.paintApps(); + + requestAnimationFrame(() => requestAnimationFrame(() => this.paintRings())); + + return root; + }, + + ring(key, label, cls) { + return `
—${esc(label)}
`; + }, + + act(path, ic, title) { + return `${icon(ic)}${esc(title)}`; + }, + + set(id, text) { + const el = this.$(id); + if (el && el.textContent !== String(text)) el.textContent = text; + }, + + /* innerHTML only when it changed: polled cards keep their DOM (and hover state) between ticks */ + put(el, html) { + if (el && el._html !== html) el.innerHTML = el._html = html; + }, + + greet() { + const d = new Date(), h = d.getHours(); + const hi = T(h < 5 ? '夜深了' : h < 11 ? '早上好' : h < 14 ? '中午好' : h < 18 ? '下午好' : '晚上好', + h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening'); + const ok = this.s.wan && this.s.wan.up; + this.set('td-greet', ok ? hi + T(',一切都好', ', all good') : T('网络好像断开了', 'You seem to be offline')); + this.set('td-today', td.ZH ? `星期${'日一二三四五六'[d.getDay()]} · ${d.getMonth() + 1} 月 ${d.getDate()} 日` + : d.toLocaleDateString('en', { weekday: 'long', month: 'long', day: 'numeric' })); + }, + + paintStatic() { + const w = this.s.wan; + const bands = [...new Set((this.s.wifi.aps || []).filter(a => a.up && a.stations.length).map(a => td.bandName(a.band)))]; + + /* PPPoE is terminated by this router, not by the upstream network. Keep the labels in sync + * when the WAN reconnects or fails over to a different protocol. */ + const dial = this.s.mode == 'wan' && w && w.proto == 'pppoe'; + if (this.s.mode == 'wan') { + this.put(this.$('td-up-icon'), icon(dial ? 'globe' : 'modem')); + this.set('td-up-title', dial ? T('互联网', 'Internet') : T('上级网络', 'Upstream')); + } + this.set('td-router-role', this.s.mode == 'side' ? T('旁路由', 'Side router') : dial ? T('本机拨号', 'PPPoE dial-up') : T('这台路由器', 'This router')); + + this.set('td-lan-t', this.s.mode == 'side' ? T('局域网', 'LAN') : bands.length ? 'Wi‑Fi ' + bands.join(' / ') : T('有线', 'Wired')); + + const ok = !!(w && w.up), chip = this.$('td-conn-chip'); + this.$('td-up-node').classList.toggle('bad', !ok); + this.$('td-link-up').classList.toggle('down', !ok); + if (chip.dataset.ok != String(ok)) { + chip.dataset.ok = ok; + chip.className = ok ? 'td-chip' : 'td-chip bad'; + chip.innerHTML = ok ? `${esc(T('畅通', 'Online'))}` : `${esc(T('未连接', 'Offline'))}`; + } + + /* further lines (IPTV, a backup WAN): one quiet row each under the main one */ + const lines = ((w && w.more) || []).map(u => `

${esc(u.name)}${esc([ PROTO[u.proto] || u.proto, u.ip, u.uptime && td.duration(u.uptime) ].filter(Boolean).join(' · '))}

`).join(''); + this.put(this.$('td-lines'), lines); + + if (!ok) { + this.set('td-proto', T('没有连接', 'Not connected')); + return; + } + + this.set('td-proto', this.s.mode == 'side' ? w.gw || '—' : this.s.mode == 'sta' ? T('无线中继', 'Wi‑Fi uplink') : dial ? T('运营商网络', 'ISP network') : PROTO[w.proto] || w.proto || '—'); + this.set('td-wanip', w.ip || w.ip6 || '—'); + this.set('td-dns', td.dnsName(w.dns, w.gw, this.s.mode == 'side' ? T('主路由', 'Main router') : null)); + this.set('td-wanup', w.uptime ? td.duration(w.uptime) : '—'); + + this.paintTags(); + }, + + paintTags() { + const box = this.$('td-tags'); + this.put(box, this.s.tags.map(t => `${icon('check')}${esc(t)}`).join('')); + }, + + paintWifi(wifi) { + const box = this.$('td-wifi'); + this.s.wifi = wifi; + if (!box) return; + + const radios = wifi.radios || {}, aps = wifi.aps || []; + const devs = k => k + ' ' + T('台', k == 1 ? 'client' : 'clients'); + const n = aps.reduce((a, ap) => a + ap.stations.length, 0); + const guest = aps.find(a => (a.network || []).some(x => /guest/i.test(x))); + /* The headline is the network people mostly join: 5 GHz first (2.4 GHz is often the + * smart-home one), never the guest network. A band with another name shows it on its tile. */ + const rank = ap => (ap.up ? 10 : 0) + (ap == guest ? -5 : 0) + ({ '5g': 3, '6g': 2, '2g': 1 }[ap.band] || 0); + const main = aps.filter(ap => ap.up).sort((a, b) => rank(b) - rank(a))[0]; + const tile = (label, value, sub, off) => `
${esc(label)}
${esc(value)}
${sub ? `
${esc(sub)}
` : ''}`; + + const tiles = Object.keys(radios).sort((a, b) => String((radios[a].config || {}).band).localeCompare(String((radios[b].config || {}).band))).map(r => { + const c = radios[r].config || {}, iw = (radios[r].iwinfo || {}); + const on = radios[r].up && !c.disabled; + const width = String(c.htmode || '').replace(/^[A-Z]+/, ''); + const mine = aps.filter(a => a.radio == r && a != guest); + const other = [ ...new Set(mine.filter(a => a.ssid && a.ssid != (main || {}).ssid).map(a => a.ssid)) ].join(' / '); + return tile(td.bandName(c.band) + (other ? ' · ' + other : ''), + on ? devs(mine.reduce((a, ap) => a + ap.stations.length, 0)) : T('已关闭', 'Off'), + on ? `${T('信道', 'Ch')} ${iw.channel || c.channel || 'auto'}${width ? ` · ${width} MHz` : ''}` : '', !on); + }); + + if (guest) + tiles.push(tile(T('访客网络', 'Guest network'), guest.up ? devs(guest.stations.length) : T('未开启', 'Off'), guest.ssid, !guest.up)); + + this.put(box, (main + ? `
${esc(main.ssid || '—')}

${esc(n + ' ' + T('台设备已连接', n == 1 ? 'device connected' : 'devices connected'))}

` + : `
${esc(T('未开启', 'Off'))}

${esc(T('这台路由器还没有开启无线网络', 'No wireless network is enabled'))}

`) + + (tiles.length ? `
${tiles.join('')}
` : '')); + }, + + /* ---------- ports and storage: ports, apps and the gateway ping every 30 s; storage comes with the system info we already poll ---------- */ + + refreshPorts() { + const box = this.$('td-ports'); + if (!box) return Promise.resolve(); + return td.ports(this.s.wan).then(list => { + if (!list.length) return box.closest('.td-card').remove(); // nothing the kernel can tell us (swconfig) + const lan = list.filter(p => !p.line); + let k = 0; + this.set('td-ports-n', T(`${list.filter(p => p.up).length} / ${list.length} 已接网线`, `${list.filter(p => p.up).length} of ${list.length} in use`)); + this.put(box, list.map(p => { + const m = /^lan(\d+)$/.exec(p.name); + const name = p.line || (m ? 'LAN ' + m[1] : lan.length > 1 ? 'LAN ' + ++k : 'LAN'); + const speed = !p.up ? T('未接网线', 'Unplugged') : p.speed > 0 ? (p.speed >= 1000 ? p.speed / 1000 + ' Gbps' : p.speed + ' Mbps') : T('已连接', 'Connected'); + return `
${esc(name)}
${esc(speed)}
`; + }).join('')); + }); + }, + + paintDisk() { + const box = this.$('td-disk'); + if (!box) return; + const info = this.s.info || {}; + const row = (label, d) => { + if (!d || !d.total) return ''; + const used = d.used != null ? d.used : d.total - d.free, pct = Math.round(used / d.total * 100); + return `

${esc(label)}${esc(T(`剩余 ${td.bytes((d.avail != null ? d.avail : d.free) * 1024)} / 共 ${td.bytes(d.total * 1024)}`, + `${td.bytes((d.avail != null ? d.avail : d.free) * 1024)} free of ${td.bytes(d.total * 1024)}`))}

`; + }; + const html = row(T('系统空间', 'System'), info.root) + row(T('临时文件', 'Temporary files'), info.tmp) + row(T('虚拟内存', 'Swap'), info.swap); + this.put(box, html); + }, + + refreshClients() { + return td.clients(this.s.wifi, this.s.wan).then(list => { + const online = list.filter(c => c.online); + this.s.clients = online; + this.set('td-ndev', online.length + ' ' + T('台设备', online.length == 1 ? 'device' : 'devices')); + this.set('td-ndev-s', online.length ? T('正在上网', 'online') : T('暂无设备', 'none yet')); + this.paintSummary(); + + const box = this.$('td-devs'); + if (!box) return; + + const top = online.slice(0, 6); + this.put(box, top.length ? top.map(c => { + const [ sig ] = td.signal(c.signal); + const sub = c.conn == 'wifi' ? `Wi‑Fi ${td.bandName(c.band)} · ${sig}` : td.connName(c) + (c.ip && c.ip != c.display ? ' · ' + c.ip : ''); + const rt = c.conn == 'wifi' && c.link + ? `${Math.round(c.link)} Mbps
` + : `${esc(c.conn == 'lan' ? T('局域网', 'LAN') : T('有线', 'Wired'))}
`; + return `
${icon(c.kind)}
${esc(c.display)}${esc(sub)}
${rt}
`; + }).join('') + (online.length > 6 ? `${esc(T(`还有 ${online.length - 6} 台设备`, `${online.length - 6} more`))}` : '') + : `
${esc(T('现在没有设备在线', 'No devices online'))}
`); + }); + }, + + paintSummary() { + const info = this.s.info || {}, n = this.s.clients.length; + const host = (this.s.board || {}).hostname || T('路由器', 'Router'); + if (!this.s.wan || !this.s.wan.up) { + this.set('td-summary', T('上级网络没有响应,检查一下光猫或网线是否接好。', 'The upstream network is not responding. Check the modem and cables.')); + return; + } + this.set('td-summary', td.ZH + ? `${host} 已平稳运行 ${td.duration(info.uptime)},${n} 台设备在线。` + : `${host} has been up for ${td.duration(info.uptime)} with ${n} device${n == 1 ? '' : 's'} online.`); + }, + + sample(devText, info) { + const now = Date.now(); + const w = this.s.wan; + const nd = td.netdev(devText); + const d = w && nd[w.device]; + + if (info) this.s.info = info; + + if (d && this.s.last && now - this.s.last.t < 10000) { + const dt = Math.max(0.5, (now - this.s.last.t) / 1000); + const rx = Math.max(0, (d.rx - this.s.last.rx) / dt), tx = Math.max(0, (d.tx - this.s.last.tx) / dt); + this.s.down.push(rx); this.s.down.shift(); + this.s.up.push(tx); this.s.up.shift(); + const [ dv, du ] = td.rate(rx), [ uv, uu ] = td.rate(tx); + this.set('td-down', dv); this.set('td-down-u', du); + this.set('td-up', uv); this.set('td-up-u', uu); + this.drawChart(); + } + + if (d) { + this.s.last = { t: now, rx: d.rx, tx: d.tx }; + this.set('td-boot', this.s.mode == 'side' ? td.bytes(d.rx) : `↓ ${td.bytes(d.rx)} ↑ ${td.bytes(d.tx)}`); + } + + this.paintRings(); + }, + + drawChart() { + const svg = this.$('td-chart'); + if (!svg) return; + + const down = this.s.down, up = this.s.up; + const max = Math.max(1, ...down, ...up) * 1.15; + const W = 600, H = 118; + const path = (a, close) => { + const p = a.map((v, i) => [ i * W / (a.length - 1), H - 6 - (v / max) * (H - 22) ]); + let s = `M${p[0]}`; + for (let i = 0; i < p.length - 1; i++) { + const p0 = p[i - 1] || p[i], p1 = p[i], p2 = p[i + 1], p3 = p[i + 2] || p2; + s += `C${p1[0] + (p2[0] - p0[0]) / 6},${p1[1] + (p2[1] - p0[1]) / 6} ${p2[0] - (p3[0] - p1[0]) / 6},${p2[1] - (p3[1] - p1[1]) / 6} ${p2}`; + } + return close ? s + `L${W},${H}L0,${H}Z` : s; + }; + + const [ fill, line, upLine ] = svg.querySelectorAll('path'); + fill.setAttribute('d', path(down, true)); + line.setAttribute('d', path(down)); + if (upLine) upLine.setAttribute('d', path(up)); + }, + + paintRings() { + const setRing = (key, pct, text, label, cls) => { + const c = this.$('td-ring-' + key); + if (!c) return; + c.style.strokeDashoffset = RING * (1 - Math.max(0, Math.min(100, pct || 0)) / 100); + if (cls != null) c.setAttribute('class', 'fg ' + cls); + this.set('td-ring-' + key + '-v', text); + if (label) this.set('td-ring-' + key + '-l', label); + }; + + const info = this.s.info || {}, m = info.memory || {}; + if (this.s.cpuPct != null) setRing('cpu', this.s.cpuPct, Math.round(this.s.cpuPct) + '%'); + else if (info.load) setRing('cpu', Math.min(100, info.load[0] / 65536 * 50), (info.load[0] / 65536).toFixed(2)); + + if (m.total) { + const used = (m.total - (m.available || (m.free + (m.buffered || 0) + (m.cached || 0)))) / m.total * 100; + setRing('mem', used, Math.round(used) + '%'); + } + + if (this.s.temp != null) { + const t = this.s.temp; + setRing('temp', t, Math.round(t) + '°C', + T('温度', 'Temp') + ' · ' + (t < 70 ? T('正常', 'normal') : t < 85 ? T('偏高', 'warm') : T('过热', 'hot')), + t < 85 ? 'warm' : 'hot'); + } + else setRing('temp', 0, '—'); + }, + + tick() { + return Promise.all([ td.readFile('/proc/net/dev'), td.readFile('/proc/stat'), td.temperature() ]).then(([ dev, stat, temp ]) => { + const c = td.cpuStat(stat); + if (c && this.s.cpu && c.total > this.s.cpu.total) + this.s.cpuPct = 100 * (1 - (c.idle - this.s.cpu.idle) / (c.total - this.s.cpu.total)); + if (c) this.s.cpu = c; + if (temp != null) this.s.temp = temp; + else if (this.s.temp == null) this.$('td-ring-temp')?.closest('.td-ring').remove(); // no usable sensor (VMs, some x86) + this.sample(dev); + }); + }, + + slow() { + return Promise.all([ td.sysinfo(), td.wan(), this.s.hasWifi ? td.wifiInfo() : this.s.wifi ]).then(([ info, wan, wifi ]) => { + this.s.info = info; + this.s.wan = wan; + this.greet(); // the hour, the date and the online state all change over a long session; it only writes what differs + this.paintWifi(wifi); + this.paintStatic(); + this.paintDisk(); + return Promise.all([ this.refreshClients(), ++this.ticks % 3 ? null : Promise.all([ this.refreshPorts(), this.paintApps(), this.pingGateway() ]) ]); + }); + }, + + pingGateway() { + const w = this.s.wan; + if (!w || !w.gw || !/^[0-9.]+$/.test(w.gw)) { + this.set('td-lat', w && w.up ? T('已连接', 'Connected') : T('未连接', 'Offline')); + return Promise.resolve(); + } + /* a gateway that never answers ping (many ISP modems) is retried every few minutes, not every 30 s */ + if (this.s.miss >= 3 && this.ticks % 18) return Promise.resolve(); + return L.resolveDefault(fs.exec('/bin/ping', [ '-c', '1', '-W', '1', w.gw ]), {}).then(r => { + const m = String((r || {}).stdout || '').match(/time[=<]([\d.]+)/); + const v = m ? (+m[1] < 1 ? '<1' : Math.round(+m[1])) + ' ms' : null; + this.s.miss = v ? 0 : (this.s.miss || 0) + 1; + this.set('td-ping', v || T('无响应', 'No reply')); + this.set('td-lat', v ? T('延迟 ', 'Latency ') + v : T('已连接', 'Connected')); + }); + }, + + /* ---------- apps card: one procd query plus a config read per shown app, every 30 s; the DOM is touched only when something changed. + * Like the devices card it shows a few rows (apps with something to say first) and counts the rest. ---------- */ + + paintApps() { + const box = this.$('td-apps'); + if (!box) return Promise.resolve(); + return td.services().then(all => { + const fn = s => APPS[s.name.toLowerCase()]; + const live = this.s.apps.map(p => [ p, td.service(all, p.path) ]).filter(([ , s ]) => s && s.running) + .sort(([ , a ], [ , b ]) => !fn(a) - !fn(b)); + const top = live.slice(0, live.length > 5 ? 4 : 5), more = live.length - top.length; + return Promise.all(top.map(([ p, s ]) => (fn(s) || (() => Promise.resolve({})))().then(x => [ p, x ]))).then(rows => { + const html = rows.length ? rows.map(([ p, x ]) => `
${icon(td.appIcon(p.path))}
+
${esc(p.title)}${esc([ T('运行中', 'Running'), x.sub ].filter(Boolean).join(' · '))}
+ ${x.seg ? `
` + : x.link ? `${esc(T('控制台', 'Console'))}${icon('arrow')}` + : x.rt ? `
${esc(x.rt)}
` : ''}
`).join('') + + (more ? `${esc(T(`还有 ${more} 个在运行`, `${more} more running`))}` : '') + : `
${esc(T('没有正在运行的应用', 'No apps running'))}
`; + if (box._html === html) return; + this.put(box, html); + box.querySelectorAll('[data-oc]').forEach(seg => this.ocSwitch(seg)); + }); + }); + }, + + /* OpenClash's proxy mode, switched through its own switch_rule_mode call */ + ocSwitch(seg) { + let mode = seg.dataset.oc; + const mark = m => seg.querySelectorAll('button').forEach(b => b.classList.toggle('on', b.dataset.m == m)); + mark(mode); + seg.addEventListener('click', ev => { + const b = ev.target.closest('button[data-m]'); + if (!b || b.dataset.m == mode) return; + const prev = mode; + mark(mode = b.dataset.m); + request.get(L.url('admin/services/openclash/switch_rule_mode'), { query: { rule_mode: mode } }) + .then(r => { if (!r.ok) throw r; td.toast(T('已切换代理模式', 'Proxy mode switched')); }) + .catch(() => { mark(mode = prev); td.toast(T('切换失败', 'Switch failed')); }); + }); + }, + + handleSave: null, + handleSaveApply: null, + handleReset: null +}); diff --git a/luci-theme-7dollar/install.sh b/luci-theme-7dollar/install.sh new file mode 100755 index 000000000..9d8358e06 --- /dev/null +++ b/luci-theme-7dollar/install.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# 7Dollar — one line installer for OpenWrt +# wget -qO- https://raw.githubusercontent.com/itsmaiGe/luci-theme-7dollar/main/install.sh | sh +# Behind the GFW? put a GitHub proxy in front: +# wget -qO- https:///https://raw.githubusercontent.com/itsmaiGe/luci-theme-7dollar/main/install.sh | PROXY=https:/// sh + +REPO=itsmaiGe/luci-theme-7dollar +BRANCH=${BRANCH:-main} +P=${PROXY:-} +RAW="${P}https://raw.githubusercontent.com/$REPO/$BRANCH" +TMP=/tmp/7dollar.$$ + +say() { printf '\033[38;5;173m●\033[0m %s\n' "$*"; } +die() { printf '\033[31m✕ %s\033[0m\n' "$*"; rm -rf "$TMP"; exit 1; } +get() { + if command -v uclient-fetch >/dev/null 2>&1; then uclient-fetch -q -O "$2" "$1" + elif command -v wget >/dev/null 2>&1; then wget -q -O "$2" "$1" + elif command -v curl >/dev/null 2>&1; then curl -fsSL -o "$2" "$1" + else return 1; fi +} + +[ -f /etc/openwrt_release ] || die "这不是 OpenWrt 系统 / not OpenWrt" +[ -d /usr/share/ucode/luci ] || die "需要 OpenWrt 23.05 或更新版本 (ucode LuCI) / needs OpenWrt 23.05+" +mkdir -p "$TMP" || die "无法创建临时目录" + +say "7Dollar 主题安装中…" + +# opkg (OpenWrt 23.05 / 24.10) installs the .ipk, apk (OpenWrt 25.x) the .apk +if command -v opkg >/dev/null 2>&1; then LIST=latest.txt; else LIST=latest-apk.txt; fi +get "$RAW/dist/$LIST" "$TMP/latest" || die "下载失败,请检查网络,或设置 PROXY 后重试" +PKG=$(head -n1 "$TMP/latest" | tr -d '\r\n ') +VER=$(echo "$PKG" | sed -n 's/^luci-theme-7dollar[_-]\([0-9.]*\)-r.*/\1/p') +say "下载 $PKG" +# from the Release (counted as a download on GitHub); the copy in the repo is the fallback +get "${P}https://github.com/$REPO/releases/download/v$VER/$PKG" "$TMP/$PKG" || get "$RAW/dist/$PKG" "$TMP/$PKG" \ + || die "下载失败,请检查网络,或设置 PROXY 后重试" +if [ "$LIST" = latest.txt ]; then + opkg install --force-reinstall "$TMP/$PKG" || die "opkg 安装失败" +else + apk add --allow-untrusted "$TMP/$PKG" || die "apk 安装失败" +fi + +rm -rf "$TMP" /tmp/luci-indexcache* /tmp/luci-modulecache/ +say "完成!刷新浏览器里的路由器后台即可看到 7Dollar。" +say "Done! Reload the router page in your browser." diff --git a/luci-theme-7dollar/root/etc/config/7dollar b/luci-theme-7dollar/root/etc/config/7dollar new file mode 100644 index 000000000..82279245b --- /dev/null +++ b/luci-theme-7dollar/root/etc/config/7dollar @@ -0,0 +1,4 @@ + +config theme 'main' + option palette 'clay' + option mode 'auto' diff --git a/luci-theme-7dollar/root/etc/uci-defaults/30_luci-theme-7dollar b/luci-theme-7dollar/root/etc/uci-defaults/30_luci-theme-7dollar new file mode 100755 index 000000000..37868a730 --- /dev/null +++ b/luci-theme-7dollar/root/etc/uci-defaults/30_luci-theme-7dollar @@ -0,0 +1,19 @@ +#!/bin/sh +# Register the 7Dollar theme and make it the active one, except on an upgrade where it was +# already registered: then the user's own theme choice stays. (Packages up to 1.0.7 unregister +# the theme when upgraded, so upgrading from those still lands on 7Dollar, as before.) + +[ -f /etc/config/7dollar ] || printf "\nconfig theme 'main'\n\toption palette 'clay'\n\toption mode 'auto'\n" > /etc/config/7dollar + +uci -q delete 7dollar.main.plugin_openclash && uci commit 7dollar # replaced by the apps card in 1.0.12 + +[ "$PKG_UPGRADE" = 1 ] && uci -q get luci.themes.7Dollar >/dev/null || uci -q set luci.main.mediaurlbase=/luci-static/7dollar +uci -q set luci.themes.7Dollar=/luci-static/7dollar +uci -q commit luci + +# LuCI caches the menu, and our pages depend on the active theme: when the theme is switched +# (Language and Style), touching our menu file makes LuCI rebuild it. 24.10+ (ucitrack). +[ -x /etc/init.d/ucitrack ] && /etc/init.d/ucitrack reload >/dev/null 2>&1 + +rm -rf /tmp/luci-indexcache* /tmp/luci-modulecache/ 2>/dev/null +exit 0 diff --git a/luci-theme-7dollar/root/usr/share/luci/menu.d/luci-theme-7dollar.json b/luci-theme-7dollar/root/usr/share/luci/menu.d/luci-theme-7dollar.json new file mode 100644 index 000000000..19b93e43b --- /dev/null +++ b/luci-theme-7dollar/root/usr/share/luci/menu.d/luci-theme-7dollar.json @@ -0,0 +1,67 @@ +{ + "admin/home": { + "title": "Home", + "order": 0, + "action": { + "type": "view", + "path": "7dollar/home" + }, + "depends": { + "acl": [ "luci-theme-7dollar" ], + "uci": { + "luci": { + "main": { + "mediaurlbase": "/luci-static/7dollar" + } + } + } + } + }, + "admin/clients": { + "title": "Devices", + "order": 2, + "action": { + "type": "view", + "path": "7dollar/devices" + }, + "depends": { + "acl": [ "luci-theme-7dollar" ], + "uci": { + "luci": { + "main": { + "mediaurlbase": "/luci-static/7dollar" + } + } + } + } + }, + "admin/apps": { + "title": "Apps", + "order": 39, + "action": { + "type": "view", + "path": "7dollar/apps" + }, + "depends": { + "acl": [ "luci-theme-7dollar" ], + "uci": { + "luci": { + "main": { + "mediaurlbase": "/luci-static/7dollar" + } + } + } + } + }, + "admin/system/appearance": { + "title": "Appearance", + "order": 3, + "action": { + "type": "view", + "path": "7dollar/appearance" + }, + "depends": { + "acl": [ "luci-theme-7dollar" ] + } + } +} diff --git a/luci-theme-7dollar/root/usr/share/rpcd/acl.d/luci-theme-7dollar.json b/luci-theme-7dollar/root/usr/share/rpcd/acl.d/luci-theme-7dollar.json new file mode 100644 index 000000000..1bcead918 --- /dev/null +++ b/luci-theme-7dollar/root/usr/share/rpcd/acl.d/luci-theme-7dollar.json @@ -0,0 +1,49 @@ +{ + "luci-theme-7dollar": { + "description": "Grant access to the 7Dollar theme dashboard and appearance settings", + "read": { + "ubus": { + "luci-rpc": [ "getDHCPLeases", "getHostHints", "getWirelessDevices", "getNetworkDevices" ], + "iwinfo": [ "assoclist" ], + "network.interface": [ "dump" ], + "system": [ "board", "info" ], + "service": [ "list" ], + "luci": [ "getMountPoints" ], + "uci": [ "get" ], + "file": [ "read", "exec" ] + }, + "file": { + "/proc/stat": [ "read" ], + "/proc/net/dev": [ "read" ], + "/proc/net/route": [ "read" ], + "/proc/net/arp": [ "read" ], + "/sys/class/net/*/brforward": [ "read" ], + "/sys/devices/virtual/net/*/brforward": [ "read" ], + "/proc/sys/net/ipv4/tcp_congestion_control": [ "read" ], + "/sys/class/thermal": [ "list" ], + "/sys/class/hwmon": [ "list" ], + "/sys/class/thermal/thermal_zone*/type": [ "read" ], + "/sys/class/thermal/thermal_zone*/temp": [ "read" ], + "/sys/class/hwmon/hwmon*/name": [ "read" ], + "/sys/class/hwmon/hwmon*/temp1_input": [ "read" ], + "/proc/[0-9]*/net/dev": [ "read" ], + "/proc/[0-9]*/net/route": [ "read" ], + "/proc/[0-9]*/net/arp": [ "read" ], + "/sys/devices/*/thermal_zone*/type": [ "read" ], + "/sys/devices/*/thermal_zone*/temp": [ "read" ], + "/sys/devices/*/hwmon*/name": [ "read" ], + "/sys/devices/*/hwmon*/temp1_input": [ "read" ], + "/sys/module/mt79*e/parameters/wed_enable": [ "read" ], + "/opt/open-box/data/panel-port": [ "read" ], + "/bin/ping": [ "exec" ] + }, + "uci": [ "7dollar", "firewall", "luci", "AdGuardHome", "bandix", "homeproxy", "ksmbd", "mosdns", "nikki", "openclash", "passwall", "passwall2", "samba4", "shadowsocksr", "smartdns" ] + }, + "write": { + "ubus": { + "uci": [ "add", "set", "delete", "commit" ] + }, + "uci": [ "7dollar", "luci" ] + } + } +} diff --git a/luci-theme-7dollar/root/usr/share/ucitrack/luci-theme-7dollar.json b/luci-theme-7dollar/root/usr/share/ucitrack/luci-theme-7dollar.json new file mode 100644 index 000000000..10abbbf8a --- /dev/null +++ b/luci-theme-7dollar/root/usr/share/ucitrack/luci-theme-7dollar.json @@ -0,0 +1,4 @@ +{ + "config": "luci", + "exec": "/bin/touch -c /usr/share/luci/menu.d/luci-theme-7dollar.json" +} diff --git a/luci-theme-7dollar/scripts/build-apk.sh b/luci-theme-7dollar/scripts/build-apk.sh new file mode 100644 index 000000000..33fb134ac --- /dev/null +++ b/luci-theme-7dollar/scripts/build-apk.sh @@ -0,0 +1,82 @@ +#!/bin/sh +# Build dist/luci-theme-7dollar-.apk for OpenWrt 25.x (apk-tools v3). +# Needs apk-tools v3 (`apk mkpkg`, set APK=/path/to/apk.static) and runs as root so files are +# owned by root; .github/workflows/apk.yml does both. +set -eu +cd "$(dirname "$0")/.." + +NAME=luci-theme-7dollar +VER=$(sed -n 's/^PKG_VERSION:=//p' Makefile)-r$(sed -n 's/^PKG_RELEASE:=//p' Makefile) +OUT=dist/$NAME-$VER.apk +# Stamp files with the last commit that touched the package, so rebuilding unchanged sources +# gives a byte-identical apk (CI then has nothing to commit). +export SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH:-$(git -c safe.directory="$PWD" log -1 --format=%ct -- Makefile htdocs ucode root scripts/stage.sh scripts/build-apk.sh)} +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +D=$TMP/data S=$TMP/scripts +sh scripts/stage.sh "$D" +mkdir -p "$D/lib/apk/packages" "$S" +( cd "$D" && find . \( -type f -o -type l \) | sed 's/^\.//' | sort ) > "$TMP/list" +mv "$TMP/list" "$D/lib/apk/packages/$NAME.list" +echo /etc/config/7dollar > "$D/lib/apk/packages/$NAME.conffiles" +echo "/etc/config/7dollar $(sha256sum "$D/etc/config/7dollar" | cut -d' ' -f1)" > "$D/lib/apk/packages/$NAME.conffiles_static" + +# Same maintainer scripts OpenWrt's package-pack.mk generates for apk packages. +cat > "$S/post-install" </dev/null 2>&1 +} +exit 0 +SH +{ echo '#!/bin/sh'; echo 'export PKG_UPGRADE=1'; sed 1d "$S/post-install"; } > "$S/post-upgrade" +cat > "$S/pre-deinstall" < "$S/post-deinstall" <<'SH' +#!/bin/sh +[ -n "${IPKG_INSTROOT}" ] || { + uci -q delete luci.themes.7Dollar + [ "$(uci -q get luci.main.mediaurlbase)" = "/luci-static/7dollar" ] && \ + uci -q set luci.main.mediaurlbase=/luci-static/bootstrap + uci commit luci + rm -rf /tmp/luci-indexcache* /tmp/luci-modulecache/ +} +exit 0 +SH +chmod 755 "$S"/* +find "$D" "$S" -exec touch -h -d "@$SOURCE_DATE_EPOCH" {} + + +mkdir -p dist +rm -f dist/$NAME-*.apk +${APK:-apk} mkpkg \ + --info "name:$NAME" \ + --info "version:$VER" \ + --info "description:7Dollar, an elegant, lightweight LuCI theme" \ + --info "arch:noarch" \ + --info "license:Apache-2.0" \ + --info "origin:$NAME" \ + --info "url:https://github.com/itsmaiGe/luci-theme-7dollar" \ + --info "maintainer:itsmaiGe" \ + --info "depends:libc luci-base" \ + --script "post-install:$S/post-install" \ + --script "post-upgrade:$S/post-upgrade" \ + --script "pre-deinstall:$S/pre-deinstall" \ + --script "post-deinstall:$S/post-deinstall" \ + --files "$D" \ + --output "$OUT" +basename "$OUT" > dist/latest-apk.txt +echo "$OUT ($(wc -c < "$OUT" | tr -d ' ') bytes)" diff --git a/luci-theme-7dollar/scripts/build-ipk.sh b/luci-theme-7dollar/scripts/build-ipk.sh new file mode 100755 index 000000000..51ea8875f --- /dev/null +++ b/luci-theme-7dollar/scripts/build-ipk.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Build dist/luci-theme-7dollar__all.ipk without an OpenWrt SDK. +# The result is a standard opkg package (tar.gz flavour, like ipkg-build). +set -euo pipefail +cd "$(dirname "$0")/.." + +VER=$(sed -n 's/^PKG_VERSION:=//p' Makefile)-r$(sed -n 's/^PKG_RELEASE:=//p' Makefile) +NAME=luci-theme-7dollar +OUT=dist/${NAME}_${VER}_all.ipk +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +export COPYFILE_DISABLE=1 # no macOS ._ files + +TAR=tar; command -v gtar >/dev/null && TAR=gtar +own() { if $TAR --version | grep -q GNU; then echo "--format=gnu --owner=0 --group=0 --numeric-owner"; else echo "--format ustar --no-xattrs --no-mac-metadata --uid 0 --gid 0 --uname root --gname root"; fi; } + +# ---- data ---- +D=$TMP/data +sh scripts/check-jsmin.sh +sh scripts/stage.sh "$D" +SIZE=$(du -sk "$D" | cut -f1) + +# ---- control ---- +C=$TMP/control +mkdir -p "$C" +cat > "$C/control" < "$C/conffiles" +cat > "$C/postinst" <<'SH' +#!/bin/sh +[ "${IPKG_NO_SCRIPT}" = "1" ] && exit 0 +[ -s ${IPKG_INSTROOT}/lib/functions.sh ] || exit 0 +. ${IPKG_INSTROOT}/lib/functions.sh +default_postinst $0 $@ +[ -n "${IPKG_INSTROOT}" ] || { + rm -rf /tmp/luci-indexcache* /tmp/luci-modulecache/ + /etc/init.d/rpcd reload >/dev/null 2>&1 +} +exit 0 +SH +cat > "$C/prerm" <<'SH' +#!/bin/sh +[ -s ${IPKG_INSTROOT}/lib/functions.sh ] || exit 0 +. ${IPKG_INSTROOT}/lib/functions.sh +default_prerm $0 $@ +SH +cat > "$C/postrm" <<'SH' +#!/bin/sh +[ "$1" = upgrade ] && exit 0 # opkg runs this on upgrades too; only a real removal unregisters +[ -n "${IPKG_INSTROOT}" ] || { + uci -q delete luci.themes.7Dollar + [ "$(uci -q get luci.main.mediaurlbase)" = "/luci-static/7dollar" ] && \ + uci -q set luci.main.mediaurlbase=/luci-static/bootstrap + uci commit luci + rm -rf /tmp/luci-indexcache* /tmp/luci-modulecache/ +} +exit 0 +SH +chmod 644 "$C/control" "$C/conffiles"; chmod 755 "$C/postinst" "$C/prerm" "$C/postrm" +echo "2.0" > "$TMP/debian-binary" + +( cd "$D" && $TAR $(own) -czf "$TMP/data.tar.gz" . ) +( cd "$C" && $TAR $(own) -czf "$TMP/control.tar.gz" . ) +mkdir -p dist +( cd "$TMP" && $TAR $(own) -czf pkg.tar.gz ./debian-binary ./data.tar.gz ./control.tar.gz ) +mv "$TMP/pkg.tar.gz" "$OUT" +basename "$OUT" > dist/latest.txt +echo "$OUT ($(wc -c < "$OUT" | tr -d ' ') bytes)" diff --git a/luci-theme-7dollar/scripts/check-jsmin.sh b/luci-theme-7dollar/scripts/check-jsmin.sh new file mode 100755 index 000000000..e7ec15554 --- /dev/null +++ b/luci-theme-7dollar/scripts/check-jsmin.sh @@ -0,0 +1,18 @@ +#!/bin/sh +# Source builds (feeds / SDK) minify our JS with LuCI's jsmin, which is stricter than a browser: +# for one, it misreads a regex literal that follows `return`. Run every script through the same +# jsmin and make sure the result still parses. Needs cc, curl and node; skipped without them. +set -eu +cd "$(dirname "$0")/.." +T=${TMPDIR:-/tmp}/7dollar-jsmin +mkdir -p "$T" +if [ ! -x "$T/jsmin" ]; then + curl -sfL -o "$T/jsmin.c" https://raw.githubusercontent.com/openwrt/luci/master/modules/luci-base/src/jsmin.c \ + && cc -o "$T/jsmin" "$T/jsmin.c" 2>/dev/null || { echo "check-jsmin: skipped (no cc or network)"; exit 0; } +fi +bad=0 +for f in $(find htdocs -name '*.js'); do + "$T/jsmin" < "$f" > "$T/out.js" 2>/dev/null || continue # jsmin gave up: the buildroot then ships the file as it is + node -e "new Function(require('fs').readFileSync('$T/out.js','utf8'))" 2>/dev/null || { echo "check-jsmin: $f breaks after jsmin"; bad=1; } +done +exit $bad diff --git a/luci-theme-7dollar/scripts/stage.sh b/luci-theme-7dollar/scripts/stage.sh new file mode 100644 index 000000000..1f482f722 --- /dev/null +++ b/luci-theme-7dollar/scripts/stage.sh @@ -0,0 +1,17 @@ +#!/bin/sh +# Lay out the installed file tree of luci-theme-7dollar in $1 (shared by build-ipk.sh and build-apk.sh). +# JS and CSS are minified on the way (what the OpenWrt buildroot does for LuCI itself); the sources in +# the repo stay readable. MINIFY=0 skips it. +set -eu +cd "$(dirname "$0")/.." +D=$1 +mkdir -p "$D/www" "$D/usr/share/ucode/luci" +cp -R htdocs/. "$D/www/" +cp -R ucode/. "$D/usr/share/ucode/luci/" +cp -R root/. "$D/" +find "$D" -name .DS_Store -exec rm {} + +[ "${MINIFY:-1}" = 0 ] || npx -y esbuild@0.28.2 $(find "$D/www" -name '*.js' -o -name '*.css') \ + --minify --charset=utf8 --target=esnext --log-level=warning --allow-overwrite --outbase="$D/www" --outdir="$D/www" +find "$D" -type d -exec chmod 755 {} + +find "$D" -type f -exec chmod 644 {} + +chmod 755 "$D"/etc/uci-defaults/* diff --git a/luci-theme-7dollar/ucode/7dollar.uc b/luci-theme-7dollar/ucode/7dollar.uc new file mode 100644 index 000000000..3e11d85a0 --- /dev/null +++ b/luci-theme-7dollar/ucode/7dollar.uc @@ -0,0 +1,11 @@ +// 7Dollar: helpers shared by the theme templates (sidebar and login page). + +/* What to call this box. x86 boards often keep a BIOS placeholder ("Default string", + * "To be filled by O.E.M.") as their model: name them by CPU instead, e.g. "Intel Celeron N5105". */ +export function model(board) { + const m = board?.model; + if (m && !match(m, /default string|to be filled|o\.e\.m|system product name|not (specified|applicable)/i)) + return m; + return trim(replace(replace(board?.system ?? '', /\((R|TM)\)|\bCPU\b|@.*$/g, ''), /\s+/g, ' ')) + || board?.release?.distribution || 'OpenWrt'; +}; diff --git a/luci-theme-7dollar/ucode/template/themes/7dollar/footer.ut b/luci-theme-7dollar/ucode/template/themes/7dollar/footer.ut new file mode 100644 index 000000000..5138a0116 --- /dev/null +++ b/luci-theme-7dollar/ucode/template/themes/7dollar/footer.ut @@ -0,0 +1,17 @@ +{# + 7Dollar — an elegant LuCI theme + Licensed to the public under the Apache License 2.0. +-#} + {% if (!blank_page): %} + + + + + + + {% endif %} + + diff --git a/luci-theme-7dollar/ucode/template/themes/7dollar/header.ut b/luci-theme-7dollar/ucode/template/themes/7dollar/header.ut new file mode 100644 index 000000000..d32cc05c0 --- /dev/null +++ b/luci-theme-7dollar/ucode/template/themes/7dollar/header.ut @@ -0,0 +1,96 @@ +{# + 7Dollar — an elegant LuCI theme + Licensed to the public under the Apache License 2.0. +-#} + +{% + import { getuid, getspnam } from 'luci.core'; + import { model as boardModel } from 'luci.7dollar'; + + const boardinfo = ubus.call('system', 'board') ?? {}; + const conf = uci.get_all('7dollar', 'main') ?? {}; + const palette = (conf.palette in [ 'clay', 'celadon', 'mist', 'dusk', 'graphite' ]) ? conf.palette : 'clay'; + const mode = (conf.mode in [ 'light', 'dark', 'auto' ]) ? conf.mode : 'auto'; + const zh = !!match(dispatcher.lang ?? '', /^zh/); + const T = (z, e) => zh ? z : e; + const ver = '1.0.16'; + const hostname = striptags(boardinfo.hostname ?? 'OpenWrt'); + const model = striptags(boardModel(boardinfo)); + const sprite = `${media}/icons.svg`; + + http.prepare_content('text/html; charset=UTF-8'); +-%} + + + + + + + {{ striptags(`${hostname}${node ? ` · ${node.title}` : ''}`) }} + + + + + {% if (node?.css): %} + + {% endif %} + {% if (css): %} + + {% endif %} + + + + + + {% if (!blank_page): %} +
+ + +
+
+
+ + {% if (getuid() == 0 && getspnam('root')?.pwdp === ''): %} +
+

{{ _('No password set!') }}

+

{{ _('There is no password set on this router. Please configure a root password to protect the web interface.') }}

+ {% if (dispatcher.lookup("admin/system/admin")): %} + + {% endif %} +
+ {% endif %} + + {% if (boardinfo.rootfs_type == "initramfs"): %} +
+

{{ _('System running in recovery (initramfs) mode.') }}

+

{{ _('No changes to settings will be stored and are lost after rebooting. This mode should only be used to install a firmware upgrade') }}

+ {% if (dispatcher.lookup("admin/system/flash")): %} + + {% endif %} +
+ {% endif %} + + + + + {% endif %} diff --git a/luci-theme-7dollar/ucode/template/themes/7dollar/sysauth.ut b/luci-theme-7dollar/ucode/template/themes/7dollar/sysauth.ut new file mode 100644 index 000000000..7916ea55c --- /dev/null +++ b/luci-theme-7dollar/ucode/template/themes/7dollar/sysauth.ut @@ -0,0 +1,79 @@ +{# + 7Dollar — login page + Licensed to the public under the Apache License 2.0. +-#} + +{% include('header', { blank_page: true }) %} + +{% + import { model } from 'luci.7dollar'; + + const board = ubus.call('system', 'board') ?? {}; + const zh = !!match(dispatcher.lang ?? '', /^zh/); + const T = (z, e) => zh ? z : e; + const sprite = `${media}/icons.svg`; + const user = duser ?? 'root'; + const custom = fuser && fuser != user; +-%} + + + + + +{% include('footer', { blank_page: true }) %} diff --git a/luci-theme-7dollar/uninstall.sh b/luci-theme-7dollar/uninstall.sh new file mode 100755 index 000000000..62c295f84 --- /dev/null +++ b/luci-theme-7dollar/uninstall.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# Remove 7Dollar and switch LuCI back to Bootstrap. +# wget -qO- https://raw.githubusercontent.com/itsmaiGe/luci-theme-7dollar/main/uninstall.sh | sh + +if command -v opkg >/dev/null 2>&1 && opkg status luci-theme-7dollar | grep -q '^Status: .* installed'; then + opkg remove luci-theme-7dollar +elif command -v apk >/dev/null 2>&1 && apk info -e luci-theme-7dollar >/dev/null 2>&1; then + apk del luci-theme-7dollar +else + # copied in by the 1.0.x installer on OpenWrt 25.x + rm -rf /www/luci-static/7dollar /www/luci-static/resources/7dollar /www/luci-static/resources/view/7dollar \ + /usr/share/ucode/luci/template/themes/7dollar + rm -f /www/luci-static/resources/menu-7dollar.js /usr/share/luci/menu.d/luci-theme-7dollar.json \ + /usr/share/rpcd/acl.d/luci-theme-7dollar.json /etc/uci-defaults/30_luci-theme-7dollar + uci -q delete luci.themes.7Dollar + [ "$(uci -q get luci.main.mediaurlbase)" = "/luci-static/7dollar" ] && uci set luci.main.mediaurlbase=/luci-static/bootstrap + uci commit luci + /etc/init.d/rpcd reload >/dev/null 2>&1 +fi + +rm -rf /tmp/luci-indexcache* /tmp/luci-modulecache/ +echo "7Dollar 已卸载,界面已切回 Bootstrap。设置文件 /etc/config/7dollar 保留,可手动删除。"