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..ae7a6461 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..974777ad --- /dev/null +++ b/luci-theme-footstrap/htdocs/luci-static/footstrap/manifest.json @@ -0,0 +1,15 @@ +{ + "name": "LuCI", + "start_url": "/cgi-bin/luci/", + "display": "standalone", + "background_color": "#f6f8fa", + "theme_color": "#f6f8fa", + "icons": [ + { + "src": "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..eecfe041 --- /dev/null +++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-appearance.js @@ -0,0 +1,639 @@ +'use strict'; +'require baseclass'; +'require ui'; +'require fs-prefs as prefs'; +'require fs-axes as axes'; +'require fs-router as router'; +'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 and + * fs-axes.js hold the axes and fs-version.js the version string. `view/footstrap/appearance.js` + * calls `renderStandalone()`, the whole surface this file ships. + * + * 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 readout. Two questions no stored value answers: what colour a role is right now (the + * palette's own while the axis is off) and what contrast it lands at. */ +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. Every declaration is !important: an app's CSS can style a + * plain
. */ + _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)` parses as three numbers in the wrong units + * (measured: #010078, graded "Too faint to read"). Painting one pixel makes the engine convert. */ +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); +} + +function toHex(s) { + const rgb = parseColor(s) || [ 0, 0, 0 ]; + return '#' + rgb.map((v) => 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". */ +function colorControl(onPick, label, o) { + 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; + + let typed = false; + + /* 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; typed = false; } + /* 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. */ + 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. An untouched field returns first, so a + * bare Tab-through never pins the shown palette colour on any of the 9 axes. */ + const commit = () => { + if (!typed) return; + 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('input', () => { typed = true; }); + 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' }, [ + 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; +} + +/* Every stamp that moves a colour lands on :root, including head.ut's OS listener and an app + * rewriting data-darkmode, neither of which calls back here. */ +let _watch = null; +function unwatchColours() { + if (_watch) _watch.disconnect(); + _watch = null; +} +router.onNavigate(unwatchColours); +function watchColours(refresh) { + unwatchColours(); + _watch = new MutationObserver(refresh); + _watch.observe(document.documentElement, { + attributes: true, attributeFilter: [ 'data-darkmode', 'data-palette', 'data-wallpaper' ] + }); +} + +/* Build the whole form; renderStandalone() hands it to the page. Everything applies immediately and + * there is nothing to save: every axis is this browser's, in localStorage. Only "Save to router" + * writes anything, pushing the current look to the router for other browsers. */ +function build() { + /* 24.10.0-24.10.2 have no ui.RangeSlider and a package from the feed still installs there (opkg does + * not version luci-base); one readable line beats the raw "not a constructor" TypeError. */ + if (!ui.RangeSlider) + return E('p', { 'class': 'alert-message warning' }, [ _('Footstrap needs OpenWrt 24.10.3 or newer.', 'footstrap') ]); + + /* 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()); + const repaint = (fn) => (v) => { fn(v); refreshColours(); }; + + /* One captioned row in LuCI's own shape: `.cbi-value` > `label.cbi-value-title` + + * `.cbi-value-field`, which base/30-forms.css and theme/60-inputs.css already lay out. `make` is + * handed the caption because each control needs it again as the aria-label of its inner + * input/select (a stock widget's wrapper div cannot carry a name). `extra` carries rows with more + * than a control, `opts.cls` marks the rows CSS singles 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'); + + /* Every enum axis is a `ui.Select` and every number a `ui.RangeSlider`, already dressed by + * base/30-forms.css and theme/60-inputs.css (`ui.RangeSlider` reached openwrt-24.10 in 24.10.3). Listen + * for `widget-change`, the seam UIElement publishes, not an event on the inner element. + * RangeSlider also emits `widget-update` while the handle moves, which resizes the page under the + * drag; the appliers are idempotent, so wiring both costs nothing. */ + const selectCtl = (current, choices, apply, label) => { + /* an array of [value, label] pairs when a key is integer-like: Object.keys hoists it ahead of + * the authored order ('2020' rendered above Footstrap, the default); `sort` pins the order */ + const pairs = Array.isArray(choices) ? choices : Object.entries(choices); + const w = new ui.Select(String(current), Object.fromEntries(pairs), { widget: 'select', sort: pairs.map((p) => p[0]) }); + const node = w.render(); + node.querySelector('select, input').setAttribute('aria-label', label); + node.addEventListener('widget-change', () => apply(w.getValue())); + return node; + }; + + const sliderCtl = (axis, apply, label, opts) => { + const o = opts || {}; + const w = new ui.RangeSlider(String(axis.current()), { + min: axis.min, max: axis.max, step: o.step || 1 + }); + const node = w.render(); + node.querySelector('select, input').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 + * page is drawn in, so applying mid-drag slides the handle from under the pointer and one small + * drag runs to the maximum. A keyboard user still applies at once (an arrow key fires `change`). */ + if (o.live !== false) node.addEventListener('widget-update', push); + node.addEventListener('widget-change', push); + return node; + }; + + const colourGroup = (label, axis, probe, contrast, opts = {}) => group(label, (lbl) => { + const ctl = colorControl(bump(axis.apply), lbl, { + probe: probe, + read: axis.current, + contrast: contrast + }); + colourCtls.push(ctl); + return ctl; + }, opts); + + /* Every label here carries the 'footstrap' context: LuCI serves one merged catalogue and the first + * archive holding the hash wins, so a bare msgid is a global name any luci-app can win. */ + + /* ---- 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(prefs.applyMode), label)), + + group(_('Palette', 'footstrap'), (label) => selectCtl(axes.palette.current(), [ + [ 'footstrap', 'Footstrap' ], + [ 'hicontrast', 'Hi-Contrast' ], + [ 'bootstrap', 'Bootstrap' ], + /* names another package, luci-theme-openwrt-2020, whose colourway this is */ + [ '2020', 'OpenWrt 2020' ], + [ 'forum', 'Forum' ] + ], bump(axes.palette.apply), label)), + + group(_('Density', 'footstrap'), (label) => selectCtl(axes.density.current(), { + compact: _('Compact', 'footstrap'), + normal: _('Normal', 'footstrap'), + large: _('Large', 'footstrap') + }, bump(axes.density.apply), label)), + + /* the content column's own cap, separate from Density. The slider starts at the 1280px every + * page was designed against and only widens, which also keeps --fs-content-min (500px, what + * the sidebar-to-bar fold is measured against) out of reach by construction. */ + group(_('Content width', 'footstrap'), + (label) => sliderCtl(axes.contentWidth, bump(axes.contentWidth.apply), label, { step: 40, live: false })), + + group(_('Rounding', 'footstrap'), + (label) => sliderCtl(axes.radius, bump(axes.radius.apply), label)), + + /* always built and hidden by CSS in the top layout (:root[data-layout="top"] .fs-ap-submenus): + * the page is built once, so a currentLayout() branch would freeze it 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 = [ + colourGroup(_('Tint (router identification)', 'footstrap'), axes.tint, 'var(--fs-bg)', { + 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 has no chroma of + * ours to scale); CSS hides it otherwise. Not named "Density": the string is also the + * aria-label, so a screen reader would announce two rows under one name. */ + group(_('Tint strength', 'footstrap'), + (label) => sliderCtl(axes.tintStrength, bump(repaint(axes.tintStrength.apply)), label, { + step: 5 + }), { cls: 'fs-ap-tint fs-ap-tintstr' }), + + /* Accent recolours the accented controls (buttons, toggles, sliders, focus rings), not the + * canvas; any #rrggbb is taken. Four roles, one shape: the role's colour read as text on a + * card, the use that fails first (as a fill it carries derived ink). The rows are data + * because a string literal repeated eight times is not mangled by the minifier. */ + ...[ + [ _('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. Body text is read on each, so each reports + * --fs-text against itself; no ink is derived, since moving --fs-text would recolour what is + * measured against. The hairline takes the 3:1 UI-component threshold (a border is a shape) and + * the readout states the number and leaves the call to the admin. */ + const surfaces = [ + ...[ + [ _('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 ---- + * The rows a value brings are siblings of the Wallpaper row: nested in its `.cbi-value-field` + * they started 216px right of every other control (measured on the router). They are hidden as + * a group, as stock pages do with a dependent field. The select is the per-browser switch that + * keeps the Save button honest; Choose/Remove only swap the picture and never touch the axis. */ + const REMOVE = _('Remove', 'footstrap'); + const wallpaper = (() => { + const err = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' }); + const preview = E('img', { 'class': 'fs-ap-bgprev', 'alt': '', 'hidden': '' }); + 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 ]); + + 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 ]); + + /* Dim is an ordinary saved axis, so it is bump()-ed or the Save button misreports its status */ + const dimLabel = _('Dim', 'footstrap'); + const scaleLabel = _('Scale', 'footstrap'); + const strengthLabel = _('Strength', 'footstrap'); + const inkLabel = _('Colours', 'footstrap'); + + /* Colours decides whether the file's own palette is kept or replaced by the theme's: a mask + * uses the alpha only, right for line art and wrong for artwork with 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, bump(axes.patternSize.apply), lbl, { step: 20 })), + group(strengthLabel, (lbl) => sliderCtl(axes.patternStrength, 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)) + ]; + const fileRows = [ + group(_('File', 'footstrap'), + () => E('div', { 'class': 'fs-ap-bgrow' }, [ chooseBtn, removeBtn ]), + { extra: [ fileInput, preview, err ] }), + group(dimLabel, (lbl) => sliderCtl(axes.photoDim, 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 base/10-reset.css restates with !important (the UA's bare + * `[hidden]` loses to `.cbi-value`'s display). Hidden, not removed: each row holds a live + * control that a rebuild would freeze to its construction-time state. */ + 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); }; + + /* Both uploads share one wiring (pick, upload, report, remove) and never move the Wallpaper + * dropdown, which the user may change mid-upload. The input is cleared on every change so + * re-picking the same file fires `change`; the label is restored in `finally`, or a failed + * upload leaves "Uploading…" standing. */ + 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(u.reflect) + .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 + }); + + wireUploader({ + choose: chooseBtn, remove: removeBtn, input: fileInput, err: err, + label: chooseLabel, reflect: reflect, + upload: assets.uploadLoginBg, drop: assets.removeLoginBg + }); + + const wallRow = group(_('Wallpaper', 'footstrap'), (label) => selectCtl(axes.wallpaper.current(), { + off: _('Off', 'footstrap'), + pattern: _('Pattern', 'footstrap'), + file: _('File', 'footstrap') + }, setWallpaper, label)); + + return [ wallRow ].concat(patRows, fileRows); + })(); + + /* ---- section 4: the router default and the version ---- + * Save writes the current look to /etc/config/footstrap over the scoped uci ACL. It does not + * change this browser (localStorage keeps overriding), so it only shows on a fresh browser. */ + /* each label is said twice in build(); hoisted, a msgid is paid once (24 B x2 -> 30 B) */ + const SAVE_TO_ROUTER = _('Save to router', 'footstrap'); + const RESET_TO_ROUTER = _('Reset to router', 'footstrap'); + const RESET_TO_BUILTIN = _('Reset to built-in', '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: "Reset to router" clears them + * and lets every axis fall back to the router, "Reset to built-in" writes the theme's defaults + * explicitly. Neither touches /etc/config/footstrap. "Default" is not a label: it named the + * router's look in "Save as default" and the theme's in "Reset to default", three names for two + * states (forum topic 251930, post 92). */ + const resetSavedBtn = E('button', { 'class': 'btn', 'type': 'button' }, [ RESET_TO_ROUTER ]); + const resetBtn = E('button', { 'class': 'btn cbi-button-negative', 'type': 'button' }, [ RESET_TO_BUILTIN ]); + /* Save's only visible failure surface: the rpc rejecting (expired session 403, missing ACL, ubus + * down). A deleted config is not caught: commit no-ops and returns 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. A browser that + * refuses storage keeps its choices in fs-prefs's in-memory map, so the comparison stays honest; + * the note waits while a failed Save's error shows, or the .finally below would overwrite it. */ + function refreshSave() { + if (prefs.storageBroken() && saveErr.hidden) { + saveErr.textContent = _('This browser is not storing preferences (site data is blocked), so a change here lasts until you reload. Saving to the router still works and applies to every browser.', 'footstrap'); + saveErr.hidden = false; + } + 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; + saveErr.title = ''; /* a stale rpc error would tooltip the storage note (measured: 'ubus code 6' over it) */ + axes.saveAsDefault() + /* 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 to the router. 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. */ + let armed = null; + function disarm() { + if (!armed) return; + armed.btn.textContent = armed.label; + armed.btn.classList.remove('fs-ap-armed'); + armed = null; + } + function twoClick(btn, label, run) { + btn.addEventListener('click', () => { + if (armed && armed.btn === btn) { + disarm(); + run(); + location.reload(); + return; + } + disarm(); + armed = { btn: btn, label: label }; + btn.textContent = _('Confirm reset', 'footstrap'); + btn.classList.add('fs-ap-armed'); + }); + } + twoClick(resetSavedBtn, RESET_TO_ROUTER, axes.resetToSaved); + twoClick(resetBtn, RESET_TO_BUILTIN, 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() ]); + + 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 theme's translations are their own packages, and nothing in a package manager can read `uci + * luci.main.lang` to fetch the right one. */ + 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 + ])); + + const section = (title, rows) => E('div', { 'class': 'fs-ap-section' }, [ + E('div', { 'class': 'fs-ap-head' }, [ E('h2', {}, [ title ]) ]) + ].concat(rows)); + + /* Folded groups: nine colour fields and an uploader are the widest rows and rarely touched, so + * each group is a closed-by-default disclosure that applies and disables nothing. */ + let foldSeq = 0; + function foldable(title, rows, key) { + const id = 'fs-ap-fold-' + (++foldSeq); + let open = (prefs.lsGet(key) === 'on'); + /* id only: `aria-controls` needs one, and no rule has ever styled the panel itself */ + const body = E('div', { 'id': id }, rows); + const btn = E('button', { + 'type': 'button', 'class': 'fs-ap-fold', 'aria-expanded': String(open), 'aria-controls': id + }, [ + title, + E('span', { 'class': 'fs-ap-chev', 'aria-hidden': 'true' }) + ]); + const paint = () => { + body.hidden = !open; + btn.setAttribute('aria-expanded', String(open)); + }; + btn.addEventListener('click', () => { + open = !open; + prefs.lsSet(key, open ? 'on' : 'off'); + paint(); + if (open) refreshColours(); + }); + paint(); + return E('div', { 'class': 'fs-ap-section' }, [ + E('div', { 'class': 'fs-ap-head' }, [ E('h2', {}, [ btn ]) ]), body + ]); + } + + const note = E('div', { 'class': 'alert-message fs-ap-note' }, [ + E('p', {}, [ _('Footstrap theme settings apply at once and are stored permanently in this browser.', 'footstrap') ]) + ]); + + const page = E('div', { 'class': 'fs-ap' }, [ + note, + section(_('Interface', 'footstrap'), shell), + foldable(_('Colours', 'footstrap'), + colours.concat([ E('div', { 'class': 'fs-ap-head fs-ap-sub' }, [ E('h3', {}, [ _('Surfaces', 'footstrap') ]) ]) ], surfaces), + 'fs-ui-colours'), + foldable(_('Background', 'footstrap'), wallpaper, 'fs-ui-background'), + section(_('Saving', 'footstrap'), defaults) + ]); + + Promise.resolve().then(refreshColours); + + watchColours(refreshColours); + return page; +} + +return baseclass.extend({ + /* the dispatched page's whole render(), called by view/footstrap/appearance.js */ + renderStandalone: () => Promise.resolve().then(build), + + /* test-only: the observer's lifetime is a navigation, which a test can fire and cannot reach + * through build() */ + watchColours, /* fs:probe */ +}); diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-assets.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-assets.js new file mode 100644 index 00000000..8238907c --- /dev/null +++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-assets.js @@ -0,0 +1,294 @@ +'use strict'; +'require baseclass'; +'require rpc'; +'require ui'; +'require fs-axes as axes'; + +/* fs-assets — putting a file on the router and taking it off again: the pattern and login-photo uploads, + * the SVG sanitizer, the rollback. A module of its own: only the Appearance page needs these ~4 KB. */ + +/* Hoisted: a string literal is not mangled, so each repeat is paid on flash; msgid stays literal + * for update-po.sh. */ +const MSG_UPLOAD_FAILED = _('Upload failed.', 'footstrap'); +const MSG_NOT_SVG = _('That file is not an SVG image.', 'footstrap'); +const MSG_BAD_IMAGE = _('Could not process the image.', 'footstrap'); +const MSG_PICK_SVG = _('Please choose an SVG file.', 'footstrap'); +/* One template, two numbers: a translator places both %d in one sentence instead of agreeing + * plurals. */ +const MSG_SANITIZED = _('Cleaned before upload: removed %d unsafe element(s) and %d external reference(s).', 'footstrap'); +const MSG_NOTHING_LEFT = _('That SVG had nothing left once the unsafe parts were removed.', 'footstrap'); + +/* ---- the pattern: an SVG the admin uploads, tiled and recoloured ---- + * Fixed path matched by the rpcd ACL, under /etc so an upgrade keeps it. Not the security boundary: a + * `curl` to cgi-upload skips every check below; a direct open is stopped by the CGI's + * `Content-Security-Policy: default-src 'none'; sandbox`. */ +const PAT_PATH = '/etc/footstrap/pattern.svg'; /* cgi-upload target; the ACL grants exactly this */ +const PAT_MAX = 512 * 1024; /* a tile that has to reach a router's flash and then every page load */ +/* What `_sanitizeSvg` removes, judged on the parsed document, not the text. Painted as an image nothing + * loads; these rules cover a direct open of the file: + * - elements: only the SVG namespace stays (`` ran same-origin), minus + * script, foreignObject, a ` + {% endif %} + + + {# + Lets an admin install this page as an app (icon, own window, no address bar), worth more + than a tab on the phone LuCI is opened from when the network is broken. Both files are + static, a theme having no dispatcher node to render them per request: start_url is + /cgi-bin/luci/, uhttpd's default (a moved LuCI opens the standard path), and + background/theme_color is the default palette's page colour, read once at install time. + .json, not .webmanifest: uhttpd's compiled-in MIME map has no entry for that extension, + so it answers application/octet-stream. + #} + + {# + iOS reads this link and never the manifest's icons array, and it scales the 192 square the + manifest also uses down to its own 180. One raster serves both. + #} + + {% if (dispatched?.css): %} + {# + The `css` property of the dispatched menu.d node: a stylesheet the server links for the + page instead of the view injecting one at module eval. `dispatched`, not `node`: the + dispatcher exports the resolved node as `dispatched` and never binds `node`, so no ucode + theme emitted this link until openwrt/luci#8920. That commit also adds `css: 'string'` to + the node schema; without it the property is filtered out and this stays false, as on 24.10 + and 25.12. Versioned, as uhttpd sends no Cache-Control. Not `data-fs-shell`: the sheet + belongs to one page, so the SPA router must judge and contain it like any app sheet. + #} + + {% endif %} + {% if (css): %} + {# + no `title` attribute: on a + {% endif %} + {# + The catalogue is a CGI spawn on the critical path (30 ms on the router) that delivers 13 + bytes at lang=en, where no en catalogue exists because the msgids already are English. + The process is the cost, so emit those bytes inline when the language has no catalogue + and keep the tag when it has. The probe mirrors the server: load_catalog() reads every + `*..lmo` in /usr/lib/lua/luci/i18n, so a router shipping an en catalogue still gets + the tag; deciding by language NAME would drop it. Fails open: a throwing probe keeps the + tag, as a missing catalogue renders English and reports nothing. + #} + {%- let has_catalog = true; + try { has_catalog = length(require('fs').glob(`/usr/lib/lua/luci/i18n/*.${dispatcher.lang}.lmo`)) > 0; } catch (e) {} %} + {% if (has_catalog): %} + + {% else %} + + {% endif %} + {# + the one asset stock LuCI leaves without a cache key. Must stay synchronous and un-deferred: + luci.js captures `window.cbi_init` in its constructor, and `defer` is not an option for the + catalogue above either — footer.ut runs `L.require('menu-footstrap')` inline while the + parser is still running, so a module's `_()` would race a deferred window.TR. + #} + + diff --git a/luci-theme-footstrap/ucode/template/themes/footstrap/partials/notice.ut b/luci-theme-footstrap/ucode/template/themes/footstrap/partials/notice.ut new file mode 100644 index 00000000..c6227eab --- /dev/null +++ b/luci-theme-footstrap/ucode/template/themes/footstrap/partials/notice.ut @@ -0,0 +1,16 @@ +{# + One top-of-content warning notice: heading, sentence and an optional "go fix it" button. The + `dispatcher.lookup` guard must not drift: `path` is looked up before it is linked, so a build + without that page, or a user without rights to it, gets the warning and no dead link. + include(…, { title, text }) for a plain notice; add { path, label } for the button. + title/text are emitted unescaped: they are `_()` results over strings this theme ships, not + request data. + Licensed to the public under the Apache License 2.0. +-#} +
+

{{ title }}

+

{{ text }}

+ {% if (path && dispatcher.lookup(path)): %} + + {% endif %} +
diff --git a/luci-theme-footstrap/ucode/template/themes/footstrap/sysauth.ut b/luci-theme-footstrap/ucode/template/themes/footstrap/sysauth.ut new file mode 100644 index 00000000..f10e7cb7 --- /dev/null +++ b/luci-theme-footstrap/ucode/template/themes/footstrap/sysauth.ut @@ -0,0 +1,186 @@ +{# + The LOGIN page. + + Copyright 2008 Steven Barth + Copyright 2008-2012 Jo-Philipp Wich + Licensed to the public under the Apache License 2.0. + + It exists because the generic template includes the header WITHOUT `blank_page: true`: with no + theme-local sysauth the whole chrome renders around the login form with every control dead. + No departure here may drop a field the backend posts. + Do not re-introduce a `