diff --git a/clashoo/Makefile b/clashoo/Makefile index 87fe0f31..f356910b 100644 --- a/clashoo/Makefile +++ b/clashoo/Makefile @@ -1,8 +1,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=clashoo -PKG_SHORT_SHA:=88dcbf7 -PKG_COMMIT_DATE:=2026.09.30 +PKG_SHORT_SHA:=9f053c4 +PKG_COMMIT_DATE:=2026.10.02 PKG_VERSION:=$(PKG_COMMIT_DATE)~$(PKG_SHORT_SHA) PKG_RELEASE:=1 @@ -11,8 +11,8 @@ PKG_SOURCE:=$(PKG_NAME)-alpha-$(PKG_SHORT_SHA).tar.gz PKG_SOURCE_SUBDIR:=$(PKG_NAME)-alpha-$(PKG_SHORT_SHA) PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)-alpha-$(PKG_SHORT_SHA) PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-clashoo/releases/download/mihomo-src -PKG_SOURCE_VERSION:=88dcbf7f1614a67c3b36b848ee3592dfa92ada36 -PKG_HASH:=7976eab9de5b7d651a75726b16b21173ac533d11d09ccac927ab226b519d562b +PKG_SOURCE_VERSION:=9f053c49075de076d83d3e5918241e410f7e9adf +PKG_HASH:=b2d1f5d9ecfc2c6818578e2be21d31d5e25976b6f8b96aff1a9e3cb5183e503a PKG_BUILD_VERSION:=alpha-$(PKG_SHORT_SHA) PKG_LICENSE:=GPL3.0+ diff --git a/docker/Makefile b/docker/Makefile index 8402e9b6..ae1318ff 100644 --- a/docker/Makefile +++ b/docker/Makefile @@ -1,12 +1,12 @@ include $(TOPDIR)/rules.mk PKG_NAME:=docker -PKG_VERSION:=29.8.2 +PKG_VERSION:=29.9.0-rc.1 PKG_RELEASE:=1 PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE_URL:=https://codeload.github.com/docker/cli/tar.gz/v$(PKG_VERSION)? -PKG_HASH:=12990af6e98ecc545914820c2c835bdb3303c71439ee3ad0f27ab5341f97f37c +PKG_HASH:=5b2eda9020415e28922d273cc9e76409f0034bf6efdff2cec2637aa8fc476aea PKG_BUILD_DIR:=$(BUILD_DIR)/cli-$(PKG_VERSION) PKG_GIT_SHORT_COMMIT:=$(shell $(CURDIR)/git-short-commit.sh 'github.com/docker/cli' 'v$(PKG_VERSION)' '$(TMP_DIR)/git-short-commit/$(PKG_NAME)-$(PKG_VERSION)') diff --git a/luci-app-chatgpt-web/.github/workflows/main.yml b/luci-app-chatgpt-web/.github/workflows/main.yml new file mode 100644 index 00000000..3de4bc6d --- /dev/null +++ b/luci-app-chatgpt-web/.github/workflows/main.yml @@ -0,0 +1,49 @@ +name: Build + +on: + push: + tags: + - v* + +jobs: + build: + name: Build ${{ matrix.arch }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + arch: + + - aarch64_generic + - i386_pentium-mmx + - loongarch64_generic + - mips_mips32 + - mipsel_24kc + - mipsel_mips32 + - x86_64 + + steps: + - uses: actions/checkout@main + with: + fetch-depth: 0 + - name: Building packages + uses: sbwml/openwrt-gh-action-sdk@go1.24 + env: + ARCH: ${{ matrix.arch }}-openwrt-24.10 + FEEDNAME: packages_ci + PACKAGES: luci-app-chatgpt-web + NO_REFRESH_CHECK: true + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.arch }} + path: bin/packages/${{ matrix.arch }}/packages_ci/*.ipk + + - name: Upload packages + uses: ncipollo/release-action@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + allowUpdates: true + replacesArtifacts: true + artifacts: "bin/packages/${{ matrix.arch }}/packages_ci/*.ipk" diff --git a/luci-app-chatgpt-web/Makefile b/luci-app-chatgpt-web/Makefile new file mode 100644 index 00000000..b5cae97b --- /dev/null +++ b/luci-app-chatgpt-web/Makefile @@ -0,0 +1,22 @@ +# SPDX-License-Identifier: GPL-3.0-only +# +# Copyright (C) 2023-2025 sirpdboy https://github.com/sirpdboy/luci-app-chatgpt-web +# +# This is free software, licensed under the Apache License, Version 2.0 . +# +include $(TOPDIR)/rules.mk + +NAME:=chatgpt-web +PKG_NAME:=luci-app-$(NAME) +PKG_VERSION:=1.2.1 +PKG_RELEASE:=16 + +LUCI_TITLE:=LuCI Support for chatgpt-web Client +LUCI_PKGARCH:=all + +define Package/$(PKG_NAME)/conffiles +/etc/config/chatgpt-web +endef +include $(TOPDIR)/feeds/luci/luci.mk + +# call BuildPackage - OpenWrt buildroot signature diff --git a/luci-app-chatgpt-web/README.md b/luci-app-chatgpt-web/README.md new file mode 100644 index 00000000..ab5de559 --- /dev/null +++ b/luci-app-chatgpt-web/README.md @@ -0,0 +1,179 @@ +## 访问数:![hello](https://views.whatilearened.today/views/github/sirpdboy/deplives.svg)[![](https://img.shields.io/badge/TG群-点击加入-FFFFFF.svg)](https://t.me/joinchat/AAAAAEpRF88NfOK5vBXGBQ) + + + +

+
luci-app-chatgpt-web
+

+ +

+OpenWrt +Chrome +Safari +Firefox + GitHub release +Downloads +

+ + +![screenshots](https://raw.githubusercontent.com/sirpdboy/openwrt/master/doc/说明1.jpg) + +请 **认真阅读完毕** 本页面,本页面包含注意事项和如何使用。 + +## 功能说明 + +luci-app-chatgpt Pure ChatGPT by OPENWRT based on OpenAI API + +luci-app-chatgpt 在OPENWRT上实现ChatGPT项目,基于OpenAI API + + + +- [luci-app-chatgpt](#功能说明) + - [特性](#特性) + - [编译方法](#编译方法) + - [使用方法](#使用方法) + - [自定义选项](#自定义选项)。 + - [说明](#说明) + - [界面](#界面) + - [捐助](#捐助) + + + +## 特性 + +- 无须第三依赖,直接使用官方API接口。 +- 支持复制/更新/刷新会话,语音输入,朗读等功能,以及众多[自定义选项](#自定义选项)。 +- 支持搜索会话,导入导出会话和设置,自定义头像,自定义系统角色等。 + +参考项目: +[markdown-it](https://github.com/markdown-it/markdown-it), +[highlight.js](https://github.com/highlightjs/highlight.js), +[github-markdown-css](https://github.com/sindresorhus/github-markdown-css), +[chatgpt-html](https://github.com/slippersheepig/chatgpt-html), +[markdown-it-copy](https://github.com/ReAlign/markdown-it-copy), +[markdown-it-texmath](https://github.com/goessner/markdown-it-texmath), +[awesome-chatgpt-prompts-zh](https://github.com/PlexPt/awesome-chatgpt-prompts-zh) + + +## 编译方法 + +- 将luci-app-chatgpt添加至 LEDE/OpenWRT 源码的方法。 + +### 下载源码方法: + + ```Brach + + # 下载源码 + + git clone https://github.com/sirpdboy/chatgpt-web.git package/luci-app-chatgpt + make menuconfig + + ``` +### 配置菜单 + + ```Brach + make menuconfig + # 找到 LuCI -> Applications, 选择 luci-app-chatgpt, 保存后退出。 + ``` + +### 编译 + + ```Brach + # 编译固件 + make package/luci-app-chatgpt/compile V=s + ``` + +## 使用方法 + + - 打开luci-app-chatgpt基本设置,填写API密钥、GPT模型、填写OpenAI接口、选择用户图像等。 + + - 可选GPT模型,默认gpt-3.5,当前使用gpt-4模型需通过openai的表单申请。 + + - OpenAI接口当本地可正常访问`api.openai.com`,填写`https://api.openai.com/` + + - 不可正常访问`api.openai.com`,填写其反代地址,注意:反代接口响应需添加跨域Header `Access-Control-Allow-Origin` + + - chatgpt-web页面就可以正常使用了,如果需要设置更多请看 自定义设置设置语音和系统角色等。 + +## 自定义选项 + +- 左边栏支持,清除对话,搜索会话,新建/重命名/删除(会话/文件夹)导出/导入/重置会话和设置数据,显示本地存储。 + +- 可选系统角色,默认不开启,有四个预设角色,后期会加入更多角色。 + +- 可选角色性格,默认灵活创新,对应接口文档的top_p参数。 + +- 可选回答质量,默认平衡,对应接口文档的temperature参数。 + +- 修改打字机速度,默认较快,值越大速度越快。 + +- 允许连续对话,默认开启,对话中包含上下文信息,会导致api费用增加。 + +- 允许长回复,默认关闭,**开启后可能导致api费用增加,并丢失大部分上下文,对于一些要发送`继续`才完整的回复,不用发`继续`了。** + +- 选择语音,默认Bing语音,支持Azure语音和系统语音,可分开设置提问语音和回答语音。 + +- 音量,默认最大。 + +- 语速,默认正常。 + +- 音调,默认正常。 + +- 允许连续朗读,默认开启,连续郎读到所有对话结束。 + +- 允许自动朗读,默认关闭,自动朗读新的回答。**(iOS需打开设置-自动播放视频预览,Mac上Safari需打开此网站的设置-允许全部自动播放)** + +- 支持语音输入,默认识别为普通话,可长按语音按钮修改识别选项。**语音识别必需条件:使用chrome内核系浏览器 + https网页或本地网页。** 如点击语音按钮没反应,可能是未授予麦克风权限或者没安装麦克风设备。 + + +## 说明 + +-源码来源:https://github.com/sirpdboy/chatgpt-web +-你可以随意使用其中的源码,但请注明出处。 + +![screenshots](https://raw.githubusercontent.com/sirpdboy/openwrt/master/doc/说明2.jpg) + +## 界面 + +![screenshots](./doc/chatgpt1.png) + +![screenshots](./doc/chatgpt2.png) + +![screenshots](./doc/chatgpt3.png) + + +## 使用与授权相关说明 + +- 本人开源的所有源码,任何引用需注明本处出处,如需修改二次发布必告之本人,未经许可不得做于任何商用用途。 + +# My other project + +- 路由安全看门狗 :https://github.com/sirpdboy/luci-app-watchdog +- 网络速度测试 :https://github.com/sirpdboy/luci-app-netspeedtest +- 计划任务插件(原定时设置) : https://github.com/sirpdboy/luci-app-taskplan +- 关机功能插件 : https://github.com/sirpdboy/luci-app-poweroffdevice +- opentopd主题 : https://github.com/sirpdboy/luci-theme-opentopd +- kucat酷猫主题: https://github.com/sirpdboy/luci-theme-kucat +- kucat酷猫主题设置工具: https://github.com/sirpdboy/luci-app-kucat-config +- NFT版上网时间控制插件: https://github.com/sirpdboy/luci-app-timecontrol +- 家长控制: https://github.com/sirpdboy/luci-theme-parentcontrol +- 定时限速: https://github.com/sirpdboy/luci-app-eqosplus +- 系统高级设置 : https://github.com/sirpdboy/luci-app-advanced +- ddns-go动态域名: https://github.com/sirpdboy/luci-app-ddns-go +- 进阶设置(系统高级设置+主题设置kucat/agron/opentopd): https://github.com/sirpdboy/luci-app-advancedplus +- 网络设置向导: https://github.com/sirpdboy/luci-app-netwizard +- 一键分区扩容: https://github.com/sirpdboy/luci-app-partexp +- lukcy大吉: https://github.com/sirpdboy/luci-app-lukcy + +## 捐助 + +![screenshots](https://raw.githubusercontent.com/sirpdboy/openwrt/master/doc/说明3.jpg) + +| 图飞了😂 | 图飞了😂 | +| :-----------------: | :-------------: | +|![xm1](https://raw.githubusercontent.com/sirpdboy/openwrt/master/doc/支付宝.png) | ![xm1](https://raw.githubusercontent.com/sirpdboy/openwrt/master/doc/微信.png) | + + + 图飞了😂 + + diff --git a/luci-app-chatgpt-web/doc/chatgpt1.png b/luci-app-chatgpt-web/doc/chatgpt1.png new file mode 100644 index 00000000..f715b006 Binary files /dev/null and b/luci-app-chatgpt-web/doc/chatgpt1.png differ diff --git a/luci-app-chatgpt-web/doc/chatgpt2.png b/luci-app-chatgpt-web/doc/chatgpt2.png new file mode 100644 index 00000000..7de824a2 Binary files /dev/null and b/luci-app-chatgpt-web/doc/chatgpt2.png differ diff --git a/luci-app-chatgpt-web/doc/chatgpt3.png b/luci-app-chatgpt-web/doc/chatgpt3.png new file mode 100644 index 00000000..9d280941 Binary files /dev/null and b/luci-app-chatgpt-web/doc/chatgpt3.png differ diff --git a/luci-app-chatgpt-web/luasrc/controller/chatgpt-web.lua b/luci-app-chatgpt-web/luasrc/controller/chatgpt-web.lua new file mode 100644 index 00000000..c100132c --- /dev/null +++ b/luci-app-chatgpt-web/luasrc/controller/chatgpt-web.lua @@ -0,0 +1,17 @@ +-- Copyright (C) 2023-2025 sirpdboy https://github.com/sirpdboy/luci-app-chatgpt-web +-- Licensed to the public under the Apache License 2.0. + +module("luci.controller.chatgpt-web", package.seeall) + +function index() + if not nixio.fs.access("/etc/config/chatgpt-web") then + return + end + + local e = entry({"admin", "services", "chatgpt-web"}, alias("admin", "services", "chatgpt-web", "setting"),_("Chatgpt Web"), 20) + e.dependent=false + e.acl_depends = { "luci-app-chatgpt-web" } + entry({"admin", "services", "chatgpt-web", "setting"}, cbi("chatgpt-web"), _("Base Setting"), 10).leaf=true + entry({"admin", "services", "chatgpt-web", "chatgpt-web"}, cbi("chatgpt",{hideapplybtn=true, hidesavebtn=true, hideresetbtn=true}), _("Chatgpt Web"), 30).leaf=true +end + diff --git a/luci-app-chatgpt-web/luasrc/model/cbi/chatgpt-web.lua b/luci-app-chatgpt-web/luasrc/model/cbi/chatgpt-web.lua new file mode 100644 index 00000000..38f5e7da --- /dev/null +++ b/luci-app-chatgpt-web/luasrc/model/cbi/chatgpt-web.lua @@ -0,0 +1,36 @@ +-- Copyright (C) 2023-2025 sirpdboy https://github.com/sirpdboy/https://github.com/sirpdboy/luci-app-chatgpt-web + +local m, s ,o + +m = Map("chatgpt-web") +m.title = translate("Chatgpt Web") +m.description = translate("ChatGPT-Web is the version used on OPENWRT of OPENAI")..translate("
For specific usage, see:")..translate("GitHub @sirpdboy/chatgpt-web\ ") + +s = m:section(TypedSection, "basic", translate("Base Setting")) +s.addremove = false +s.anonymous = true + + +o = s:option(Value,"userpic",translate("Character images")) +o:value("girl.jpg",translate("girl")) +o:value("girl2.jpg",translate("girl2")) +o:value("girl3.jpg",translate("girl3")) +o:value("boy.jpg",translate("boy")) +o:value("boy2.jpg",translate("boy2")) +o:value("boy3.jpg",translate("boy3")) +o:value("dog.jpg",translate("dog")) +o:value("cartoon.jpg",translate("cartoon")) +o:value("cartoonboy.jpg",translate("cartoonboy")) +o:value("cartoonboy2.jpg",translate("cartoonboy2")) +o:value("cartoongirl.jpg",translate("cartoongirl")) +o:value("cartoongirl2.jpg",translate("cartoongirl2")) +o.rmempty = true +o.default="girl.jpg" + + +o=s:option(TextValue, "systemrole" ,translate("Default Role Settings")) +o.rows = 5 +o.rmempty=false + + +return m diff --git a/luci-app-chatgpt-web/luasrc/model/cbi/chatgpt.lua b/luci-app-chatgpt-web/luasrc/model/cbi/chatgpt.lua new file mode 100644 index 00000000..162de6d0 --- /dev/null +++ b/luci-app-chatgpt-web/luasrc/model/cbi/chatgpt.lua @@ -0,0 +1,9 @@ +-- Copyright (C) 2023-2025 sirpdboy https://github.com/sirpdboy/luci-app-chatgpt-web +require("luci.util") +local m, s + +m = Map("chatgpt-web",translate("")) + +m:section(SimpleSection).template = "chatgpt-web" + +return m diff --git a/luci-app-chatgpt-web/luasrc/view/chatgpt-web.htm b/luci-app-chatgpt-web/luasrc/view/chatgpt-web.htm new file mode 100644 index 00000000..078e5ac7 --- /dev/null +++ b/luci-app-chatgpt-web/luasrc/view/chatgpt-web.htm @@ -0,0 +1,6845 @@ +<%# + Copyright 2023-2025 sirpdboy Wich https://github.com/sirpdboy/luci-app-chatgpt-web + Licensed to the public under the Apache License 2.0. +-%> +<% +local fs = require "nixio.fs" +local dsp = require "luci.dispatcher" +local uci = luci.model.uci.cursor() +local apikeys = uci:get_first('chatgpt-web', 'basic', 'apikey') +local userpic = "/luci-static/chatgpt-web/user/" .. uci:get_first('chatgpt-web', 'basic', 'userpic') +local sysrole = uci:get_first('chatgpt-web', 'basic', 'systemrole') + +-%> + +
+ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
ChatGPT
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+ + 4o + + +
+
+
+ + +
+ + +
+
+
+
+ +
+
+
+
+ + +
+ + +
+
+
+
+
+
+ + + +
+
+
+
+
+ + + +
+
+ + + +
+
+
+
+
+
ChatGPT
+
DeepSeek
+
Azure
+
Gemini
+
Claude
+
+
+ +
+
+
+
ChatGPT
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+ + + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/luci-app-chatgpt-web/po/zh-cn/chatgpt-web.po b/luci-app-chatgpt-web/po/zh-cn/chatgpt-web.po new file mode 100644 index 00000000..91654dbd --- /dev/null +++ b/luci-app-chatgpt-web/po/zh-cn/chatgpt-web.po @@ -0,0 +1,71 @@ +msgid "" +msgstr "Content-Type: text/plain; charset=UTF-8" + +msgid "chatgpt-web" +msgstr "ChatGPT-Web" + +msgid "ChatGPT-Web is the version used on OPENWRT of OPENAI" +msgstr "ChatGPT-Web是OPENAI的OPENWRT上使用的WEB版本,不需要第三方依赖,绿色安全直接使用OPENAI的API接口" + +msgid "Base Setting" +msgstr "基本设置" + +msgid "GPT Model" +msgstr "GPT模型" + +msgid "Character images" +msgstr "人物图像" + +msgid "boy" +msgstr "男生" + +msgid "boy2" +msgstr "男生2" + +msgid "boy3" +msgstr "男生3" + +msgid "girl" +msgstr "女生" + +msgid "girl2" +msgstr "女生2" + +msgid "girl3" +msgstr "女生3" + +msgid "dog" +msgstr "狗" + +msgid "cartoon" +msgstr "卡通图像" + +msgid "cartoonboy" +msgstr "卡通男孩" + +msgid "cartoonboy2" +msgstr "卡通小男孩" + +msgid "cartoongirl" +msgstr "卡通女生" + +msgid "cartoongirl2" +msgstr "卡通女生2" + +msgid "OpenAI interface" +msgstr "OpenAI接口" + +msgid "API Key" +msgstr "API密钥" + +msgid "Enter your own API key, and if you do not have one, enter the official website API to generate it:" +msgstr "输入自己的API密钥(sk-开头),没有的进官网API生成:" + +msgid "Click to open" +msgstr "点击前往" + +msgid "Default Role Settings" +msgstr "默认角色设置" + +msgid "
For specific usage, see:" +msgstr "
具体使用方法参见:" diff --git a/luci-app-chatgpt-web/po/zh_Hans/chatgpt-web.po b/luci-app-chatgpt-web/po/zh_Hans/chatgpt-web.po new file mode 100644 index 00000000..91654dbd --- /dev/null +++ b/luci-app-chatgpt-web/po/zh_Hans/chatgpt-web.po @@ -0,0 +1,71 @@ +msgid "" +msgstr "Content-Type: text/plain; charset=UTF-8" + +msgid "chatgpt-web" +msgstr "ChatGPT-Web" + +msgid "ChatGPT-Web is the version used on OPENWRT of OPENAI" +msgstr "ChatGPT-Web是OPENAI的OPENWRT上使用的WEB版本,不需要第三方依赖,绿色安全直接使用OPENAI的API接口" + +msgid "Base Setting" +msgstr "基本设置" + +msgid "GPT Model" +msgstr "GPT模型" + +msgid "Character images" +msgstr "人物图像" + +msgid "boy" +msgstr "男生" + +msgid "boy2" +msgstr "男生2" + +msgid "boy3" +msgstr "男生3" + +msgid "girl" +msgstr "女生" + +msgid "girl2" +msgstr "女生2" + +msgid "girl3" +msgstr "女生3" + +msgid "dog" +msgstr "狗" + +msgid "cartoon" +msgstr "卡通图像" + +msgid "cartoonboy" +msgstr "卡通男孩" + +msgid "cartoonboy2" +msgstr "卡通小男孩" + +msgid "cartoongirl" +msgstr "卡通女生" + +msgid "cartoongirl2" +msgstr "卡通女生2" + +msgid "OpenAI interface" +msgstr "OpenAI接口" + +msgid "API Key" +msgstr "API密钥" + +msgid "Enter your own API key, and if you do not have one, enter the official website API to generate it:" +msgstr "输入自己的API密钥(sk-开头),没有的进官网API生成:" + +msgid "Click to open" +msgstr "点击前往" + +msgid "Default Role Settings" +msgstr "默认角色设置" + +msgid "
For specific usage, see:" +msgstr "
具体使用方法参见:" diff --git a/luci-app-chatgpt-web/root/etc/config/chatgpt-web b/luci-app-chatgpt-web/root/etc/config/chatgpt-web new file mode 100644 index 00000000..a7a1636a --- /dev/null +++ b/luci-app-chatgpt-web/root/etc/config/chatgpt-web @@ -0,0 +1,8 @@ + +config basic + option apiHost 'https://api.openai.com/' + option modelver 'gpt-3.5-turbo' + option userpic 'girl.jpg' + option apikey '' + option systemrole '你是一个乐于助人的专业助手,尽量简明扼要地回答' + diff --git a/luci-app-chatgpt-web/root/usr/share/rpcd/acl.d/luci-app-chatgpt-web.json b/luci-app-chatgpt-web/root/usr/share/rpcd/acl.d/luci-app-chatgpt-web.json new file mode 100644 index 00000000..c8c06f50 --- /dev/null +++ b/luci-app-chatgpt-web/root/usr/share/rpcd/acl.d/luci-app-chatgpt-web.json @@ -0,0 +1,11 @@ +{ + "luci-app-chatgpt-web": { + "description": "Grant UCI access for luci-app-chatgpt-web", + "read": { + "uci": [ "chatgpt-web" ] + }, + "write": { + "uci": [ "chatgpt-web" ] + } + } +} diff --git a/luci-app-chatgpt-web/root/www/luci-static/chatgpt-web/11.11.1github.min.css b/luci-app-chatgpt-web/root/www/luci-static/chatgpt-web/11.11.1github.min.css new file mode 100644 index 00000000..275239a7 --- /dev/null +++ b/luci-app-chatgpt-web/root/www/luci-static/chatgpt-web/11.11.1github.min.css @@ -0,0 +1,10 @@ +pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*! + Theme: GitHub + Description: Light theme as seen on github.com + Author: github.com + Maintainer: @Hirse + Updated: 2021-05-15 + + Outdated base version: https://github.com/primer/github-syntax-light + Current colors taken from GitHub's CSS +*/.hljs{color:#24292e;background:#fff}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#005cc5}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#032f62}.hljs-built_in,.hljs-symbol{color:#e36209}.hljs-code,.hljs-comment,.hljs-formula{color:#6a737d}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#22863a}.hljs-subst{color:#24292e}.hljs-section{color:#005cc5;font-weight:700}.hljs-bullet{color:#735c0f}.hljs-emphasis{color:#24292e;font-style:italic}.hljs-strong{color:#24292e;font-weight:700}.hljs-addition{color:#22863a;background-color:#f0fff4}.hljs-deletion{color:#b31d28;background-color:#ffeef0} \ No newline at end of file diff --git a/luci-app-chatgpt-web/root/www/luci-static/chatgpt-web/chatgpt-web.css b/luci-app-chatgpt-web/root/www/luci-static/chatgpt-web/chatgpt-web.css new file mode 100644 index 00000000..7a24e376 --- /dev/null +++ b/luci-app-chatgpt-web/root/www/luci-static/chatgpt-web/chatgpt-web.css @@ -0,0 +1,2166 @@ + + .chat_window { + display: flex; + width: 100%; + max-width: 1920px; + height: 888px; + border-radius: 8px; + background-color: rgba(255,255,255,0.5); + overflow: auto; + } + + .chat_window>.overlay { + width: 100%; + height: 100%; + position: absolute; + left: 0; + top: 0; + background-color: rgba(0, 0, 0, .3); + z-index: 90; + opacity: 0; + visibility: hidden; + cursor: pointer; + transition: all 120ms ease-in-out; + } + + .chat_window .mainContent .cbi-input-invalid { + color: var(--title-color); + border-bottom-color: var(--title-color); + } + + + @media screen and (min-width: 800px) { + + .mainContent { + width: calc(100% - 250px); + } + + .chat_window>.nav { + position: relative; + margin-left: -250px; + transition: margin-left 250ms; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + } + + .show-nav .nav { + margin-left: 0; + } + + #sysDialog { + max-width: 600px; + } + + .sysContent { + display: flex; + } + + .sysSwitch { + flex-shrink: 0; + width: 160px; + } + + .sysSwitch>div { + padding-left: 6px; + } + + .sysDetail { + margin-left: 12px; + margin-top: 5px; + } + } + + @media screen and (max-width: 800px) { + + .mainContent { + width: 100%; + } + + .chat_window>.nav { + position: absolute !important; + left: -250px; + transition: left 250ms; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + } + + .show-nav .nav { + left: 0; + box-shadow: rgba(0, 0, 0, 0.2) 0px 8px 10px -5px, rgba(0, 0, 0, 0.14) 0px 16px 24px 2px, rgba(0, 0, 0, 0.12) 0px 6px 30px 5px; + } + + .show-nav .overlay { + visibility: visible; + opacity: 1; + } + + #sysDialog { + max-width: 400px; + } + + .sysSwitch { + display: flex; + } + + .sysSwitch>div { + width: 50%; + justify-content: center; + } + + .sysDetail { + margin-top: 8px; + } + } + .full_window { + max-width: none; + max-height: none; + } + + .chat_window>.nav { + width: 250px; + background-color: rgba(var(--primary-rgbbody),1); + color: var(--body-color); + top: 0; + z-index: 99; + flex-shrink: 0; + display: flex; + flex-direction: column; + margin-left: -250px; + transition: left 120ms ease-in-out; + } + + .mainContent { + height: 100%; + position: relative; + width: 100%; + display: flex; + flex-direction: column; + flex: 1; + } + + .show-nav .nav { + margin-left: 0; + } + .show-nav .chatnav { + margin-left: 0; + box-shadow: rgba(0, 0, 0, 0.2) 0px 8px 10px -5px, rgba(0, 0, 0, 0.14) 0px 16px 24px 2px, rgba(0, 0, 0, 0.12) 0px 6px 30px 5px; + } + + .show-nav .overlay { + opacity: 1; + visibility: visible; + } + .top_menu { + width: 100%; + background-color: rgba(255,255,255,0.5); + height: 50px; + padding: 5px 0; + } + + .top_menu .toggler { + margin-left: 10px; + width: 40px; + height: 40px; + float: left; + padding: 5px 7px; + border-radius: 4px; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + } + + .top_menu .toggler:hover { + background: #e0e0e0; + } + + .top_menu .toggler .button { + width: 26px; + height: 4px; + border-radius: 4px; + position: absolute; + pointer-events: none; + } + + .top_menu .toggler .button.close { + margin-top: 3px; + background-color: #000; + } + + .top_menu .toggler .button.minimize { + margin-top: 13px; + background-color: #000; + } + + .top_menu .toggler .button.maximize { + margin-top: 23px; + background-color: #000; + } + + .top_menu .title { + font-size: 20px; + height: 40px; + line-height: 40px; + position: relative; + pointer-events: none; + } + + #selector { + position: absolute; + top: 50%; + left: 50%; + padding: 0 4px 0 10px; + cursor: pointer; + font-size: 20px; + transform: translate(-50%, -50%); + pointer-events: auto; + border-radius: 4px; + -webkit-user-select: none; + user-select: none; + display: flex; + align-items: center; + } + + #selector:hover { + color: var(--title-color); + } + + #modelVer { + margin-left: 6px; + margin-right: 4px; + height: 40px; + line-height: 38px; + } + + .messages { + position: relative; + flex: 1; + overflow-x: hidden; + overflow-y: auto; + font-size: 16px; + width: 100%; + text-align: center; + } + + .messages::-webkit-scrollbar, + + #chatlog .markdown-body>pre::-webkit-scrollbar, + #chatlog .markdown-body>pre>code::-webkit-scrollbar, + #chatlog .responseBody pre>code::-webkit-scrollbar, + #setDialog::-webkit-scrollbar, + .allList::-webkit-scrollbar, + .sysDetail::-webkit-scrollbar, + #apiSelect::-webkit-scrollbar { + width: 10px; + height: 10px; + } + + .messages::-webkit-scrollbar-track, + + #chatlog .markdown-body>pre::-webkit-scrollbar-track, + #chatlog .markdown-body>pre>code::-webkit-scrollbar-track, + #chatlog .responseBody pre>code::-webkit-scrollbar-track, + #setDialog::-webkit-scrollbar-track, + .allList::-webkit-scrollbar-track, + .sysDetail::-webkit-scrollbar-track, + #apiSelect::-webkit-scrollbar-track { + background-clip: padding-box; + background: transparent; + border: none; + } + + .messages::-webkit-scrollbar-corner, + + #chatlog .markdown-body>pre::-webkit-scrollbar-corner, + #chatlog .markdown-body>pre>code::-webkit-scrollbar-corner, + #chatlog .responseBody pre>code::-webkit-scrollbar-corner, + #setDialog::-webkit-scrollbar-corner, + .allList::-webkit-scrollbar-corner, + .sysDetail::-webkit-scrollbar-corner, + #apiSelect::-webkit-scrollbar-corner { + background-color: transparent; + } + + .messages::-webkit-scrollbar-thumb, + + #chatlog .markdown-body>pre::-webkit-scrollbar-thumb, + #chatlog .markdown-body>pre>code::-webkit-scrollbar-thumb, + #chatlog .responseBody pre>code::-webkit-scrollbar-thumb, + #setDialog::-webkit-scrollbar-thumb, + .allList::-webkit-scrollbar-thumb, + .sysDetail::-webkit-scrollbar-thumb, + #apiSelect::-webkit-scrollbar-thumb { + background-color: rgba(0, 0, 0, 0.1); + background-clip: padding-box; + border: solid transparent; + border-radius: 10px; + } + + .messages::-webkit-scrollbar-thumb:hover, + + #chatlog .markdown-body>pre::-webkit-scrollbar-thumb:hover, + #chatlog .markdown-body>pre>code::-webkit-scrollbar-thumb:hover, + #chatlog .responseBody pre>code::-webkit-scrollbar-thumb:hover, + #setDialog::-webkit-scrollbar-thumb:hover, + .allList::-webkit-scrollbar-thumb:hover, + .sysDetail::-webkit-scrollbar-thumb:hover, + #apiSelect::-webkit-scrollbar-thumb:hover { + background-color: rgba(0, 0, 0, 0.4); + } + + #chatlog { + word-wrap: break-word; + text-align: start; + } + + #chatlog>div { + padding: 18px 40px 18px 54px; + } + .chatAvatar { + position: absolute; + width: 30px; + height: 30px; + border-radius: 2px; + } + + .request>.chatAvatar { + top: 18px; + left: 13px; + margin-top: 15px; + margin-bottom: 15px; + } + .response>.chatAvatar { + background: #10a37f; + top: 18px; + left: 13px; + margin-top: 15px; + margin-bottom: 15px; + } + + .gptAvatar>svg { + margin-top: 3px; + margin-left: 3px; + } + + .chatAvatar>img { + display: block; + width: 100%; + height: 100%; + border-radius: 2px; + } + + .modelAvatar>svg, + .chatAvatar>svg { + display: block; + margin-top: 4px; + margin-left: 4px; + } + + #chatlog .request { + position: relative; + background-color: var(--inputborder-color); + display: flex; + justify-content: center; + } + + + .response .responseBody { + display: flex; + justify-content: flex-end; + margin: 18px 45px 18px 20px; + font-size: 15px; + + } + .requestBody { + white-space: pre-wrap; + margin: 18px; + flex: 1; + } + + #chatlog .response { + background-color: var(--inputborder-color); + position: relative; + } + + .response .markdown-body { + margin: 18px 65px 18px 0px; + flex: 1; + font-size: 15px; + background-color: rgba(255,255,255,0)!important; + + } + + #chatlog .markdown-body a { + padding: 0 1px 0 2px; + } + + #chatlog .markdown-body pre { + overflow-x: auto; + padding: 10px; + position: relative; + white-space: pre-wrap; + background-color: var(--inputborder-color)!important; + + } + + #chatlog .markdown-body .mermaid { + margin-bottom: 8px; + padding: 0 10px 0 10px; + position: relative; + border-radius: 6px; + } + + .mermaid>svg { + cursor: pointer; + max-height: 600px; + display: block; + } + + #chatlog .markdown-body pre>code { + overflow-x: auto; + white-space: pre-wrap; + display: block; + } + + #chatlog .markdown-body ul { + list-style-type: disc; + } + + .m-mdic-copy-wrapper { + position: absolute; + top: 5px; + right: 16px; + -webkit-user-select: none; + user-select: none; + } + + .m-mdic-copy-wrapper span.u-mdic-copy-code_lang { + position: absolute; + top: 3px; + right: calc(100% + 4px); + font-family: system-ui; + font-size: 12px; + line-height: 18px; + color: #bbb; + } + + .m-mdic-copy-wrapper div.u-mdic-copy-notify { + position: absolute; + top: 0; + right: 0; + padding: 3px 6px; + border: 0; + border-radius: 3px; + background: none; + font-family: system-ui; + font-size: 12px; + line-height: 18px; + color: #555; + outline: none; + right: 100%; + padding-right: 4px; + } + + .m-mdic-copy-wrapper button.u-mdic-copy-btn { + position: relative; + top: 0; + right: 0; + padding: 3px 6px; + border: 0; + border-radius: 3px; + background: none; + font-family: system-ui; + font-size: 12px; + line-height: 18px; + color: #bbb; + outline: none; + cursor: pointer; + transition: color 0.2s; + } + + .m-mdic-copy-wrapper span.u-mdic-copy-code_lang::before, + .m-mdic-copy-wrapper div.u-mdic-copy-notify::before, + .m-mdic-copy-wrapper button.u-mdic-copy-btn::before { + content: attr(text); + } + + .m-mdic-copy-wrapper button.u-mdic-copy-btn:hover { + color: #555; + } + + #stopChat { + display: none; + margin: 0 auto; + margin-top: 3px; + width: 80px; + height: 32px; + text-align: center; + line-height: 32px; + color: white; + background: #f8b26a; + cursor: pointer; + border-radius: 3px; + position: sticky; + bottom: 2px; + justify-content: center; + align-items: center; + } + + #stopChat>svg { + margin-right: 8px; + } + + #stopChat:hover { + background: #f0aa60; + } + + .bottom_wrapper { + position: relative; + width: 100%; + padding: 10px 10px; + max-width: 91%; + margin: 0 auto; + } + + .bottom_wrapper .message_input_wrapper { + width: calc(100% - 134px); + position: relative; + text-align: left; + } + + .bottom_wrapper .message_input_wrapper .message_input_text { + border-radius: 4px; + border: none; + outline: none; + resize: none; + background-color: var(--inputborder-color); + height: 47px; + font-size: 16px; + max-height: 200px; + padding: 13px 0 13px 13px; + width: 100%!important; + display: block; + transition: background-color 250ms; + } + + .bottom_wrapper .message_input_wrapper .message_input_text:focus { + background-color: var(--inputborder-color); + } + + .bottom_wrapper .message_input_wrapper .message_input_text::-webkit-scrollbar { + display: none; + width: 0; + height: 0; + } + + #sendbutton { + width: 70px; + height: 47px; + font-size: 18px; + font-weight: bold; + color: #fff; + background-color: #7ac516; + padding: 0.5rem 0.5rem; + transition: all 0.2s linear; + position: absolute; + right: 65px; + bottom: 10px; + cursor: not-allowed; + } + + .activeSendBtn { + background-color: #99c959 !important; + cursor: pointer !important; + } + + .activeSendBtn:hover { + background-color: #90c050 !important; + } + + .clearConv { + position: absolute; + right: 10px; + bottom: 10px; + width: 47px; + padding: 0.5rem 0.5rem; + height: 47px; + } + + .clearConv>svg { + margin: 0 auto; + top: 50%; + left: 50%; + } + + .clearConv:hover { + background-color: var(--body-hover-bgcolor); + } + + .clearConv svg:first-child { + display: none; + } + + .clearConv svg:nth-child(2) { + display: block; + } + + .closeConv { + background: #e0e0e0;; + } + + .closeConv:hover { + background: #e8e8e8; + } + + .closeConv svg:first-child { + display: block; + } + + .closeConv svg:nth-child(2) { + display: none; + } + + .loaded>span { + display: inline-block; + } + + .loaded>svg { + display: none; + } + + .loading { + background: rgba(0,0,0,0) !important; + } + + .loading>span { + display: none; + } + + .loading>svg { + display: block; + } + + .switch-slide { + display: inline-block; + vertical-align: middle; + } + + .switch-slide-label { + display: block; + width: 38px; + height: 18px; + background: #909090; + border-radius: 30px; + cursor: pointer; + position: relative; + -webkit-transition: 0.3s ease; + transition: 0.3s ease; + } + + .switch-slide-label:after { + content: ""; + display: block; + width: 16px; + height: 16px; + border-radius: 100%; + background: #fff; + box-shadow: 0 1px 1px rgba(0, 0, 0, .1); + position: absolute; + left: 1px; + top: 1px; + -webkit-transform: translateZ(0); + transform: translateZ(0); + -webkit-transition: all 250ms; + transition: all 250ms; + } + + .switch-slide input:checked+label { + background: #99c959; + -webkit-transition: all 250ms; + transition: all 250ms; + } + + .switch-slide input:checked+label:after { + left: 21px; + } + + .settings { + padding: 0px 7px; + margin-top: -40px; + float: right; + border-radius: 4px; + cursor: pointer; + top: 5px; + } + + + .setBtn { + margin-left: 2px; + cursor: pointer; + padding: 5px; + border: none; + border-radius: 4px; + } + + .setBtn>svg { + display: block; + color: #909090; + } + + .setBtn:hover { + background: #e0e0e0;; + } + + #setting { + right: 15px; + } + + #toggleFull { + right: 56px; + } + + #toggleLight *, + #toggleFull * { + pointer-events: none; + } + + .showSetting { + background-color: var(--inputborder-color); + } + + .showModels { + background-color: var(--inputborder-color); + } + + .showModels>svg:last-child { + transform: scaleY(-1); + } + + #modelDialog { + position: absolute; + z-index: 2; + background: rgba(var(--primary-rgbbody), 1); + width: 240px; + left: 50%; + transform: translateX(-50%); + top: 46px; + overflow-y: auto; + max-height: calc(100% - 55px); + -webkit-user-select: none; + user-select: none; + border-radius: 4px; + padding: 8px 5px 8px 5px; + box-shadow: 0 0 6px rgba(0, 0, 0, 0.15); + } + + .modelSingle { + height: 40px; + border-radius: 3px; + cursor: pointer; + line-height: 40px; + display: flex; + align-items: center; + position: relative; + } + + .modelSingle * { + pointer-events: none; + } + + .modelSingle:hover { + background-color: var(--body-hover-bgcolor); + } + + .activeModel::before { + content: ""; + position: absolute; + right: 15px; + top: 7px; + width: 8px; + height: 16px; + border-style: solid; + border-width: 0 4px 4px 0; + transform: rotate(45deg); + } + + .modelAvatar { + margin-left: 6px; + margin-right: 10px; + width: 30px; + flex-shrink: 0; + border-radius: 2px; + height: 30px; + } + + #setDialog { + position: absolute; + z-index: 2; + background-color: rgba(var(--primary-rgbbody),1); + width: 320px; + right: 6px; + top: 46px; + overflow-y: auto; + max-height: calc(100% - 55px); + -webkit-user-select: none; + user-select: none; + border-radius: 5px; + padding: 8px 12px 8px 12px; + box-shadow: 0 0 6px rgba(0, 0, 0, 0.15); + } + + #setDialog input { + width: 100%; + } + + #setDialog .inlineTitle { + display: inline-block; + width: 80px; + line-height: 16px; + vertical-align: middle; + } + + #convOption, + #speechOption, + #speechDetail, + #recOption { + margin-bottom: 6px; + } + + #convOption>div, + #speechOption>div, + #speechDetail>div, + #recOption div { + margin-top: 10px; + } + + #voiceRecSetting select, #speechDetail select { + color: var(--title-color); + + } + .inputTextClass { + outline: none; + border-radius: 2px; + margin-top: 3px; + height: 32px; + font-size: 15px; + padding-left: 6px; + border: none; + } + + .areaTextClass { + width: 100%; + height: 150px; + white-space: normal; + resize: none; + padding: 6px; + } + + input[type="range"] { + -webkit-appearance: none; + appearance: none; + display: block; + margin: 4px 0 3px 0; + height: 8px; + background: #909090; + border-radius: 5px; + background-image: linear-gradient(#99c959, #99c959); + background-size: 100% 100%; + background-repeat: no-repeat; + } + + input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + height: 15px; + width: 15px; + border-radius: 50%; + background: #99c959; + cursor: ew-resize; + } + + input[type=range]::-webkit-slider-runnable-track { + -webkit-appearance: none; + box-shadow: none; + border: none; + background: transparent; + } + + .justSetLine { + display: flex; + justify-content: space-between; + align-items: center; + } + + .justSetBtn { + height: 32px; + border-radius: 3px; + line-height: 32px; + background-color: var(--inputborder-color); + text-align: center; + padding: 0px 8px; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + } + + .justSetBtn:hover { + background-color: #d0d0d0; + } + + .justSetBtn>svg { + margin-right: 3px; + } + + .readyTestVoice>div:not(:first-child) { + display: none; + } + + .pauseTestVoice>div:nth-child(1), + .pauseTestVoice>div:nth-child(3) { + display: none; + } + + .resumeTestVoice>div:nth-child(1), + .resumeTestVoice>div:nth-child(2) { + display: none; + } + + .presetSelect>div { + display: inline-block; + } + + .presetSelect select { + outline: none; + border-radius: 3px; + width: 120px; + border-color: rgba(0, 0, 0, .3); + } + + .selectDef { + display: flex; + justify-content: space-between; + font-size: 13px; + color: #909090; + } + + #preSetSpeech { + width: 100%; + outline: none; + height: 30px; + line-height: 30px; + font-size: 14px; + margin-top: 5px; + border-radius: 3px; + border-color: rgba(0, 0, 0, .3); + } + + .mdOption { + position: absolute; + right: 0px; + top: 0; + width: 50px; + height: 100%; + pointer-events: none; + } + + .mdOption>div { + pointer-events: auto; + cursor: pointer; + } + + .mdOption svg * { + pointer-events: none; + } + + .refreshReq svg:not(:first-child) { + display: none; + } + + .halfRefReq svg:not(:nth-child(2)) { + display: none; + } + + .optionItems { + position: absolute; + bottom: -5px; + display: flex; + justify-content: space-between; + visibility: visible; + z-index: 1; + color: #808080; + } + + .optionItems:hover { + visibility: visible; + } + + .request:hover .optionItems, + .request:hover .voiceCls, + .response:hover .optionItems, + .response:hover .voiceCls { + visibility: visible; + } + + .optionItem { + border-radius: 10px; + height: 24px; + width: 32px; + border: 1px solid #e0e0e0;; + background-color: #f7f7f8; ; + display: flex !important; + justify-content: center; + align-items: center; + } + + .optionItem * { + pointer-events: none; + } + + .optionItem:hover { + background: #e0e0e0;; + } + + .voiceCls { + position: relative; + height: 100%; + visibility: visible; + display: flex; + align-items: center; + } + + .voiceCls>svg { + color: #c0c0c0; + display: block; + margin-left: 5px; + position: relative; + pointer-events: none; + } + + .voiceCls:hover>svg { + color: #808080; + } + + .showVoiceCls, + .showVoiceCls .markdown-body { + background: #e0e0e0 !important; + } + + .showVoiceCls .voiceCls { + visibility: visible !important; + } + + .showEditReq { + position: sticky !important; + top: 0; + bottom: 0; + z-index: 1; + } + + .showEditReq, + .showEditReq .markdown-body { + background: #e0e0e0 !important; + } + + .readyVoice svg:not(:first-child) { + display: none; + } + + .pauseVoice svg:not(:nth-child(2)) { + display: none; + } + + .resumeVoice svg:not(:nth-child(3)) { + display: none; + } + + #voiceTypes>span { + border-radius: 3px; + margin-left: 4px; + cursor: pointer; + color: var(--title-color); + padding: 1px 5px; + } + + #voiceTypes>span:hover { + background-color: var(--body-hover-bgcolor); + } + + .selVoiceType { + background-color: var(--inputborder-color); + } + + .navHeader { + width: 100%; + padding: 5px 10px; + display: flex; + justify-content: space-between; + } + + #newChat { + text-align: center; + width: 80%; + height: 40px; + border-radius: 3px; + background: var(--inputborder-color); + display: flex; + align-items: center; + justify-content: center; + -webkit-user-select: none; + user-select: none; + cursor: pointer; + flex: 1; + } + + .navHeader>div:hover { + background: var(--body-hover-bgcolor)!important; + } + + #newChat>svg { + margin-right: 2px; + } + + #newFolder { + height: 40px; + width: 40px; + margin-left: 10px; + border-radius: 3px; + background: var(--inputborder-color); + cursor: pointer; + position: relative; + -webkit-user-select: none; + user-select: none; + } + + #newFolder>svg { + display: block; + margin: 8px auto; + } + + .extraChat { + padding: 2px 10px 6px 10px; + position: relative; + } + + #searchChat { + width: 100%; + height: 36px; + padding-left: 10px; + padding-right: 16px; + font-size: 16px; + outline: none; + border: none; + background: var(--inputborder-color); + border-radius: 3px; + } + + #searchChat:focus { + background: var(--inputborder-color); + } + + #searchChat:placeholder-shown+#clearSearch { + display: none; + } + + #clearSearch { + position: absolute; + right: 12px; + top: 8px; + cursor: pointer; + } + + #clearSearch:hover { + color: var(--title-color); + } + + #clearSearch>svg { + display: block; + } + + .seledSearch { + background: var(--inputborder-color)!important; + } + + #matchCaseSearch { + position: absolute; + right: 12px; + top: 8px; + cursor: pointer; + border-radius: 3px; + color: var(--inputborder-color); + } + + #matchCaseSearch:hover { + background: var(--body-hover-bgcolor); + } + + #matchCaseSearch>svg { + display: block; + } + + .navFooter { + padding-bottom: 8px; + background: var(--inputborder-color); + } + + .navFooter .divider { + width: 100%; + border-top: 1px solid var(--inputborder-color); + margin: 4px 0; + } + + .navFunc { + padding-top: 5px; + display: flex; + justify-content: space-evenly; + } + + .navFunc svg { + display: block; + } + + .navFunc>div, + .navFunc>label { + border-radius: 20px; + text-align: center; + padding: 8px 8px; + font-size: 14px; + cursor: pointer; + } + + .navFunc>div:hover, + .navFunc>label:hover { + background: var(--body-hover-bgcolor); + } + + .navFooter .links { + text-align: center; + -webkit-user-select: none; + user-select: none; + } + + .navFooter .links a { + text-decoration: none; + } + + + .allList { + width: 100%; + flex: 1; + overflow-y: auto; + } + + #chatList { + min-height: 50px; + } + + .dragingLi { + filter: brightness(90%); + } + + .dragingChat { + background: #e8e8e8; + } + + .expandFolder>.headLi>svg { + transform: rotate(90deg); + } + + .expandFolder>.chatsInFolder { + display: block; + } + + .chatsInFolder { + display: none; + margin-left: 22px; + padding-left: 2px; + border-left: 1px solid #909090; ; + } + + .headLi, + .chatLi { + cursor: pointer; + width: 100%; + height: 50px; + display: flex; + justify-content: space-between; + align-items: center; + position: relative; + } + + .headLi *, + .chatLi * { + pointer-events: none; + } + + .headLi>svg, + .chatLi>svg { + margin-left: 10px; + pointer-events: none; + } + + .folderOption svg, + .chatOption svg { + pointer-events: auto; + } + + .headLi svg *, + .chatLi svg * { + pointer-events: none; + } + + .headLi .folderInfo { + position: absolute; + left: 40px; + height: 40px; + max-width: calc(100% - 115px); + } + + .chatLi .chatInfo { + position: absolute; + left: 45px; + height: 40px; + max-width: calc(100% - 100px); + } + + .folderInfo *, + .chatInfo * { + -webkit-user-select: none; + user-select: none; + } + + .chatInfo span { + background: var(--inputborder-color)!important; + } + + .headLi .folderName, + .chatLi .chatName { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + line-height: 20px; + height: 20px; + } + + .headLi .folderNum, + .chatLi .chatPre { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + font-size: 12px; + line-height: 20px; + height: 20px; + } + + .headLi .folderOption, + .chatLi .chatOption { + visibility: hidden; + display: flex; + margin-right: 5px; + } + + .folderLi .chatLi>svg { + margin-left: 5px; + } + + .folderLi .chatLi .chatInfo { + left: 35px; + max-width: calc(100% - 85px); + } + + .folderLi .chatLi #activeChatEdit { + left: 32px; + width: calc(100% - 60px) + } + + .folderLi:hover { + background: var(--body-hover-bgcolor)!important; + } + + .chatLi:hover { + background: var(--body-hover-bgcolor)!important; + } + + .headLi:hover .folderOption, + .chatLi:hover .chatOption { + visibility: visible !important; + } + + .activeFolder, + .activeChatLi { + background: var(--inputborder-color)!important; + } + + .activeChatLi .chatOption { + visibility: visible !important; + } + + .folderOption>svg:hover, + .chatOption>svg:hover { + color: var(--title-color); + } + + #activeChatEdit { + position: absolute; + left: 42px; + font-size: 16px; + border-radius: 2px; + color: var(--title-color); + background-color: rgba(var(--primary-rgbbody),1); + outline: none; + border: none; + pointer-events: auto; + height: 24px; + line-height: 24px; + width: calc(100% - 75px); + padding: 20px 3px; + z-index: 1; + } + + #loadMask { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 100; + background-color: #edeff2; + } + + #loadMask>div { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + text-align: center; + } + + @keyframes loading { + + 0%, + 100% { + transform: scale(0); + } + + 50% { + transform: scale(1); + } + } + + .loadingCSSIcon { + position: relative; + display: flex; + align-items: center; + justify-content: space-evenly; + } + + .loadingCSSIcon div { + width: 15%; + height: 0; + padding-bottom: 15%; + border-radius: 50%; + animation: loading 1s cubic-bezier(0.3, 0, 0.7, 1) infinite; + } + + .loadingCSSIcon div:nth-child(1) { + background: #e15b64; + animation-delay: -0.4s + } + + .loadingCSSIcon div:nth-child(2) { + background: #f8b26a; + animation-delay: -0.2s + } + + .loadingCSSIcon div:nth-child(3) { + background: #99c959; + animation-delay: 0s; + } + + #loadMask>div>:first-child { + font-size: 40px; + color: #909090; ; + } + + #loadMask>div>:last-child { + width: 140px; + height: 70px; + margin: 0 auto; + } + + #voiceRec { + position: absolute; + right: 0; + top: 0; + width: 47px; + height: 100%; + } + + .message_if_voice { + padding-right: 47px !important; + } + + #voiceRecIcon { + width: 100%; + height: 100%; + text-align: center; + cursor: pointer; + position: relative; + } + + #voiceRecIcon:hover>svg { + color: #808080; + } + + #voiceRecIcon>svg { + width: 38px; + height: 38px; + color: #b0b0b0; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + + #voiceRecIcon>svg .animVoice { + display: none; + } + + .voiceRecing>svg { + color: #99c959 !important; + } + + .voiceRecing .animVoice { + display: inline !important; + transform-origin: 0 64%; + animation-duration: 1.5s; + animation-name: scaleVoice; + animation-timing-function: ease; + animation-iteration-count: infinite; + } + + .voiceLong .animVoice { + display: inline !important; + transform-origin: 0 64%; + animation-duration: 0.3s; + animation-name: longVoice; + animation-timing-function: ease-in-out; + animation-iteration-count: 1; + } + + @keyframes longVoice { + 0% { + transform: scaleY(0); + } + + 100% { + transform: scaleY(1); + } + } + + @keyframes scaleVoice { + 0% { + transform: scaleY(0.28); + } + + 20% { + transform: scaleY(0.60); + } + + 28% { + transform: scaleY(0.28); + } + + 36% { + transform: scaleY(0.45); + } + + 44% { + transform: scaleY(0.28); + } + + 52% { + transform: scaleY(0.45); + } + + 62% { + transform: scaleY(0.80); + } + + 72% { + transform: scaleY(0.80); + } + + 90% { + transform: scaleY(0.28); + } + + 100% { + transform: scaleY(0.28); + } + } + + #voiceRecSetting { + display: none; + position: absolute; + top: -70px; + left: -26px; + z-index: 1; + padding: 4px 4px; + -webkit-user-select: none; + user-select: none; + border-radius: 3px; + box-shadow: 0 0 6px rgba(0, 0, 0, 0.15); + } + + #voiceRecSetting select { + width: 102px; + outline: none; + height: 28px; + border-radius: 3px; + border-color: rgba(0, 0, 0, .3); + } + + .presetModelCls label { + margin-right: 8px; + } + + .presetModelCls select { + height: 30px; + line-height: 30px; + margin-top: 2px; + font-size: 15px; + } + + .modelSwitch, + .setSwitch { + display: flex; + } + + .modelSwitch { + margin-bottom: 10px; + -webkit-user-select: none; + user-select: none; + } + + .modelSwitch>div, + .setSwitch>div { + border-radius: 3px; + width: calc(100% / 3); + padding: 8px; + line-height: 1; + text-align: center; + cursor: pointer; + } + + .modelSwitch>div:hover, + .setSwitch>div:hover { + background-color: var(--body-hover-bgcolor); + } + + .activeSwitch { + background-color: var(--inputborder-color); + } + + .checkLoad { + height: 32px; + border-radius: 3px; + line-height: 32px; + background: #d0d0d0; + text-align: center; + display: flex; + justify-content: center; + cursor: pointer; + } + + .checkLoad:hover { + background: #c0c0c0; + } + + .voiceChecking { + background-color: #c0c0c0 !important; + } + + .voiceChecking>svg { + display: inline !important; + } + + .checkLoad>svg { + display: none; + margin-right: 8px; + height: 32px; + width: 64px; + } + + #preSetSystem { + height: 30px; + line-height: 30px; + } + + #sysMask { + display: none; + position: fixed; + z-index: 200; + top: 0; + left: 0; + bottom: 0; + right: 0; + cursor: pointer; + justify-content: center; + align-items: center; + background: rgba(0, 0, 0, .4); + -webkit-tap-highlight-color: transparent; + } + + #sysDialog { + position: relative; + background: rgba(var(--primary-rgbbody),1); + cursor: auto; + max-height: 100%; + width: 100%; + display: flex; + flex-direction: column; + border-radius: 4px; + padding: 20px; + } + + .sysTitle { + font-size: 20px; + font-weight: bold; + margin-bottom: 8px; + -webkit-user-select: none; + user-select: none; + } + + .sysSwitch, + .sysSwitch>div * { + pointer-events: none; + } + + .sysSwitch>div { + border-radius: 3px; + height: 32px; + line-height: 32px; + text-align: center; + cursor: pointer; + pointer-events: auto; + font-weight: bold; + display: flex; + align-items: center; + -webkit-user-select: none; + user-select: none; + } + + .sysSwitch>div>svg { + margin-right: 4px; + } + + .sysSwitch>div:hover { + background-color: var(--body-hover-bgcolor); + } + + .sysDetail { + overflow-y: auto; + flex: 1; + margin-top: 4px; + } + + #closeSet { + position: absolute; + right: 0px; + top: 0px; + cursor: pointer; + padding: 10px 14px; + } + + #closeSet:hover { + color: #000; + } + + .setContent { + margin-top: 8px; + } + + .setNotNormalFlow { + position: relative; + } + + .setTitle { + margin-bottom: 6px; + font-weight: bold; + -webkit-user-select: none; + user-select: none; + } + + .setDetail { + margin: 0 10px; + -webkit-user-select: none; + user-select: none; + } + + .autoSelect>label, + .autoSelect>input { + cursor: pointer; + } + + .dataDetail { + display: flex; + } + + .dataDetail svg { + margin-right: 4px; + } + + .dataDetail>div, + .dataDetail>label { + border-radius: 3px; + text-align: center; + padding: 6px 8px; + margin-right: 12px; + font-size: 15px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + } + + .dataDetail>div:hover, + .dataDetail>label:hover { + background-color: var(--body-hover-bgcolor); + } + + .keyOptionDetail { + display: flex; + margin-top: 10px; + } + + #resetHotKey { + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + font-size: 15px; + border-radius: 3px; + padding: 6px 8px; + margin-left: 90px; + } + + #resetHotKey>svg { + margin-right: 4px; + } + + #resetHotKey:hover { + background-color:var(--body-hover-bgcolor); + } + + .hotKeyDetail>div { + position: relative; + height: 30px; + margin-bottom: 2px; + } + + .hotKeyDetail label { + line-height: 30px; + } + + .hotKeyDetail select { + position: absolute; + left: 110px; + outline: none; + border-radius: 3px; + width: 120px; + border-color: rgba(0, 0, 0, .3); + height: 30px; + font-size: 15px; + } + + .avatarDetail { + display: flex; + margin-top: 2px; + } + + .avatarDetail img { + border-radius: 2px; + width: 32px; + height: 32px; + flex-shrink: 0; + margin-right: 10px; + margin-top: 2px; + } + + .inputDetail input { + outline: none; + border-radius: 3px; + padding-left: 8px; + font-size: 15px; + width: 100%; + height: 34px; + border: 1px solid rgba(0, 0, 0, .3); + } + + .themeDetail { + display: flex; + width: 180px; + justify-content: space-between; + pointer-events: none; + } + + .themeDetail svg { + display: block; + } + + .themeDetail>div { + pointer-events: auto; + border-radius: 20px; + text-align: center; + padding: 8px 8px; + font-size: 14px; + cursor: pointer; + } + + .themeDetail>div * { + pointer-events: none; + } + + .themeDetail>div:hover { + background-color: var(--body-hover-bgcolor); + } + + .darkTheme>div:first-child { + background-color: var(--inputborder-color); + } + + .lightTheme>div:nth-child(2) { + background-color: var(--inputborder-color); + } + + .autoTheme>div:nth-child(3) { + background-color: var(--inputborder-color); + } + + .langDetail { + width: 110px; + } + + .enLang>div:first-child { + background-color: var(--inputborder-color); + } + + .zhLang>div:nth-child(2) { + background-color: var(--inputborder-color); + } + + #customAutoSet input { + width: 100px; + height: 30px; + line-height: 30px; + font-size: 15px; + outline: none; + border: 1px solid rgba(0, 0, 0, .3); + text-align: center; + border-radius: 3px; + background: #fff; + color: #24292f; + } + + #customAutoSet label { + margin-right: 8px; + } + + .progressBar { + position: relative; + width: 100%; + height: 12px; + border-radius: 6px; + background: #e0e0e0; + overflow: hidden; + } + + .nowProgress { + position: absolute; + left: 0; + top: 0; + height: 12px; + min-width: 1px; + border-radius: 6px; + background: #99c959; + } + + .progressDetail { + display: flex; + justify-content: space-between; + font-size: 15px; + } + + .cursorCls { + background-color: var(--inputborder-color); + width: 4px; + animation: 1s cursor-blinker infinite step-start; + } + + @keyframes cursor-blinker { + 0% { + opacity: 0; + } + + 50% { + opacity: 1; + } + + 100% { + opacity: 0; + } + } + + .apiSelsContainer { + position: relative; + z-index: 1; + } + + #apiSelect { + position: absolute; + top: 64px; + padding: 4px 0; + width: calc(100% - 20px); + left: 10px; + box-shadow: 0 0 6px rgba(0, 0, 0, 0.15); + max-height: 180px; + overflow-y: auto; + -webkit-user-select: none; + user-select: none; + } + + #apiSelect>div { + pointer-events: auto; + cursor: pointer; + font-size: 15px; + padding: 6px 0 6px 8px; + height: 36px; + display: flex; + justify-content: space-between; + align-items: center; + } + + #apiSelect>div:hover { + background-color:var(--body-hover-bgcolor); + } + + #apiSelect>div>span { + height: 100%; + line-height: 23px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + #apiSelect>div * { + pointer-events: none; + } + + .delApiOption:hover { + background-color: var(--body-hover-bgcolor); + } + + .delApiOption { + width: 36px; + height: 36px; + flex-shrink: 0; + pointer-events: auto !important; + } + + .delApiOption>svg { + margin: 6px; + display: block; + } + + @keyframes textInOut { + from { + opacity: 0; + } + + to { + opacity: 1; + } + } + + .response[data-loading="true"]>.responseBody>*:not(.cursorCls) { + animation: textInOut 0.48s ease-in-out forwards; + } + + .cotBtn { + width: 120px; + height: 32px; + font-size: 14px; + user-select: none; + background-color: var(--inputborder-color); + position: relative; + padding-left: 14px; + line-height: 32px; + border-radius: 8px; + margin-bottom: 12px; + cursor: pointer; + } + + .cotBtn:hover { + background-color: var(--body-hover-bgcolor); + } + + .cotBtn.endCot { + width: 142px; + } + + .cotBtn::after { + content: ''; + display: inline-block; + position: absolute; + right: 15px; + top: 14px; + width: 6px; + height: 6px; + transform: rotate(-45deg); + } + + .cotBtn.down::after { + top: 11px; + transform: rotate(135deg); + } + + .cotContent { + background-color: var(--inputborder-color); + padding-left: 12px; + font-size: 14px; + } + + .cotContent>:last-child { + margin-bottom: 12px !important; + } + + .hide { + display: none; + } + + + + + + + diff --git a/luci-theme-footstrap/htdocs/luci-static/footstrap/logo_48.png b/luci-theme-footstrap/htdocs/luci-static/footstrap/logo_48.png new file mode 100644 index 00000000..f29b31f9 Binary files /dev/null and b/luci-theme-footstrap/htdocs/luci-static/footstrap/logo_48.png differ diff --git a/luci-theme-footstrap/htdocs/luci-static/footstrap/manifest.json b/luci-theme-footstrap/htdocs/luci-static/footstrap/manifest.json new file mode 100644 index 00000000..1694b462 --- /dev/null +++ b/luci-theme-footstrap/htdocs/luci-static/footstrap/manifest.json @@ -0,0 +1,18 @@ +{ + "id": "/cgi-bin/luci/", + "name": "LuCI", + "short_name": "LuCI", + "start_url": "/cgi-bin/luci/", + "scope": "/cgi-bin/luci/", + "display": "standalone", + "background_color": "#f6f8fa", + "theme_color": "#f6f8fa", + "icons": [ + { + "src": "/luci-static/footstrap/app-icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-appearance.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-appearance.js new file mode 100644 index 00000000..601e28c1 --- /dev/null +++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-appearance.js @@ -0,0 +1,842 @@ +'use strict'; +'require baseclass'; +'require ui'; +'require dom'; +'require fs-prefs as prefs'; +'require fs-axes as axes'; +'require fs-assets as assets'; +'require fs-version as ver'; + +/* The Appearance controls: the DOM that presents the axes. It owns no preference — fs-prefs.js + * holds the axes and fs-version.js the version string; this file is the form they are shown in. + * + * Built once, for the dispatched page: `view/footstrap/appearance.js` — a menu entry, an ordinary + * route — calls `renderStandalone()` below, the whole surface this file exposes. Before the page + * existed the same form was stapled onto a tab on System -> System instead, watching for the stock + * form's own tab strip with a MutationObserver, a 5 s deadline, a `body[data-page]` tracker, a + * stale-group WeakSet disqualifying the outgoing page's own DOM from a false match, and a + * sessionStorage flag so a reset's reload landed back on the tab rather than whichever one LuCI + * remembered. A Save & Apply on a real router once lost that tab outright, never reproduced on + * either stand — a mount stapled onto another app's own render is a timing dependency on that + * render, by construction, and the most likely account of a user report that could never be + * reproduced either way. All of it is gone now that the page owns a route of its own. Twenty-one + * axes, nine of them with a colour field, a swatch and a contrast readout, do not fit a floating + * popover that has to trap Tab and stay inside a 320px column, and keeping both containers would + * render every axis twice. + * + * The version line makes no request and must not grow one: which version is INSTALLED is what this + * page answers, and which is available is the package manager's question. */ + +/* ---- colour: reading what the page is actually painted ---- + * + * This lived in fs-widgets.js, which the menu and the search palette also require — so the whole + * colour engine was downloaded on every admin page to be used on this one. It is 3 KB of probe, + * canvas and WCAG arithmetic that nothing outside this form has ever called: `colorControl` was + * fs-widgets' only colour export and this file its only consumer. */ + +/* ---- colour: reading what the page is actually painted ---- + * + * Two questions no stored value answers: what colour a role is right now (the palette's own while + * the axis is off — there is deliberately no copy of the palette in JS), and what contrast the + * user's colour lands at. Both are about the computed cascade, so both are asked of the browser. + * + * `getComputedStyle(root).getPropertyValue('--fs-accent')` answers neither: a custom property + * computes to the token stream after var() substitution, so `oklch(from … l c H)` comes back + * unevaluated. Setting the expression as a real `color` and reading it back makes the browser + * resolve it — relative colour, color-mix() and the tint's calc() are what the theme is made of. + * One hidden probe is reused; an element per query would thrash layout on every slider drag. */ +let _probe = null; +function probeColor(expr) { + if (!_probe) { + /* Off-screen rather than display:none, so the reading does not depend on a display:none + * element computing `color` in every engine. It has no text and no size, so it paints + * nothing. + * + * Every declaration is !important (issue #19): this is an unmarked element in a document + * shared with `luci-app-*`, and an app's unlayered `span { color: … !important }` outranks + * a layer and a plain inline style alike. A probe that loses its own colour reports the + * app's, which then becomes the admin's saved axis on the next confirm. */ + /* the id is this probe's escape from fs-router.js's strayBodyNode() (issue #56): it is + * appended once and never removed, so without an fs-* id it would read as body litter and + * force every navigation away from this page into a full load. */ + _probe = E('span', { 'id': 'fs-appearance-probe', 'aria-hidden': 'true' }); + _probe.style.cssText = 'position:fixed!important;left:-9999px!important;top:0!important;' + + 'width:0!important;height:0!important;overflow:hidden!important;' + + 'pointer-events:none!important;'; + document.body.appendChild(_probe); + } + /* cleared first: an expression the engine rejects leaves the previous colour standing, which + * would report a stale answer as a fresh one */ + _probe.style.setProperty('color', ''); + _probe.style.setProperty('color', expr, 'important'); + return getComputedStyle(_probe).color; +} + +/* A computed colour -> [r,g,b] 0..255, or null. Rasterised, not parsed: a computed `color` keeps + * the space it was authored in, so `oklch(0.54 0.19 300)` would parse as three numbers in the + * wrong units and produce a colour nobody chose — measured: #010078, graded "Too faint to read", + * in the hex field, the swatch and the contrast readout alike. Painting one pixel makes the engine + * convert instead (tools/export-tier.mjs uses the same method). Canvas 2D is universal on every + * engine 24.10 ships against, so there is no second path. */ +let _cx = null; +function rasterCtx() { + if (!_cx) { + const cv = document.createElement('canvas'); + cv.width = cv.height = 1; + _cx = cv.getContext('2d', { willReadFrequently: true }); + } + return _cx; +} +function parseColor(s) { + const cx = rasterCtx(); + /* fillStyle keeps the last value it could parse, so a colour this engine rejects would + * report the previous one as a fresh reading — the trap probeColor() clears for */ + cx.fillStyle = '#000'; + cx.fillStyle = String(s || ''); + cx.clearRect(0, 0, 1, 1); + cx.fillRect(0, 0, 1, 1); + const d = cx.getImageData(0, 0, 1, 1).data; + /* translucent: composite over nothing is meaningless for a readout */ + return d[3] === 255 ? [ d[0], d[1], d[2] ] : null; +} + +/* WCAG 2.x relative luminance and contrast ratio, on sRGB. Used only to report: the theme states + * what a colour costs and leaves the choice with the user, never correcting it (03-palettes.css + * derives the ink over a fill, which is a different question). */ +function luminance(rgb) { + const c = rgb.map((v) => { + const x = v / 255; + return (x <= .03928) ? (x / 12.92) : Math.pow((x + .055) / 1.055, 2.4); + }); + return (.2126 * c[0]) + (.7152 * c[1]) + (.0722 * c[2]); +} +function contrastRatio(fgExpr, bgExpr) { + const fg = parseColor(probeColor(fgExpr)), bg = parseColor(probeColor(bgExpr)); + if (!fg || !bg) return null; + const a = luminance(fg), b = luminance(bg); + return (Math.max(a, b) + .05) / (Math.min(a, b) + .05); +} + +/* #rrggbb, because accepts nothing else. An unparseable colour becomes black + * rather than throwing: the text field beside the swatch is the authoritative one. */ +function toHex(s) { + const rgb = parseColor(s) || [ 0, 0, 0 ]; + return '#' + rgb.map((v) => Math.round(v).toString(16).padStart(2, '0')).join(''); +} + +/* One colour axis: a native swatch, a hex field and a button back to the palette's own colour. + * Reports through onPick as a hex string, or 0 for "back to the palette", either of which the + * caller hands straight to fs-prefs.js's colorAxis. + * + * There is no hue slider and one is not coming back: rotating a hue keeps the palette's chroma, + * so no angle of it reaches a grey. The axis still accepts a stored hue (1–360) and the stylesheet + * still rotates the palette by one, so a saved value goes on working. + * + * `opts.probe` is the live token the effective colour is read back from, so the field shows the + * palette's colour while the axis is off without a copy of the palette in JS. `opts.contrast` is + * the pair whose ratio is reported under the row. */ +function colorControl(onPick, label, opts) { + const o = opts; + + /* type=color leaves the picker to the browser: accessible without reimplementing a colour + * wheel, and native on a phone. The text field beside it takes a pasted hex and is the + * fallback where the browser draws no picker. */ + const swatch = E('input', { 'type': 'color', 'class': 'fs-color-swatch', 'aria-label': label || '' }); + const field = E('input', { + 'type': 'text', 'class': 'fs-color-hex', 'spellcheck': 'false', 'autocomplete': 'off', + 'inputmode': 'text', 'maxlength': '7', 'aria-label': label || '' + }); + const clear = E('button', { 'class': 'btn fs-color-clear', 'type': 'button' }, [ _('Palette', 'footstrap') ]); + const ratio = o.contrast ? E('div', { 'class': 'cbi-value-description fs-color-contrast' }) : null; + + /* Repaint everything that mirrors the axis. Called after every edit, and through the returned + * refresh() after a preset, palette switch or dark-mode flip — each changes what the palette's + * own colour is while this axis stays off. */ + function reflect(v) { + const live = probeColor(o.probe); + const hex = (typeof v === 'string') ? v : toHex(live); + swatch.value = hex; + /* do not fight the user mid-edit: `#0` is a legal prefix, and overwriting the field on + * every keystroke made the input impossible to type into */ + if (document.activeElement !== field) field.value = hex; + /* the button back to the palette doubles as the axis state readout: enabled means the axis + * holds a colour of its own, disabled means the field shows the palette's */ + clear.disabled = !v; + if (!ratio) return; + const r = contrastRatio(o.contrast.fg, o.contrast.bg); + if (r === null) { ratio.textContent = ''; ratio.removeAttribute('title'); return; } + /* The readout states what the ratio means; the number itself stays in the title. + * Thresholds are WCAG AA: 4.5:1 for body text, 3:1 for large text and for a UI shape, so a + * hairline is graded on the second (`kind: 'shape'`) and warns rather than fails — a faint + * border is a legitimate choice. + * + * Class names are written out whole: tools/fs-orphans.mjs sweeps dead CSS by matching + * fs-* tokens in the source, and a concatenated name is invisible to it. */ + const where = o.contrast.label; + const grade = (o.contrast.kind === 'shape') + ? ((r >= 3) + ? { cls: 'fs-contrast-aa', text: _('Clearly visible %s', 'footstrap').format(where) } + : { cls: 'fs-contrast-aa-large', text: _('Barely visible %s', 'footstrap').format(where) }) + : (r >= 4.5) + ? { cls: 'fs-contrast-aa', text: _('Easy to read %s', 'footstrap').format(where) } + : (r >= 3) + ? { cls: 'fs-contrast-aa-large', text: _('Hard to read %s — large text only', 'footstrap').format(where) } + : { cls: 'fs-contrast-low', text: _('Too faint to read %s', 'footstrap').format(where) }; + ratio.className = 'fs-color-contrast ' + grade.cls; + ratio.textContent = grade.text; + ratio.title = _('Contrast %s:1 (WCAG AA wants %s:1 here)', 'footstrap') + .format(r.toFixed(1), (o.contrast.kind === 'shape') ? '3' : '4.5'); + } + + const pick = (v) => { onPick(v); reflect(v); }; + + swatch.addEventListener('input', () => pick(swatch.value.toLowerCase())); + /* commit on blur and Enter, not per keystroke: a half-typed `#0096` would repaint the page + * under the cursor. An unparseable value snaps back to what the axis holds, so the field + * cannot claim a colour the page is not painted in. */ + const commit = () => { + const v = field.value.trim().toLowerCase(); + if ((/^#[0-9a-f]{6}$/).test(v)) pick(v); + /* re-read rather than a cached copy: the page can change what the axis holds behind this + * control (a preset, Reset to default), which a snapshot taken at build time would miss */ + else reflect(o.read()); + }; + field.addEventListener('blur', commit); + field.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); commit(); } }); + clear.addEventListener('click', () => pick(0)); + + const wrap = E('div', { 'class': 'fs-colorctl' + (o.cls ? ' ' + o.cls : '') }, [ + E('div', { 'class': 'fs-color-row' }, [ swatch, field, clear ]) + ].concat(ratio ? [ ratio ] : [])); + /* the caller decides when this runs: probeColor() needs the document, and this control is not + * in it yet */ + wrap.fsRefresh = () => reflect(o.read()); + return wrap; +} + +/* Build the whole form. Returns the root element, for renderStandalone() below to hand to the + * page's own render(). + * + * Everything applies immediately and there is nothing to save: every axis is this browser's, in + * localStorage, and the page repaints under the control as it moves. Only "Save to router" writes + * anything, pushing the current look to the ROUTER for other browsers. That distinction is the + * model (docs/design-system.md), and why this page has no Save/Reset footer of LuCI's own — + * view/footstrap/appearance.js nulls handleSave/handleSaveApply/handleReset, which is what actually + * drops it: view.js's own default footer (Save & Apply | Apply unchecked | Save | Reset, wired to + * apply_rollback) renders and runs otherwise, POSTing on a form with nothing staged (measured, + * live, before that null). */ +function build() { + /* every saved axis re-checks the Save button after applying, so it greys the moment this + * browser matches the saved default and un-greys when it diverges. Wrapped around the appliers + * because the controls call them directly and have no other seam back here. */ + const bump = (fn) => (v) => { fn(v); refreshSave(); }; + + /* Every colour control mirrors something it does not own: the palette's colour while its own + * axis is off, and the contrast that colour lands at. A palette switch or dark-mode flip moves + * all of it under controls nobody touched, so they refresh together. */ + const colourCtls = []; + const refreshColours = () => colourCtls.forEach((c) => c.fsRefresh()); + /* wrap an applier so the colour readouts follow it: mode and palette change what every axis is + * measured against */ + const repaint = (fn) => (v) => { fn(v); refreshColours(); }; + + /* One captioned row in LuCI's own shape: `.cbi-value` > `label.cbi-value-title` + + * `.cbi-value-field`. Nothing here styles those class names — base/30-forms.css and + * theme/60-inputs.css already lay them out — so this page inherits every future fix to the form + * layout instead of keeping a private copy. It also puts the row on a shared surface (zone 2, + * where an app may win on specificity), which is right for a page inside #view. + * + * `make` is handed the same label string the caption renders, because every control needs it a + * second time as its aria-label; stating it twice is how the visible caption and what a screen + * reader announces drift apart. `extra` carries rows with more than a control, `opts.cls` marks + * the rows CSS has to single out. */ + const group = (label, make, opts) => { + const o = opts || {}; + return E('div', { 'class': 'cbi-value' + (o.cls ? ' ' + o.cls : '') }, [ + E('label', { 'class': 'cbi-value-title' }, [ label ]), + E('div', { 'class': 'cbi-value-field' }, [ make(label) ].concat(o.extra || [])) + ]); + }; + + /* the three literals every colour row repeats; a string literal survives minification intact */ + const CARD_BG = 'var(--fs-panel)', INK = 'var(--fs-text)', ON_CARD = _('on a card', 'footstrap'); + + /* ---- the controls are LuCI's own ---- + * + * Every enum axis is a `ui.Select` and every number a `ui.RangeSlider`: the widgets the other + * tabs are built from, already dressed by this stylesheet (`select` in base/30-forms.css, + * `.cbi-range-slider` in theme/60-inputs.css). Both exist on every release this theme supports + * — checked against openwrt-24.10, since `ui.RangeSlider` is the one that did not exist further + * back. + * + * Listen for `widget-change` from UIElement, not for an event on the inner element: that is the + * seam the widget publishes, and reaching past it ties this to how the widget happens to be + * built. RangeSlider also emits `widget-update` while the handle moves, which is what resizes + * the page under the drag; the appliers are idempotent, so wiring both costs nothing. */ + const selectCtl = (current, choices, apply, label) => { + const w = new ui.Select(String(current), choices, { widget: 'select', sort: Object.keys(choices) }); + const node = w.render(); + node.setAttribute('aria-label', label); + node.addEventListener('widget-change', () => apply(w.getValue())); + return node; + }; + + const sliderCtl = (current, min, max, apply, label, opts) => { + const o = opts || {}; + const w = new ui.RangeSlider(String(current), { + min: min, max: max, step: o.step || 1 + }); + const node = w.render(); + node.setAttribute('aria-label', label); + const push = () => apply(parseInt(w.getValue(), 10)); + /* `live: false` for an axis whose value moves THIS CONTROL. Content width widens the column + * the Appearance page is drawn in, so applying mid-drag slides the handle out from under + * the pointer, the pointer catches up, and one small drag runs the value to the maximum — + * reported from a router, and not a thing any other slider here can do: rounding and the + * tint sliders repaint around a control that stays where it is. On release the page still + * follows, and a keyboard user is unaffected either way: an arrow key on a range input + * fires `change` as well as `input`, so the value applies at once. */ + if (o.live !== false) node.addEventListener('widget-update', push); + node.addEventListener('widget-change', push); + return node; + }; + + /* one colour axis: `axis` is the `{current, apply}` object fs-axes.js exports it as; `probe` is + * the live token the control reads the effective colour back from, `contrast` the pair it + * reports */ + const colourGroup = (label, axis, probe, contrast, opts = {}) => group(label, (lbl) => { + const ctl = colorControl(bump(axis.apply), lbl, { + probe: probe, + read: axis.current, + contrast: contrast, + cls: opts.cls + }); + colourCtls.push(ctl); + return ctl; + }, opts); + + /* Every label here carries the 'footstrap' context (`_(str, ctx)`, key `ctx\1str`). LuCI serves + * one merged catalogue — load_catalog() loads every *..lmo and a lookup returns the first + * archive holding the hash — so a bare msgid is a global name any luci-app may take, and + * readdir order picks the winner: the layout toggle rendered "Максимум" on a Russian router + * because another catalogue translates "Top" as "maximum" (issue #6). Contexting cannot be + * selective. The chrome, the login/notice sentences and the System/Memory/Storage headings are + * deliberately bare — inheriting luci-base's translation covers the ~40 languages this theme + * has no catalogue for. */ + + /* ---- section 1: the shell ---- */ + const shell = [ + group(_('Layout', 'footstrap'), (label) => selectCtl(prefs.currentLayout(), { + sidebar: _('Sidebar', 'footstrap'), + top: _('Top', 'footstrap') + }, bump(prefs.applyLayout), label)), + + group(_('Theme', 'footstrap'), (label) => selectCtl(prefs.currentMode(), { + auto: _('Auto', 'footstrap'), + light: _('Light', 'footstrap'), + dark: _('Dark', 'footstrap') + }, bump(repaint(prefs.applyMode)), label)), + + group(_('Palette', 'footstrap'), (label) => selectCtl(axes.palette.current(), { + footstrap: 'Footstrap', + hicontrast: 'Hi-Contrast', + /* names the OTHER package, luci-theme-bootstrap, whose colours this palette is — + * so it is a proper noun and stays untranslated, like the two above it */ + bootstrap: 'Bootstrap', + /* names the OTHER package again, luci-theme-openwrt-2020, whose colourway this is */ + '2020': 'OpenWrt 2020', + /* names the OpenWrt forum (forum.openwrt.org), whose Discourse colourway this is — + * a proper noun like the three above it, not the English common noun "forum" */ + forum: 'Forum' + }, bump(repaint(axes.palette.apply)), label)), + + group(_('Density', 'footstrap'), (label) => selectCtl(prefs.currentDensity(), { + compact: _('Compact', 'footstrap'), + normal: _('Normal', 'footstrap'), + large: _('Large', 'footstrap') + }, bump(prefs.applyDensity), label)), + + /* issue #44: the content column's own cap, separate from Density (which moves type and + * air, not the column's ceiling). The slider STARTS at the 1280px the theme has always + * shipped and only ever widens — there is no reason to offer a column narrower than the + * one every page was designed against, and keeping 1280 as the left end also keeps + * --fs-content-min (500px, what the sidebar-to-bar fold is measured against) out of reach + * by construction rather than by a rule someone has to remember. */ + group(_('Content width', 'footstrap'), + (label) => sliderCtl(axes.contentWidth.current(), 1280, 3840, + bump(axes.contentWidth.apply), label, { step: 40, live: false })), + + group(_('Rounding', 'footstrap'), + (label) => sliderCtl(axes.radius.current(), 0, 20, bump(axes.radius.apply), label)), + + /* The top layout has no accordion, so this switch is meaningless there: always built, + * hidden by CSS (:root[data-layout="top"] .fs-ap-submenus). Do not wrap it in an + * `if (currentLayout() !== 'top')` — the page is built once, so the branch would freeze + * the control to the layout the page loaded in while CSS morphs the chrome live. */ + group(_('Submenus', 'footstrap'), (label) => selectCtl( + prefs.currentAutoCollapse() ? 'on' : 'off', { + off: _('Keep open', 'footstrap'), + on: _('Auto-collapse', 'footstrap') + }, bump(prefs.applyAutoCollapse), label), + { cls: 'fs-ap-submenus' }) + ]; + + /* ---- section 2: colours ---- */ + const colours = [ + /* the caption says what the axis is for: "Tint" alone reads as decoration, and nobody + * would look for the router-identity cue under it */ + colourGroup(_('Tint (router identification)', 'footstrap'), axes.tint, 'var(--fs-bg)', { + /* the canvas is the one axis with no derived ink: its text is --fs-text, a palette + * token this axis must not move, so the ratio is reported instead of corrected */ + fg: 'var(--fs-text)', bg: 'var(--fs-bg)', label: _('on the canvas', 'footstrap') + }, { cls: 'fs-ap-tint' }), + + /* The strength half of the Tint, meaningful only in hue mode: a hex canvas is the colour + * asked for, with no chroma of ours to scale. CSS hides it in the other two states. + * + * Not called "Density": that is the select above, and this string is both the caption and + * the aria-label, so a screen reader would announce two rows under one name. */ + group(_('Tint strength', 'footstrap'), + (label) => sliderCtl(axes.tintStrength.current(), 0, 200, bump(repaint(axes.tintStrength.apply)), label, { + step: 5 + }), { cls: 'fs-ap-tint fs-ap-tintstr' }), + + /* recolours the accented controls (buttons/toggles/sliders/focus rings), not the canvas. + * Measured as text on a card, the use that fails first: as a fill it carries derived ink, + * as a link or status label it carries only itself. It is also what answers #20 ("sometimes + * you want grey or black"), taking any #rrggbb — the colour-chip presets that once sat here + * are not coming back. */ + /* Four status roles, one shape: the role's own colour read against a card. Written out + * eight times between here and the surfaces below, they cost their repeated literals in + * full — a string is not mangled — so the rows are data and the row is stated once. */ + ...[ + [ _('Accent', 'footstrap'), axes.accent, 'var(--fs-accent)' ], + [ _('Good', 'footstrap'), axes.good, 'var(--fs-good)' ], + [ _('Warning', 'footstrap'), axes.warn, 'var(--fs-warn)' ], + [ _('Danger', 'footstrap'), axes.danger, 'var(--fs-danger)' ] + ].map(([ label, axis, ink ]) => + colourGroup(label, axis, ink, { fg: ink, bg: CARD_BG, label: ON_CARD })) + ]; + + /* ---- the surfaces: the sheet the UI is drawn on ---- + * Cards, inset controls, the chrome bar and the hairlines between them. Body text is read on + * each, so each reports --fs-text against itself. No ink is derived here: --fs-text is the + * palette's, and moving it would recolour the very thing being measured against. + * + * The hairline takes the 3:1 UI-component threshold instead — a border is a shape, not a label + * — and below that it is decoration, which a hairline is entitled to be, so the readout states + * the number and leaves the call to the admin. */ + const surfaces = [ + /* Same rows, one column wider: a surface reports the ink read ON it, which is --fs-text + * for the three that carry body text and the hairline itself for the border. */ + ...[ + [ _('Cards', 'footstrap'), axes.card, CARD_BG, INK, CARD_BG, ON_CARD ], + [ _('Controls', 'footstrap'), axes.control, 'var(--fs-panel2)', INK, 'var(--fs-panel2)', _('on a control', 'footstrap') ], + [ _('Sidebar and bar', 'footstrap'), axes.bar, 'var(--fs-bar-bg)', INK, 'var(--fs-bar-bg)', _('in the sidebar', 'footstrap') ], + [ _('Borders', 'footstrap'), axes.line, 'var(--fs-border)', 'var(--fs-border)', CARD_BG, ON_CARD, 'shape' ] + ].map(([ label, axis, probe, fg, bg, where, kind ]) => + colourGroup(label, axis, probe, { fg, bg, label: where, kind })) + ]; + + /* ---- section 3: the wallpaper and the rows each value brings ---- + * + * Wallpaper is three-valued: Off, Pattern (an uploaded SVG, tiled and recoloured) and File (an + * uploaded photo). The rows a value brings are SIBLINGS of the Wallpaper row, not children of + * its field: nesting a `.cbi-value` inside a `.cbi-value-field` puts a second caption column + * inside the first, and those controls started 216px right of every other one on the page, + * measured on the router. Flat rows hidden as a group is what the stock pages do with a + * dependent field. + * + * The select is the per-browser switch deciding whether to paint an image, so it is what keeps + * the Save button honest; Choose/Remove only swap the picture and never touch the axis. The + * native file inputs stay hidden — the styled buttons trigger them. */ + const wallpaper = (() => { + const err = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' }); + const preview = E('img', { 'class': 'fs-ap-bgprev', 'alt': '', 'hidden': '' }); + /* display:none, not `hidden`: a bare `hidden=""` still renders the native + * "Choose File / No file chosen" control */ + const fileInput = E('input', { 'type': 'file', 'accept': 'image/*', 'style': 'display:none' }); + const chooseLabel = _('Choose image', 'footstrap'); + const chooseBtn = E('button', { 'class': 'btn cbi-button', 'type': 'button' }, [ chooseLabel ]); + const removeBtn = E('button', { 'class': 'btn cbi-button-remove', 'type': 'button', 'hidden': '' }, [ _('Remove', 'footstrap') ]); + + const patErr = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' }); + const patPreview = E('img', { 'class': 'fs-ap-bgprev', 'alt': '', 'hidden': '' }); + const patInput = E('input', { 'type': 'file', 'accept': 'image/svg+xml,.svg', 'style': 'display:none' }); + const patChooseLabel = _('Choose SVG', 'footstrap'); + const patChoose = E('button', { 'class': 'btn cbi-button', 'type': 'button' }, [ patChooseLabel ]); + const patRemove = E('button', { 'class': 'btn cbi-button-remove', 'type': 'button', 'hidden': '' }, [ _('Remove', 'footstrap') ]); + + /* Dim: the scrim opacity over the photo. An ordinary per-browser axis — it is in AXIS_KEYS + * and snapshotAxes(), so it moves this browser toward or away from the router default and + * must be bump()-ed like every other saved axis, or the Save button misreports its own + * status. Separate from the Tint strength above. */ + const dimLabel = _('Dim', 'footstrap'); + const scaleLabel = _('Scale', 'footstrap'); + const strengthLabel = _('Strength', 'footstrap'); + const inkLabel = _('Colours', 'footstrap'); + + /* The rows the pattern brings. Scale and Strength are live: the appliers write a custom + * property, so the tile resizes and fades under the drag. Colours decides whether the + * file's own palette is kept or replaced by the theme's — a mask uses the alpha only, + * which is right for line art and wrong for artwork that carries its own colours. */ + const patRows = [ + group(_('Pattern', 'footstrap'), + () => E('div', { 'class': 'fs-ap-bgrow' }, [ patChoose, patRemove ]), + { extra: [ patInput, patPreview, patErr ] }), + group(scaleLabel, (lbl) => sliderCtl(axes.patternSize.current(), 40, 1600, + bump(axes.patternSize.apply), lbl, { step: 20 })), + group(strengthLabel, (lbl) => sliderCtl(axes.patternStrength.current(), 0, 100, + bump(axes.patternStrength.apply), lbl, { step: 5 })), + group(inkLabel, (lbl) => selectCtl(axes.patternInk.current(), { + theme: _('Theme', 'footstrap'), + original: _('As in file', 'footstrap') + }, bump(axes.patternInk.apply), lbl)) + ]; + /* …and the rows the FILE photo brings. */ + const fileRows = [ + group(_('File', 'footstrap'), + () => E('div', { 'class': 'fs-ap-bgrow' }, [ chooseBtn, removeBtn ]), + { extra: [ fileInput, preview, err ] }), + group(dimLabel, (lbl) => sliderCtl(axes.photoDim.current(), 0, 100, + bump(axes.photoDim.apply), lbl, { step: 5 })) + ]; + + function reflect(tok) { + if (tok) { preview.src = axes.loginBg.url(tok); preview.hidden = false; removeBtn.hidden = false; } + else { preview.removeAttribute('src'); preview.hidden = true; removeBtn.hidden = true; } + } + function reflectPattern(tok) { + if (tok) { patPreview.src = axes.pattern.url(tok); patPreview.hidden = false; patRemove.hidden = false; } + else { patPreview.removeAttribute('src'); patPreview.hidden = true; patRemove.hidden = true; } + } + /* `hidden` on the row, which 80-appearance.css restates at a specificity beating + * `.cbi-value`'s own display (the UA's bare `[hidden]` rule loses to it). Hidden, not + * removed: each row holds a live control, so rebuilding on every switch would freeze it to + * the state it was constructed in. */ + function togglePanel(v) { + patRows.forEach((r) => { r.hidden = (v !== 'pattern'); }); + fileRows.forEach((r) => { r.hidden = (v !== 'file'); }); + } + reflect(axes.loginBg.current()); + reflectPattern(axes.pattern.current()); + togglePanel(axes.wallpaper.current()); + + const setWallpaper = (v) => { axes.wallpaper.apply(v); refreshSave(); togglePanel(v); refreshColours(); }; + + /* Both uploads present the same three controls and the same four states — pick, upload, + * report, remove — so the wiring is stated once. What differs is `after`: the pattern also + * has to move the Wallpaper dropdown, because the upload switched this browser onto the + * tile and the page would otherwise paint it while the control still read Off. + * + * The file input is cleared on every change so re-picking the SAME file fires `change` + * again, and the button carries its own busy state: the label is restored in `finally`, or + * a failed upload leaves "Uploading…" standing for the life of the form. */ + const wireUploader = (u) => { + const fail = (e) => { u.err.textContent = String((e && e.message) || e); u.err.hidden = false; }; + u.choose.addEventListener('click', () => { u.err.hidden = true; u.input.click(); }); + u.input.addEventListener('change', () => { + const f = u.input.files && u.input.files[0]; + u.input.value = ''; + if (!f) return; + u.err.hidden = true; u.choose.disabled = true; + u.choose.textContent = _('Uploading…', 'footstrap'); + u.upload(f) + .then((tok) => { u.reflect(tok); if (u.after) u.after(tok); }) + .catch(fail) + .finally(() => { u.choose.disabled = false; u.choose.textContent = u.label; }); + }); + u.remove.addEventListener('click', () => { + u.err.hidden = true; u.remove.disabled = true; + u.drop().then(() => u.reflect('')).catch(fail) + .finally(() => { u.remove.disabled = false; }); + }); + }; + + wireUploader({ + choose: patChoose, remove: patRemove, input: patInput, err: patErr, + label: patChooseLabel, reflect: reflectPattern, + upload: assets.uploadPattern, drop: assets.removePattern, + /* `dom.callClassMethod` is how LuCI moves its own widgets from outside; setWallpaper is + * then called directly, because a programmatic setValue emits no `widget-change`. */ + after: () => { dom.callClassMethod(seg, 'setValue', 'pattern'); setWallpaper('pattern'); } + }); + + wireUploader({ + choose: chooseBtn, remove: removeBtn, input: fileInput, err: err, + label: chooseLabel, reflect: reflect, + upload: assets.uploadLoginBg, drop: assets.removeLoginBg + }); + + let seg; + const wallRow = group(_('Wallpaper', 'footstrap'), (label) => { + seg = selectCtl(axes.wallpaper.current(), { + off: _('Off', 'footstrap'), + pattern: _('Pattern', 'footstrap'), + file: _('File', 'footstrap') + }, setWallpaper, label); + return seg; + }); + + return [ wallRow ].concat(patRows, fileRows); + })(); + + /* ---- section 4: the router default and the version ---- + * + * Save the current look as the router-wide default (fs-prefs writes /etc/config/footstrap over + * the scoped uci ACL). It does not change this browser — localStorage keeps overriding — so the + * saved default only shows on a fresh browser. The two Reset buttons below are the escape + * hatches, and they do not land in the same place. */ + /* the button/status label said three times below, inside this one build() call — hoisting the + * RESULT, not the msgid, so update-po.sh still sees the literal `_()` argument elsewhere + * (measured: 22 B x3 -> 27 B, 39 B saved) */ + const SAVE_TO_ROUTER = _('Save to router', 'footstrap'); + /* said twice below, same build() call (measured: 24 B x2 -> 30 B, 18 B saved) */ + const RESET_TO_ROUTER = _('Reset to router', 'footstrap'); + const saveBtn = E('button', { 'class': 'btn cbi-button-action', 'type': 'button' }, [ SAVE_TO_ROUTER ]); + /* Two resets, because two things sit underneath a browser's tweaks (fs-prefs.js): "Reset to + * router" clears them and lets every axis fall back to whatever the router holds, while "Reset + * to built-in" writes the theme's built-ins explicitly — the only way to say "as the theme + * ships" on a router that has a look of its own. Neither touches /etc/config/footstrap. + * + * ONE word per state, and "default" is not one of them: it used to name the router's look in + * "Save as default" and the theme's in "Reset to default", while the router's look also + * answered to "saved" — three names for two states, asked about on the forum (topic 251930, + * post 92). The row now reads as one save and two resets, over `router` and `built-in`. */ + const resetSavedBtn = E('button', { 'class': 'btn', 'type': 'button' }, [ RESET_TO_ROUTER ]); + /* the stock destructive class, so the button discarding every local tweak is the red one + * (theme/55-buttons.css). "Reset to router" stays neutral: it steps back to the shared state + * rather than discarding. */ + const resetBtn = E('button', { 'class': 'btn cbi-button-negative', 'type': 'button' }, [ _('Reset to built-in', 'footstrap') ]); + /* Save's only visible failure surface. The realistic failure is the rpc rejecting — an expired + * session (403), a missing ACL, ubus down. A DELETED config is not caught: rpcd stages the set + * in the session and commit then no-ops without writing the file, returning success (measured + * on the router). The package owns that file and the read side falls back to built-in + * defaults. */ + const saveErr = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' }); + + /* The Save button is the status: matching disables it, diverging enables it. Called after every + * axis change (via bump). + * + * Unless the browser refuses storage, where the comparison would always be true — nothing was + * written, so every current*() reads the router default back however far the page has been + * dragged from it. Say so instead, and leave the button enabled: pushing this browser's look to + * the router is the one thing that still works. */ + function refreshSave() { + if (prefs.storageBroken()) { + saveBtn.disabled = false; + saveBtn.textContent = SAVE_TO_ROUTER; + saveErr.textContent = _('This browser is not storing preferences (site data is blocked), so a change here lasts until you reload. Saving as default still works and applies to every browser.', 'footstrap'); + saveErr.hidden = false; + return; + } + const saved = axes.matchesSavedDefault(); + saveBtn.disabled = saved; + saveBtn.textContent = saved ? _('Saved to router', 'footstrap') : SAVE_TO_ROUTER; + } + saveBtn.addEventListener('click', () => { + saveBtn.disabled = true; + saveErr.hidden = true; + axes.saveAsDefault() + .then(() => { saveErr.hidden = true; }) + /* on failure refreshSave re-enables the button so the user can retry; the usual cause + * is a stale session, which a reload fixes. The raw rpc error stays in a title + * tooltip. */ + .catch((e) => { + saveErr.textContent = _('Could not save the default. Reload the page and try again.', 'footstrap'); + saveErr.title = String((e && e.message) || e); + saveErr.hidden = false; + }) + .finally(refreshSave); + }); + /* Two-click confirm on both: discarding local tweaks is destructive and a native confirm() is + * banned in this UI. Arming one disarms the other, so a primed button cannot be fired by a + * click meant for its neighbour. Each reload lands back on this same page — an ordinary route, + * unlike the tab this once needed a sessionStorage flag to return to. */ + const armed = new Map(); + function disarm(btn, label) { + armed.delete(btn); + btn.textContent = label; + btn.classList.remove('fs-ap-armed'); + } + function twoClick(btn, label, run) { + btn.addEventListener('click', () => { + if (!armed.has(btn)) { + [ ...armed.keys() ].forEach((other) => disarm(other, armed.get(other))); + armed.set(btn, label); + btn.textContent = _('Confirm reset', 'footstrap'); + btn.classList.add('fs-ap-armed'); + return; + } + disarm(btn, label); + run(); + location.reload(); + }); + } + twoClick(resetSavedBtn, RESET_TO_ROUTER, axes.resetToSaved); + twoClick(resetBtn, _('Reset to built-in', 'footstrap'), axes.resetToBuiltin); + refreshSave(); /* correct label and enabled state before the first paint */ + + const versionLink = E('a', { + 'class': 'fs-ap-version', + 'href': ver.REPO_URL, + 'target': '_blank', + /* `noreferrer` alone: it implies noopener, and the theme's other outward links spell it + * that way */ + 'rel': 'noreferrer' + }, [ ver.label() ]); + + /* One clause per button, in LuCI's own help-sentence idiom and under the buttons themselves, + * because that is where the question is asked: with all three named "save"/"reset" and two of + * them resets, the row does not say which state each lands in (forum topic 251930, post 90 — + * "what is the difference on 'reset to saved' and 'reset to default'?"). Three lines rather + * than a paragraph: three parts want a list, and each is read on its own. + * + * Text nodes separated by
, NOT a child per line: `.cbi-value-field *` in + * theme/60-inputs.css hands mono to every descendant and excludes `.cbi-value-description` + * itself, so a wrapped line becomes a descendant that the exclusion does not reach — measured, + * all three lines came out monospace. Text nodes inherit the sans face from the container, and + * one container also means one `?` glyph with every line aligned under it. */ + const buttonHelp = E('div', { 'class': 'cbi-value-description' }, [ + _('Save to router — store this look on the router. Browsers with a look of their own keep it.', 'footstrap'), + E('br'), + _('Reset to router — drop this browser\'s changes and follow the router.', 'footstrap'), + E('br'), + _('Reset to built-in — go back to the look the theme ships with.', 'footstrap') + ]); + + const defaults = [ + /* the one row whose control is a pair of buttons, each named by its own text, so `make` + * ignores the caption rather than re-using it as an aria-label */ + group(_('Saved look', 'footstrap'), + () => E('div', { 'class': 'fs-ap-actrow' }, [ saveBtn, E('span', { 'class': 'fs-ap-actgap', 'aria-hidden': 'true' }), resetSavedBtn, resetBtn ]), + { extra: [ buttonHelp, saveErr ] }) + ]; + + + /* ---- the catalogue this router has not got ---- + * + * The theme's translations are their own packages since 0.14.4, the way every `luci-app-*` + * ships them, and nothing in a package manager can read `uci luci.main.lang` to fetch the right + * one: apk learns it from `install-if` against `luci-i18n-base-` (owfeed.yml), opkg has no + * conditional form of that at all, and a router upgraded from 0.14.3 through the feed simply + * loses the catalogue that used to ride inside the theme (issue #41). `install.sh` covers its + * own path; this covers the one nobody ran a script on. + * + * ASKED OF THE PAGE, not of the package list: the theme has no ubus call of its own and must not + * grow one for this. `_()` returns its argument unchanged when no catalogue answers, so asking + * for a string the catalogue certainly carries is the whole test. + * + * `Layout` is that string — a caption this very form renders, present in every catalogue under `po/`. A word + * that only LOOKS certain is worse than no check: `Appearance` is in the source but obsolete in + * the catalogues (`#~ msgid`), so testing it reported "not translated" on a router whose + * Russian catalogue was installed and working. + * + * The language comes from the document, not from `L.env`, which carries no language field at + * all — the dispatcher stamps `` and that is what the page knows. + * + * Nothing is shown on an English or `auto` router, where there is no catalogue to miss. */ + const lang = (document.documentElement.getAttribute('lang') || '').trim(); + const untranslated = lang && lang !== 'en' && lang !== 'auto' && + _('Layout', 'footstrap') === 'Layout'; + const missing = untranslated ? E('div', { 'class': 'fs-ap-verrow fs-ap-i18n' }, [ + E('span', {}, [ _('This theme is not translated on this router yet.', 'footstrap') + ' ' ]), + E('code', {}, [ 'luci-i18n-footstrap-' + lang ]) + ]) : ''; + + defaults.push(E('div', { 'class': 'fs-ap-footer' }, [ + E('div', { 'class': 'fs-ap-verrow' }, [ versionLink ]), + missing + ])); + + /* not .cbi-section: inside a tab pane that is a card within a card, and the stock tabs put + * their rows straight into the pane. These are grouping headings within one pane, styled by + * pages/80-appearance.css. */ + const section = (title, rows) => E('div', { 'class': 'fs-ap-section' }, [ + E('div', { 'class': 'fs-ap-head' }, [ E('h4', {}, [ title ]) ]) + ].concat(rows)); + + /* ---- the folded groups ---- + * Nine colour fields and an uploader are the widest rows on the page and most admins never + * touch them, so each group is a disclosure, closed by default. + * + * A disclosure and not a switch: a switch answers "is this feature on", and turning it off + * would either revert nine colours or change nothing at all. Opening or closing a fold applies, + * un-applies and disables nothing. + * + * W3C APG disclosure pattern, as the menu's sections use: a