mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-29 19:55:13 +08:00
🎨 Sync 2026-06-11 21:20:11
This commit is contained in:
1 parent
741d7b6a34
commit
b23950c545
19 files changed
+950
-420
No files matched your search
+4
-4
@@ -1,8 +1,8 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:= deepbot
|
||||
PKG_VERSION:= 1.0.0
|
||||
PKG_RELEASE:=1
|
||||
PKG_VERSION:= 0.0.2
|
||||
PKG_RELEASE:=2
|
||||
|
||||
include $(INCLUDE_DIR)/package.mk
|
||||
|
||||
@@ -15,9 +15,9 @@ define Package/deepbot
|
||||
endef
|
||||
|
||||
define Package/deepbot/description
|
||||
Telegram bot powered by DeepSeek AI.
|
||||
Telegram bot powered by Open AI models. DeepSeek default.
|
||||
Supports private and group chats, conversation history,
|
||||
daily token limits, SOCKS5/HTTP proxy.
|
||||
daily token limits, SOCKS5/HTTP proxy, white lists.
|
||||
Lua implementation from
|
||||
https://github.com/Bimaoitsuki/deepseek-telegram-bot-
|
||||
endef
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
config deepbot 'main'
|
||||
option token 'YOUR_BOT_TOKEN' # Telegrab BOT token (from @BotFather)
|
||||
option deepseek_key 'YOUR_DEEPSEEK_KEY' # DeepSeek API key
|
||||
option deepseek_url 'https://api.deepseek.com/v1/chat/completions' # DeepSeek API URL
|
||||
option deepseek_key 'YOUR_DEEPSEEK_KEY' # AI API key
|
||||
option deepseek_url 'https://api.deepseek.com/v1/chat/completions' # API URL
|
||||
option model 'deepseek-chat' # use AI model (e.g deepseek-chat or deepseek/deepseek-chat)
|
||||
option proxy '' # Proxy server (proxy_type://host:port[@username:passwd] see man curl)
|
||||
option admin_id '123456789' # Admin Telegram ID
|
||||
option whitelist_mode 'none' # Whitelist mode: all, private, chat, none
|
||||
option database '/usr/share/deepbot/chat_history.db' # SQLite3 database path
|
||||
option token_limit '10000' # Token linit per day
|
||||
option rate_limit '5' # Max replies per user in one min
|
||||
|
||||
@@ -7,32 +7,45 @@ USE_PROCD=1
|
||||
PROG="/usr/bin/deepbot.lua"
|
||||
|
||||
start_service() {
|
||||
local token deepseek_key deepseek_url proxy database \
|
||||
token_limit rate_limit group_mention_only debug
|
||||
local token deepseek_key deepseek_url proxy database \
|
||||
token_limit rate_limit group_mention_only debug \
|
||||
model admin_id whitelist_mode
|
||||
|
||||
config_load deepbot
|
||||
config_get token main token ''
|
||||
config_get deepseek_key main deepseek_key ''
|
||||
config_get deepseek_url main deepseek_url 'https://api.deepseek.com/v1/chat/completions'
|
||||
config_get proxy main proxy ''
|
||||
config_get database main database '/usr/share/deepbot/chat_history.db'
|
||||
config_get token_limit main token_limit '10000'
|
||||
config_get rate_limit main rate_limit '5'
|
||||
config_get group_mention_only main group_mention_only '1'
|
||||
config_get debug main debug '0'
|
||||
config_load deepbot
|
||||
config_get token main token ''
|
||||
config_get deepseek_key main deepseek_key ''
|
||||
config_get deepseek_url main deepseek_url 'https://api.deepseek.com/v1/chat/completions'
|
||||
config_get model main model 'deepseek-chat'
|
||||
config_get proxy main proxy ''
|
||||
config_get database main database '/usr/share/deepbot/chat_history.db'
|
||||
config_get token_limit main token_limit '10000'
|
||||
config_get rate_limit main rate_limit '5'
|
||||
config_get group_mention_only main group_mention_only '1'
|
||||
config_get debug main debug '0'
|
||||
config_get admin_id main admin_id ''
|
||||
config_get whitelist_mode main whitelist_mode 'none'
|
||||
|
||||
procd_open_instance
|
||||
procd_set_param command lua "$PROG"
|
||||
procd_set_param env \
|
||||
TELEGRAM_BOT_TOKEN="$token" \
|
||||
DEEPSEEK_API_KEY="$deepseek_key" \
|
||||
DEEPSEEK_API_URL="$deepseek_url" \
|
||||
PROXY_URL="$proxy" \
|
||||
DEBUG="$debug"
|
||||
procd_set_param stdout 1
|
||||
procd_set_param stderr 1
|
||||
procd_set_param respawn 30 5 0
|
||||
procd_close_instance
|
||||
procd_open_instance
|
||||
procd_set_param command lua "$PROG"
|
||||
procd_set_param env \
|
||||
TELEGRAM_BOT_TOKEN="$token" \
|
||||
DEEPSEEK_API_KEY="$deepseek_key" \
|
||||
DEEPSEEK_API_URL="$deepseek_url" \
|
||||
AI_MODEL="$model" \
|
||||
PROXY_URL="$proxy" \
|
||||
DATABASE_NAME="$database" \
|
||||
TOKEN_LIMIT_PER_DAY="$token_limit" \
|
||||
RATE_LIMIT="$rate_limit" \
|
||||
GROUP_MENTION_ONLY="$group_mention_only" \
|
||||
DEBUG="$debug" \
|
||||
ADMIN_ID="$admin_id" \
|
||||
WHITELIST_MODE="$whitelist_mode"
|
||||
procd_set_param stdout 1
|
||||
procd_set_param stderr 1
|
||||
procd_set_param respawn 30 5 0
|
||||
procd_close_instance
|
||||
|
||||
logger -t deepbot "Started with mode: $whitelist_mode, model: $model"
|
||||
}
|
||||
|
||||
service_triggers() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env lua
|
||||
-- DeepSeek Telegram Bot (Lua 5.1.5)
|
||||
-- AI Telegram Bot for OpenWrt (Lua 5.1.5)
|
||||
-- Config: /etc/config/deepbot
|
||||
-- Depends: curl lua luasocket lua-cjson lsqlite3 uci
|
||||
|
||||
@@ -7,7 +7,7 @@ local socket = require("socket")
|
||||
local json = require("cjson")
|
||||
local sqlite3 = require("lsqlite3")
|
||||
|
||||
-- ===== UCI КОНФИГ =====
|
||||
-- ===== UCI =====
|
||||
local function uci_get(key)
|
||||
local f = io.popen("uci -q get deepbot." .. key .. " 2>/dev/null")
|
||||
if not f then return nil end
|
||||
@@ -28,7 +28,6 @@ local function uci_get_int(key, default)
|
||||
end
|
||||
|
||||
-- ===== Config =====
|
||||
-- Prio: envilopment variables > UCI > default value
|
||||
local TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
|
||||
or uci_get("main.token")
|
||||
or "YOUR_BOT_TOKEN"
|
||||
@@ -38,21 +37,31 @@ local DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY")
|
||||
local DEEPSEEK_API_URL = os.getenv("DEEPSEEK_API_URL")
|
||||
or uci_get("main.deepseek_url")
|
||||
or "https://api.deepseek.com/v1/chat/completions"
|
||||
local AI_MODEL = os.getenv("AI_MODEL")
|
||||
or uci_get("main.model")
|
||||
or "deepseek-chat"
|
||||
local PROXY_URL = os.getenv("PROXY_URL")
|
||||
or uci_get("main.proxy")
|
||||
or ""
|
||||
local DATABASE_NAME = uci_get("main.database")
|
||||
local DATABASE_NAME = os.getenv("DATABASE_NAME")
|
||||
or uci_get("main.database")
|
||||
or "/usr/share/deepbot/chat_history.db"
|
||||
local TOKEN_LIMIT_PER_DAY = uci_get_int("main.token_limit", 10000)
|
||||
local RATE_LIMIT = uci_get_int("main.rate_limit", 5)
|
||||
local TOKEN_LIMIT_PER_DAY = tonumber(os.getenv("TOKEN_LIMIT_PER_DAY"))
|
||||
or uci_get_int("main.token_limit", 10000)
|
||||
local RATE_LIMIT = tonumber(os.getenv("RATE_LIMIT"))
|
||||
or uci_get_int("main.rate_limit", 5)
|
||||
local DEBUG = os.getenv("DEBUG") == "1"
|
||||
or uci_get_bool("main.debug", false)
|
||||
-- reply in chant only ask or @bot call
|
||||
local GROUP_MENTION_ONLY = uci_get_bool("main.group_mention_only", true)
|
||||
local GROUP_MENTION_ONLY = os.getenv("GROUP_MENTION_ONLY") == "1"
|
||||
or uci_get_bool("main.group_mention_only", true)
|
||||
local ADMIN_ID = os.getenv("ADMIN_ID")
|
||||
or uci_get("main.admin_id")
|
||||
or ""
|
||||
local WHITELIST_MODE = os.getenv("WHITELIST_MODE")
|
||||
or uci_get("main.whitelist_mode")
|
||||
or "none" -- none, all, private, group
|
||||
|
||||
local TG_API_BASE = "https://api.telegram.org/bot" .. TELEGRAM_BOT_TOKEN
|
||||
|
||||
-- bot_username defined after getMe
|
||||
local BOT_USERNAME = ""
|
||||
|
||||
-- ===== LOG =====
|
||||
@@ -108,27 +117,33 @@ local function curl_post(url, headers, body)
|
||||
end
|
||||
|
||||
-- ===== TELEGRAM API =====
|
||||
local function tg_call(method, params)
|
||||
local function tg_call(method, params, silent)
|
||||
silent = silent or false
|
||||
local body = params and json.encode(params) or "{}"
|
||||
dbg("tg_call " .. method .. " body: " .. body:sub(1, 200))
|
||||
if DEBUG then dbg("tg_call " .. method .. " body: " .. body:sub(1, 200)) end
|
||||
|
||||
local resp = curl_post(
|
||||
TG_API_BASE .. "/" .. method,
|
||||
{ ["Content-Type"] = "application/json" },
|
||||
body
|
||||
)
|
||||
if not resp or resp == "" then
|
||||
err("tg_call " .. method .. ": empty response")
|
||||
if not silent then err("tg_call " .. method .. ": empty response") end
|
||||
return nil
|
||||
end
|
||||
local ok, data = pcall(json.decode, resp)
|
||||
if not ok then
|
||||
err("tg_call " .. method .. " JSON error: " .. tostring(data))
|
||||
err("raw: " .. resp:sub(1, 300))
|
||||
if not silent then
|
||||
err("tg_call " .. method .. " JSON error: " .. tostring(data))
|
||||
if DEBUG then err("raw: " .. resp:sub(1, 300)) end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
if not data.ok then
|
||||
err("tg_call " .. method .. " API error: " ..
|
||||
tostring(data.description or json.encode(data)))
|
||||
if not silent then
|
||||
err("tg_call " .. method .. " API error: " ..
|
||||
tostring(data.description or json.encode(data)))
|
||||
end
|
||||
end
|
||||
return data
|
||||
end
|
||||
@@ -150,6 +165,7 @@ local function init_database()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_id ON conversations (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_timestamp ON conversations (timestamp);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS token_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
@@ -158,6 +174,14 @@ local function init_database()
|
||||
UNIQUE(user_id, date)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_token_usage ON token_usage (user_id, date);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS allowed_users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL UNIQUE,
|
||||
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
added_by INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_allowed_user_id ON allowed_users (user_id);
|
||||
]])
|
||||
if rc ~= sqlite3.OK then
|
||||
err("DB init error: " .. tostring(db:errmsg()))
|
||||
@@ -166,15 +190,40 @@ local function init_database()
|
||||
end
|
||||
end
|
||||
|
||||
local function validate_utf8(text)
|
||||
if not text then return "" end
|
||||
local clean = text:gsub("[^\128-\191]", function(c)
|
||||
if c:byte() < 128 then return c
|
||||
elseif c:byte() >= 194 and c:byte() <= 244 then return c
|
||||
else return "" end
|
||||
end)
|
||||
clean = clean:gsub("'", "''")
|
||||
return clean
|
||||
end
|
||||
|
||||
local function get_user_messages(user_id, limit)
|
||||
limit = limit or 10
|
||||
local messages = {}
|
||||
local stmt = db:prepare(
|
||||
"SELECT role, content FROM conversations WHERE user_id=? ORDER BY timestamp ASC LIMIT ?"
|
||||
)
|
||||
stmt:bind_values(user_id, limit)
|
||||
for row in stmt:nrows() do
|
||||
table.insert(messages, { role = row.role, content = row.content })
|
||||
end
|
||||
stmt:finalize()
|
||||
return messages
|
||||
end
|
||||
|
||||
local function get_history_messages(user_id, limit)
|
||||
limit = limit or 20
|
||||
local messages = {}
|
||||
local stmt = db:prepare(
|
||||
"SELECT role, content FROM conversations WHERE user_id=? ORDER BY timestamp DESC LIMIT ?"
|
||||
)
|
||||
stmt:bind_values(user_id, limit)
|
||||
for row in stmt:nrows() do
|
||||
table.insert(messages, 1, { role = row.role, content = row.content })
|
||||
table.insert(messages, { role = row.role, content = row.content })
|
||||
end
|
||||
stmt:finalize()
|
||||
return messages
|
||||
@@ -182,12 +231,15 @@ end
|
||||
|
||||
local function save_message(user_id, role, content, tokens)
|
||||
tokens = tokens or 0
|
||||
content = validate_utf8(content)
|
||||
|
||||
local stmt = db:prepare(
|
||||
"INSERT INTO conversations (user_id, role, content, tokens) VALUES (?,?,?,?)"
|
||||
)
|
||||
stmt:bind_values(user_id, role, content, tokens)
|
||||
stmt:step()
|
||||
stmt:finalize()
|
||||
|
||||
local today = os.date("%Y-%m-%d")
|
||||
db:exec(string.format(
|
||||
"INSERT OR IGNORE INTO token_usage (user_id, date, tokens_used) VALUES (%d,'%s',0)",
|
||||
@@ -215,6 +267,108 @@ local function get_daily_token_usage(user_id)
|
||||
return result
|
||||
end
|
||||
|
||||
-- ===== WHITELIST DATABASE FUNCTIONS =====
|
||||
local function add_allowed_user(user_id, added_by)
|
||||
local stmt = db:prepare(
|
||||
"INSERT OR IGNORE INTO allowed_users (user_id, added_by) VALUES (?, ?)"
|
||||
)
|
||||
stmt:bind_values(user_id, added_by)
|
||||
stmt:step()
|
||||
stmt:finalize()
|
||||
end
|
||||
|
||||
local function remove_allowed_user(user_id)
|
||||
db:exec(string.format("DELETE FROM allowed_users WHERE user_id=%d", user_id))
|
||||
end
|
||||
|
||||
local function get_allowed_users_full()
|
||||
local users = {}
|
||||
for row in db:nrows("SELECT user_id, added_at, added_by FROM allowed_users ORDER BY added_at DESC") do
|
||||
table.insert(users, {
|
||||
user_id = row.user_id,
|
||||
added_at = row.added_at,
|
||||
added_by = row.added_by
|
||||
})
|
||||
end
|
||||
return users
|
||||
end
|
||||
|
||||
local function get_allowed_users_list()
|
||||
local users = {}
|
||||
for row in db:nrows("SELECT user_id FROM allowed_users ORDER BY user_id") do
|
||||
table.insert(users, tostring(row.user_id))
|
||||
end
|
||||
return users
|
||||
end
|
||||
|
||||
-- ===== ACCESS CONTROL =====
|
||||
local function is_admin(user_id)
|
||||
if ADMIN_ID == "" then
|
||||
return false
|
||||
end
|
||||
return tostring(user_id) == ADMIN_ID
|
||||
end
|
||||
|
||||
local function is_user_allowed(user_id, chat_type)
|
||||
-- Режим none: все могут писать
|
||||
if WHITELIST_MODE == "none" then
|
||||
return true
|
||||
end
|
||||
|
||||
-- Режим all: везде проверяем whitelist
|
||||
if WHITELIST_MODE == "all" then
|
||||
if is_admin(user_id) then
|
||||
return true
|
||||
end
|
||||
local stmt = db:prepare("SELECT 1 FROM allowed_users WHERE user_id=?")
|
||||
stmt:bind_values(user_id)
|
||||
local allowed = false
|
||||
for row in stmt:nrows() do allowed = true; break end
|
||||
stmt:finalize()
|
||||
return allowed
|
||||
end
|
||||
|
||||
-- Режим private: личка - whitelist, группы - всем
|
||||
if WHITELIST_MODE == "private" then
|
||||
if chat_type == "private" then
|
||||
if is_admin(user_id) then
|
||||
return true
|
||||
end
|
||||
local stmt = db:prepare("SELECT 1 FROM allowed_users WHERE user_id=?")
|
||||
stmt:bind_values(user_id)
|
||||
local allowed = false
|
||||
for row in stmt:nrows() do allowed = true; break end
|
||||
stmt:finalize()
|
||||
return allowed
|
||||
else
|
||||
-- В группах всем можно
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
-- Режим group: группы - whitelist, личка - только админ
|
||||
if WHITELIST_MODE == "group" then
|
||||
if chat_type == "private" then
|
||||
-- В личке только админ
|
||||
return is_admin(user_id)
|
||||
else
|
||||
-- В группах проверяем whitelist
|
||||
if is_admin(user_id) then
|
||||
return true
|
||||
end
|
||||
local stmt = db:prepare("SELECT 1 FROM allowed_users WHERE user_id=?")
|
||||
stmt:bind_values(user_id)
|
||||
local allowed = false
|
||||
for row in stmt:nrows() do allowed = true; break end
|
||||
stmt:finalize()
|
||||
return allowed
|
||||
end
|
||||
end
|
||||
|
||||
-- По умолчанию - запрещено
|
||||
return false
|
||||
end
|
||||
|
||||
-- ===== utils =====
|
||||
local function estimate_tokens(text)
|
||||
return math.max(1, math.floor(#text / 4))
|
||||
@@ -261,23 +415,19 @@ local function cache_set(key, val)
|
||||
end
|
||||
|
||||
-- ===== CHAT: reply filter =====
|
||||
-- reply cleared text
|
||||
local function group_filter(msg)
|
||||
local chat_type = msg.chat.type -- "private","group","supergroup","channel"
|
||||
local chat_type = msg.chat.type
|
||||
|
||||
-- private msg reply always
|
||||
if chat_type == "private" then
|
||||
return true, msg.text
|
||||
end
|
||||
|
||||
-- in chat — if GROUP_MENTION_ONLY disabled
|
||||
if not GROUP_MENTION_ONLY then
|
||||
return true, msg.text
|
||||
end
|
||||
|
||||
local text = msg.text or ""
|
||||
|
||||
-- 1. Reply
|
||||
if msg.reply_to_message and msg.reply_to_message.from then
|
||||
if msg.reply_to_message.from.username == BOT_USERNAME then
|
||||
dbg("group_filter: reply to bot")
|
||||
@@ -285,18 +435,15 @@ local function group_filter(msg)
|
||||
end
|
||||
end
|
||||
|
||||
-- 2. ask bot @BotUsername (any case)
|
||||
if BOT_USERNAME ~= "" then
|
||||
local mention = "@" .. BOT_USERNAME
|
||||
local lower_text = text:lower()
|
||||
local lower_mention = mention:lower()
|
||||
if lower_text:find(lower_mention, 1, true) then
|
||||
-- clean quote
|
||||
local clean = text:gsub("(?i)" .. mention, "")
|
||||
-- lua (?i) not support, make case-insensitive
|
||||
clean = lower_text:gsub(lower_mention, "")
|
||||
clean = clean:match("^%s*(.-)%s*$") -- trim
|
||||
if clean == "" then clean = text end -- if empty — keep
|
||||
clean = clean:match("^%s*(.-)%s*$")
|
||||
if clean == "" then clean = text end
|
||||
dbg("group_filter: mention found, clean text: " .. clean)
|
||||
return true, clean
|
||||
end
|
||||
@@ -306,7 +453,7 @@ local function group_filter(msg)
|
||||
return false, nil
|
||||
end
|
||||
|
||||
-- ===== DEEPSEEK API =====
|
||||
-- ===== API =====
|
||||
local function call_deepseek(user_id, prompt)
|
||||
local cache_key = tostring(user_id) .. "\0" .. prompt
|
||||
if cache_get(cache_key) then
|
||||
@@ -339,45 +486,46 @@ local function call_deepseek(user_id, prompt)
|
||||
messages[#messages+1] = { role = "user", content = prompt }
|
||||
|
||||
local payload = json.encode({
|
||||
model = "deepseek-chat",
|
||||
model = AI_MODEL,
|
||||
messages = messages,
|
||||
temperature = 0.5,
|
||||
})
|
||||
|
||||
local resp_body = curl_post(
|
||||
DEEPSEEK_API_URL,
|
||||
{
|
||||
["Authorization"] = "Bearer " .. DEEPSEEK_API_KEY,
|
||||
["Content-Type"] = "application/json",
|
||||
},
|
||||
payload
|
||||
)
|
||||
local headers = {
|
||||
["Authorization"] = "Bearer " .. DEEPSEEK_API_KEY,
|
||||
["Content-Type"] = "application/json"
|
||||
}
|
||||
|
||||
local resp_body = curl_post(DEEPSEEK_API_URL, headers, payload)
|
||||
|
||||
if not resp_body or resp_body == "" then
|
||||
return nil, "Empty response from DeepSeek API"
|
||||
return nil, "Empty response from API"
|
||||
end
|
||||
|
||||
local ok, data = pcall(json.decode, resp_body)
|
||||
if not ok then
|
||||
err("DeepSeek JSON error: " .. tostring(data))
|
||||
err("JSON error: " .. tostring(data))
|
||||
err("Raw: " .. resp_body:sub(1, 300))
|
||||
return nil, "JSON parse error"
|
||||
end
|
||||
|
||||
if data.error then
|
||||
local emsg = data.error.message or json.encode(data.error)
|
||||
err("API error: " .. tostring(emsg))
|
||||
return nil, tostring(emsg)
|
||||
end
|
||||
|
||||
if data.choices then
|
||||
local ai_reply = data.choices[1].message.content
|
||||
local usage = data.usage or {}
|
||||
local reply_tokens = usage.completion_tokens or estimate_tokens(ai_reply)
|
||||
save_message(user_id, "assistant", ai_reply, reply_tokens)
|
||||
if #prompt < 100 then cache_set(cache_key, data) end
|
||||
info(string.format("DeepSeek OK user=%d tokens=%d", user_id, reply_tokens))
|
||||
info(string.format("AI OK user=%d tokens=%d model=%s",
|
||||
user_id, reply_tokens, data.model or AI_MODEL))
|
||||
return data
|
||||
elseif data.error then
|
||||
local emsg = data.error.message or json.encode(data.error)
|
||||
err("DeepSeek API error: " .. tostring(emsg))
|
||||
return nil, tostring(emsg)
|
||||
else
|
||||
err("DeepSeek unknown response: " .. resp_body:sub(1, 300))
|
||||
err("Unknown response: " .. resp_body:sub(1, 300))
|
||||
return nil, "Unknown API response"
|
||||
end
|
||||
end
|
||||
@@ -393,7 +541,6 @@ local function send_message(chat_id, text, reply_to)
|
||||
if reply_to then payload.reply_to_message_id = reply_to end
|
||||
local res = tg_call("sendMessage", payload)
|
||||
if not res or not res.ok then
|
||||
-- retry without Markdown
|
||||
payload.parse_mode = nil
|
||||
res = tg_call("sendMessage", payload)
|
||||
end
|
||||
@@ -406,12 +553,26 @@ local function handle_start(msg)
|
||||
info("handle_start user=" .. user_id)
|
||||
clear_user_conversation(user_id)
|
||||
save_message(user_id, "system", "You are a helpful AI assistant for Telegram users.")
|
||||
|
||||
local mode_desc = {
|
||||
none = "⚪ Disabled (all users)",
|
||||
all = "🔴 Strict (whitelist only)",
|
||||
private = "🟡 Private only (whitelist for PM)",
|
||||
group = "🟠 Group only (whitelist for groups)"
|
||||
}
|
||||
|
||||
send_message(msg.chat.id,
|
||||
"🤖 *DeepSeek AI Bot*\n\n"
|
||||
.. "Conversation history is saved locally.\n"
|
||||
"🤖 *AI Bot*\n\n"
|
||||
.. "Conversation history is saved locally.\n\n"
|
||||
.. "*Commands:*\n"
|
||||
.. "/clear — start a new conversation\n"
|
||||
.. "/history — show recent messages\n"
|
||||
.. "/tokens — daily token usage",
|
||||
.. "/tokens — daily token usage\n"
|
||||
.. "/admin — show admin info\n"
|
||||
.. "/allowed — show whitelist (admin only)\n"
|
||||
.. "/whitelist — manage whitelist (admin only)\n"
|
||||
.. "/whoami — show your Telegram ID\n\n"
|
||||
.. "*Whitelist mode:* " .. (mode_desc[WHITELIST_MODE] or WHITELIST_MODE),
|
||||
msg.message_id
|
||||
)
|
||||
end
|
||||
@@ -423,19 +584,21 @@ local function handle_clear(msg)
|
||||
end
|
||||
|
||||
local function handle_history(msg)
|
||||
local messages = get_user_messages(msg.from.id, 20)
|
||||
local messages = get_history_messages(msg.from.id, 20)
|
||||
if #messages == 0 then
|
||||
send_message(msg.chat.id, "📜 *Conversation history is empty*", msg.message_id)
|
||||
return
|
||||
end
|
||||
local lines = { "📜 *Recent Conversation:*\n" }
|
||||
local start = math.max(1, #messages - 4)
|
||||
for i = start, #messages do
|
||||
local m = messages[i]
|
||||
local role = (m.role == "user") and "You" or "AI"
|
||||
for i, m in ipairs(messages) do
|
||||
local role = (m.role == "user") and "👤 You" or "🤖 AI"
|
||||
local text = m.content:sub(1, 200)
|
||||
if #m.content > 200 then text = text .. "…" end
|
||||
lines[#lines+1] = string.format("*%s:* %s\n", role, text)
|
||||
lines[#lines+1] = string.format("%s: %s\n", role, text)
|
||||
if i >= 10 then
|
||||
lines[#lines+1] = "\n*... and more*"
|
||||
break
|
||||
end
|
||||
end
|
||||
send_message(msg.chat.id, table.concat(lines, "\n"), msg.message_id)
|
||||
end
|
||||
@@ -457,10 +620,244 @@ local function handle_tokens(msg)
|
||||
)
|
||||
end
|
||||
|
||||
local function handle_admin_info(msg)
|
||||
local user_id = msg.from.id
|
||||
local is_user_admin = is_admin(user_id)
|
||||
|
||||
local message = "👑 *Admin System*\n\n"
|
||||
|
||||
if ADMIN_ID ~= "" then
|
||||
message = message .. "• Admin ID: `" .. ADMIN_ID .. "`\n"
|
||||
message = message .. "• Your ID: `" .. user_id .. "`\n\n"
|
||||
|
||||
if is_user_admin then
|
||||
message = message .. "✅ *You are the admin*\n"
|
||||
message = message .. "You can use whitelist commands."
|
||||
else
|
||||
message = message .. "❌ *You are not the admin*\n"
|
||||
message = message .. "Contact the bot administrator."
|
||||
end
|
||||
else
|
||||
message = message .. "⚠️ *No admin configured*\n"
|
||||
message = message .. "Set `admin_id` in UCI config."
|
||||
end
|
||||
|
||||
send_message(msg.chat.id, message, msg.message_id)
|
||||
end
|
||||
|
||||
local function handle_whoami(msg)
|
||||
local user_id = msg.from.id
|
||||
local username = msg.from.username or "no username"
|
||||
local first_name = msg.from.first_name or ""
|
||||
|
||||
local message = string.format(
|
||||
"🆔 *Your Telegram ID*\n\n"
|
||||
.. "• ID: `%d`\n"
|
||||
.. "• Username: @%s\n"
|
||||
.. "• Name: %s\n\n"
|
||||
.. "Share this ID with admin to be added to whitelist.",
|
||||
user_id, username, first_name
|
||||
)
|
||||
|
||||
send_message(msg.chat.id, message, msg.message_id)
|
||||
end
|
||||
|
||||
local function handle_allowed(msg)
|
||||
local user_id = msg.from.id
|
||||
local is_user_admin = is_admin(user_id)
|
||||
|
||||
if not is_user_admin then
|
||||
send_message(msg.chat.id, "⛔ *Access denied!* Only admin can view whitelist.", msg.message_id)
|
||||
return
|
||||
end
|
||||
|
||||
if WHITELIST_MODE == "none" then
|
||||
send_message(msg.chat.id,
|
||||
"⚪ *Whitelist is disabled*\n\n"
|
||||
.. "Enable with: `/whitelist mode all/private/group`",
|
||||
msg.message_id
|
||||
)
|
||||
return
|
||||
end
|
||||
|
||||
local users = get_allowed_users_full()
|
||||
|
||||
if #users == 0 then
|
||||
send_message(msg.chat.id,
|
||||
"📭 *Whitelist is empty*\n\n"
|
||||
.. "Add users with: `/whitelist add USER_ID`",
|
||||
msg.message_id
|
||||
)
|
||||
return
|
||||
end
|
||||
|
||||
local mode_desc = {
|
||||
all = "🔴 Strict mode (all chats)",
|
||||
private = "🟡 Private mode (whitelist for PM only)",
|
||||
group = "🟠 Group mode (whitelist for groups only)"
|
||||
}
|
||||
|
||||
local message = "👥 *Allowed Users*\n"
|
||||
message = message .. string.format("└ *Mode:* %s\n", mode_desc[WHITELIST_MODE] or WHITELIST_MODE)
|
||||
message = message .. string.format("└ *Total:* %d user(s)\n\n", #users)
|
||||
|
||||
for i, u in ipairs(users) do
|
||||
local date_str = u.added_at or "unknown"
|
||||
date_str = date_str:match("(%d+-%d+-%d+)") or date_str
|
||||
|
||||
local added_by_str = u.added_by and string.format(" (by `%d`)", u.added_by) or ""
|
||||
|
||||
message = message .. string.format("%d. `%d`%s\n", i, u.user_id, added_by_str)
|
||||
message = message .. string.format(" └ added: %s\n", date_str)
|
||||
|
||||
if #message > 3500 then
|
||||
message = message .. "\n*... and more*"
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
send_message(msg.chat.id, message, msg.message_id)
|
||||
end
|
||||
|
||||
local function handle_whitelist_status(msg)
|
||||
local user_id = msg.from.id
|
||||
|
||||
if not is_admin(user_id) then
|
||||
send_message(msg.chat.id, "⛔ *Admin only command*", msg.message_id)
|
||||
return
|
||||
end
|
||||
|
||||
local mode_desc = {
|
||||
none = "⚪ *Disabled* - all users can use the bot",
|
||||
all = "🔴 *Strict* - only whitelisted users (all chats)",
|
||||
private = "🟡 *Private only* - whitelist for PM, open in groups",
|
||||
group = "🟠 *Group only* - whitelist for groups, PM restricted to admin"
|
||||
}
|
||||
|
||||
local users = get_allowed_users_list()
|
||||
local message = string.format(
|
||||
"🛡️ *Whitelist System*\n\n"
|
||||
.. "*Mode:* %s\n"
|
||||
.. "*Users:* %d\n\n",
|
||||
mode_desc[WHITELIST_MODE] or "Unknown",
|
||||
#users
|
||||
)
|
||||
|
||||
if #users > 0 and WHITELIST_MODE ~= "none" then
|
||||
message = message .. "*Allowed users:*\n"
|
||||
for i, uid in ipairs(users) do
|
||||
message = message .. string.format("%d. `%s`\n", i, uid)
|
||||
if #message > 3500 then break end
|
||||
end
|
||||
message = message .. "\n"
|
||||
end
|
||||
|
||||
message = message .. "*Commands:*\n"
|
||||
message = message .. "• `/whitelist mode none` - disable\n"
|
||||
message = message .. "• `/whitelist mode all` - strict mode\n"
|
||||
message = message .. "• `/whitelist mode private` - PM only\n"
|
||||
message = message .. "• `/whitelist mode group` - group only\n"
|
||||
message = message .. "• `/whitelist add USER_ID` - add user\n"
|
||||
message = message .. "• `/whitelist remove USER_ID` - remove user"
|
||||
|
||||
send_message(msg.chat.id, message, msg.message_id)
|
||||
end
|
||||
|
||||
local function handle_whitelist_mode(msg, text)
|
||||
local user_id = msg.from.id
|
||||
|
||||
if not is_admin(user_id) then
|
||||
send_message(msg.chat.id, "⛔ *Admin only command*", msg.message_id)
|
||||
return
|
||||
end
|
||||
|
||||
local mode = text:match("/whitelist mode (%w+)")
|
||||
if not mode or not (mode == "none" or mode == "all" or mode == "private" or mode == "group") then
|
||||
send_message(msg.chat.id, "❌ *Usage:* `/whitelist mode {none|all|private|group}`", msg.message_id)
|
||||
return
|
||||
end
|
||||
|
||||
os.execute("uci set deepbot.main.whitelist_mode='" .. mode .. "'")
|
||||
os.execute("uci commit deepbot")
|
||||
|
||||
-- Обновляем переменную в текущем процессе
|
||||
WHITELIST_MODE = mode
|
||||
|
||||
local messages = {
|
||||
none = "⚪ *Whitelist disabled* - all users can use the bot",
|
||||
all = "🔴 *Whitelist mode: ALL* - only whitelisted users in all chats",
|
||||
private = "🟡 *Whitelist mode: PRIVATE* - whitelist for PM, open in groups",
|
||||
group = "🟠 *Whitelist mode: GROUP* - whitelist for groups, PM restricted to admin"
|
||||
}
|
||||
|
||||
send_message(msg.chat.id, messages[mode], msg.message_id)
|
||||
info("Whitelist mode changed to: " .. mode)
|
||||
end
|
||||
|
||||
local function handle_whitelist_add(msg, text)
|
||||
local user_id = msg.from.id
|
||||
|
||||
if not is_admin(user_id) then
|
||||
send_message(msg.chat.id, "⛔ *Admin only command*", msg.message_id)
|
||||
return
|
||||
end
|
||||
|
||||
local new_user = text:match("/whitelist add (%d+)")
|
||||
if not new_user then
|
||||
send_message(msg.chat.id, "❌ *Usage:* `/whitelist add USER_ID`", msg.message_id)
|
||||
return
|
||||
end
|
||||
|
||||
add_allowed_user(tonumber(new_user), user_id)
|
||||
send_message(msg.chat.id, "✅ User `" .. new_user .. "` added to whitelist", msg.message_id)
|
||||
info("Added user " .. new_user .. " to whitelist")
|
||||
end
|
||||
|
||||
local function handle_whitelist_remove(msg, text)
|
||||
local user_id = msg.from.id
|
||||
|
||||
if not is_admin(user_id) then
|
||||
send_message(msg.chat.id, "⛔ *Admin only command*", msg.message_id)
|
||||
return
|
||||
end
|
||||
|
||||
local remove_user = text:match("/whitelist remove (%d+)")
|
||||
if not remove_user then
|
||||
send_message(msg.chat.id, "❌ *Usage:* `/whitelist remove USER_ID`", msg.message_id)
|
||||
return
|
||||
end
|
||||
|
||||
remove_allowed_user(tonumber(remove_user))
|
||||
send_message(msg.chat.id, "🗑️ User `" .. remove_user .. "` removed from whitelist", msg.message_id)
|
||||
info("Removed user " .. remove_user .. " from whitelist")
|
||||
end
|
||||
|
||||
local function handle_text(msg, clean_text)
|
||||
local user_id = msg.from.id
|
||||
local chat_id = msg.chat.id
|
||||
local text = clean_text or msg.text
|
||||
local chat_type = msg.chat.type
|
||||
|
||||
if not is_user_allowed(user_id, chat_type) then
|
||||
info("Access denied for user=" .. user_id .. " in " .. chat_type)
|
||||
|
||||
local message = ""
|
||||
if WHITELIST_MODE == "all" then
|
||||
message = "⛔ *Access denied!* You are not authorized to use this bot."
|
||||
elseif WHITELIST_MODE == "private" and chat_type == "private" then
|
||||
message = "⛔ *Access denied!* You are not in the whitelist.\nUse `/whoami` to get your ID and contact admin."
|
||||
elseif WHITELIST_MODE == "group" and chat_type == "private" then
|
||||
message = "⛔ *Access denied!* Private messages are restricted to admin only."
|
||||
elseif WHITELIST_MODE == "group" and chat_type ~= "private" then
|
||||
message = "⛔ *Access denied!* You are not authorized to use this bot in groups."
|
||||
else
|
||||
message = "⛔ *Access denied!*"
|
||||
end
|
||||
|
||||
send_message(chat_id, message, msg.message_id)
|
||||
return
|
||||
end
|
||||
|
||||
local text = clean_text or msg.text
|
||||
info(string.format("handle_text user=%d chat=%d text=%q",
|
||||
user_id, chat_id, text:sub(1, 80)))
|
||||
|
||||
@@ -469,11 +866,11 @@ local function handle_text(msg, clean_text)
|
||||
return
|
||||
end
|
||||
|
||||
tg_call("sendChatAction", { chat_id = chat_id, action = "typing" })
|
||||
tg_call("sendChatAction", { chat_id = chat_id, action = "typing" }, true)
|
||||
|
||||
local data, api_err = call_deepseek(user_id, text)
|
||||
if api_err then
|
||||
err("DeepSeek error user=" .. user_id .. ": " .. api_err)
|
||||
err("Error user=" .. user_id .. ": " .. api_err)
|
||||
send_message(chat_id, "❌ *Error:* " .. api_err, msg.message_id)
|
||||
return
|
||||
end
|
||||
@@ -482,7 +879,7 @@ local function handle_text(msg, clean_text)
|
||||
send_message(chat_id, answer, msg.message_id)
|
||||
end
|
||||
|
||||
-- ===== manager =====
|
||||
-- ===== DISPATCH =====
|
||||
local function dispatch(msg)
|
||||
if not msg then dbg("dispatch: nil message") return end
|
||||
if not msg.text then dbg("dispatch: no text") return end
|
||||
@@ -491,13 +888,18 @@ local function dispatch(msg)
|
||||
|
||||
local text = msg.text
|
||||
|
||||
-- commands run any
|
||||
if text == "/start" or text:find("^/start@") then handle_start(msg)
|
||||
elseif text == "/clear" or text:find("^/clear@") then handle_clear(msg)
|
||||
elseif text == "/history" or text:find("^/history@") then handle_history(msg)
|
||||
elseif text == "/tokens" or text:find("^/tokens@") then handle_tokens(msg)
|
||||
elseif text == "/admin" or text:find("^/admin@") then handle_admin_info(msg)
|
||||
elseif text == "/whoami" or text:find("^/whoami@") then handle_whoami(msg)
|
||||
elseif text == "/allowed" or text:find("^/allowed@") then handle_allowed(msg)
|
||||
elseif text == "/whitelist" or text:find("^/whitelist@") then handle_whitelist_status(msg)
|
||||
elseif text:find("^/whitelist mode ") then handle_whitelist_mode(msg, text)
|
||||
elseif text:find("^/whitelist add ") then handle_whitelist_add(msg, text)
|
||||
elseif text:find("^/whitelist remove ") then handle_whitelist_remove(msg, text)
|
||||
elseif text:sub(1,1) ~= "/" then
|
||||
-- for standart msg — check public filter
|
||||
local should_reply, clean_text = group_filter(msg)
|
||||
if should_reply then
|
||||
handle_text(msg, clean_text)
|
||||
@@ -509,21 +911,29 @@ end
|
||||
local function run()
|
||||
info("Lua version: " .. _VERSION)
|
||||
info("Token configured: " .. (TELEGRAM_BOT_TOKEN ~= "YOUR_BOT_TOKEN" and "YES" or "NO"))
|
||||
info("DeepSeek key configured: " .. (DEEPSEEK_API_KEY ~= "YOUR_DEEPSEEK_KEY" and "YES" or "NO"))
|
||||
info("AI key configured: " .. (DEEPSEEK_API_KEY ~= "YOUR_DEEPSEEK_KEY" and "YES" or "NO"))
|
||||
info("AI Model: " .. AI_MODEL)
|
||||
info("Debug mode: " .. (DEBUG and "ON" or "OFF"))
|
||||
info("Proxy: " .. (PROXY_URL ~= "" and PROXY_URL or "none"))
|
||||
info("Group mention only: " .. (GROUP_MENTION_ONLY and "ON" or "OFF"))
|
||||
info("Admin ID: " .. (ADMIN_ID ~= "" and ADMIN_ID or "NOT SET"))
|
||||
info("Whitelist mode: " .. WHITELIST_MODE)
|
||||
|
||||
if WHITELIST_MODE ~= "none" and ADMIN_ID == "" then
|
||||
err("WARNING: Whitelist mode is '" .. WHITELIST_MODE .. "' but no admin_id configured!")
|
||||
err("Set admin_id via: uci set deepbot.main.admin_id='YOUR_ID'")
|
||||
end
|
||||
|
||||
local test = io.popen("curl --version 2>&1 | head -1")
|
||||
if test then info("curl: " .. (test:read("*l") or "?")); test:close() end
|
||||
|
||||
info("Checking token via getMe...")
|
||||
local me = tg_call("getMe", {})
|
||||
local me = tg_call("getMe", {}, true)
|
||||
if me and me.ok then
|
||||
BOT_USERNAME = me.result.username or ""
|
||||
info("Bot: @" .. BOT_USERNAME)
|
||||
else
|
||||
err("getMe failed — проверь TELEGRAM_BOT_TOKEN!")
|
||||
err("getMe failed — check TELEGRAM_BOT_TOKEN!")
|
||||
os.exit(1)
|
||||
end
|
||||
|
||||
@@ -537,7 +947,7 @@ local function run()
|
||||
offset = offset,
|
||||
timeout = 30,
|
||||
allowed_updates = { "message" },
|
||||
})
|
||||
}, true)
|
||||
|
||||
if res and res.ok and res.result then
|
||||
local count = #res.result
|
||||
@@ -549,9 +959,9 @@ local function run()
|
||||
if not ok then err("dispatch error: " .. tostring(e)) end
|
||||
end
|
||||
else
|
||||
err("getUpdates failed, sleeping 5s...")
|
||||
if res then err("Response: " .. json.encode(res)) end
|
||||
socket.sleep(5)
|
||||
if not res then
|
||||
socket.sleep(5)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk
|
||||
PKG_MAINTAINER:=Thaolga <https://github.com/Thaolga/luci-app-nekobox>
|
||||
PKG_NAME:=luci-app-nekobox
|
||||
PKG_VERSION:=2.0.9
|
||||
PKG_RELEASE:=3
|
||||
PKG_RELEASE:=4
|
||||
|
||||
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
location /nekobox/ {
|
||||
index index.php;
|
||||
|
||||
location ~ \.php$ {
|
||||
fastcgi_param HTTP_PROXY "";
|
||||
fastcgi_connect_timeout 300s;
|
||||
fastcgi_read_timeout 300s;
|
||||
fastcgi_send_timeout 300s;
|
||||
fastcgi_buffer_size 32k;
|
||||
fastcgi_buffers 4 32k;
|
||||
fastcgi_busy_buffers_size 64k;
|
||||
fastcgi_temp_file_write_size 64k;
|
||||
include fastcgi_params;
|
||||
fastcgi_pass 127.0.0.1:1026;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,6 @@ header {
|
||||
|
||||
.brand {
|
||||
@apply hover:text-primary text-foreground inline-block shrink-0 text-xl font-semibold tracking-tight no-underline transition-all duration-200 hover:-translate-y-0.5 max-md:flex-1 max-md:text-lg;
|
||||
|
||||
[data-nav-type="sidebar"] & {
|
||||
@apply mr-auto;
|
||||
}
|
||||
}
|
||||
|
||||
.nav {
|
||||
@@ -98,7 +94,7 @@ header {
|
||||
|
||||
& .navigation-toggle-line {
|
||||
transform-box: fill-box;
|
||||
@apply origin-center transition-[transform,opacity] duration-300 ease-in-out;
|
||||
@apply origin-center transition-[transform,opacity] duration-300 ease-in-out motion-reduce:transition-none;
|
||||
}
|
||||
|
||||
&.is-expanded {
|
||||
@@ -115,12 +111,6 @@ header {
|
||||
transform: translate(3.75px, -5px) rotate(-45deg) scaleX(2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
& .navigation-toggle-line {
|
||||
@apply transition-none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,22 +146,35 @@ header {
|
||||
}
|
||||
|
||||
body[data-nav-type="sidebar"] {
|
||||
/* body is flex-col in _base.css; sidebar layout needs a wrapping row */
|
||||
@apply md:flex-row md:flex-wrap;
|
||||
/* Desktop header + sidebar form one flush, L-shaped application frame: a
|
||||
full-width header on top, a sidebar pinned to the left edge below it.
|
||||
A CSS grid (not flex-wrap) keeps the header row at its natural height —
|
||||
so collapsing the sidebar never stretches it — lets the sidebar column
|
||||
animate to zero, and lets #maincontent fluidly reclaim the freed space.
|
||||
overflow-x-clip keeps the sliding sidebar out of horizontal overflow. */
|
||||
@apply motion-reduce:transition-none md:grid md:grid-cols-[17rem_minmax(0,1fr)] md:grid-rows-[auto_minmax(0,1fr)] md:overflow-x-clip md:transition-[grid-template-columns] md:duration-300 md:ease-out;
|
||||
|
||||
& header {
|
||||
@apply md:basis-full;
|
||||
&.sidebar-collapsed {
|
||||
@apply md:grid-cols-[0_minmax(0,1fr)];
|
||||
}
|
||||
|
||||
& #maincontent {
|
||||
@apply md:min-w-0 md:flex-1;
|
||||
& > header {
|
||||
@apply md:border-border/60 md:col-span-full md:row-start-1 md:mb-0 md:border-b;
|
||||
}
|
||||
|
||||
& > .sidebar-panel {
|
||||
@apply md:col-start-1 md:row-start-2;
|
||||
}
|
||||
|
||||
& > #maincontent {
|
||||
@apply md:col-start-2 md:row-start-2 md:min-w-0 md:pt-2;
|
||||
}
|
||||
|
||||
& .header-crumb {
|
||||
@apply text-muted-foreground absolute left-1/2 m-0 hidden -translate-x-1/2 list-none items-center gap-2 p-0 text-sm md:flex;
|
||||
@apply text-muted-foreground my-0 mr-auto ml-6 hidden min-w-0 list-none items-center gap-2 overflow-hidden p-0 text-sm md:flex;
|
||||
|
||||
& li {
|
||||
@apply m-0 list-none;
|
||||
@apply m-0 list-none whitespace-nowrap;
|
||||
}
|
||||
|
||||
& .crumb-sep {
|
||||
@@ -179,74 +182,68 @@ body[data-nav-type="sidebar"] {
|
||||
}
|
||||
|
||||
& .current {
|
||||
@apply text-foreground font-medium;
|
||||
@apply text-foreground min-w-0 truncate font-medium;
|
||||
}
|
||||
}
|
||||
|
||||
& .sidebar-panel {
|
||||
@apply bg-header-bg border-border/60 sticky top-14 hidden h-[calc(100vh-3.5rem)] w-68 shrink-0 overflow-hidden border-r transition-[width,visibility] duration-300 md:block;
|
||||
@apply border-border/60 sticky top-14 hidden h-[calc(100vh-3.5rem)] w-full overflow-hidden rounded-none border-r transition-[visibility] duration-300 motion-reduce:transition-none md:block;
|
||||
}
|
||||
|
||||
/* Visibility flips at the end of the slide so collapsed controls leave the
|
||||
tab order without clipping the animation. */
|
||||
&.sidebar-collapsed .sidebar-panel {
|
||||
@apply invisible w-0;
|
||||
@apply invisible;
|
||||
}
|
||||
|
||||
& .sidebar-panel-inner {
|
||||
@apply flex h-full w-68 flex-col;
|
||||
@apply bg-header-bg flex h-full w-68 flex-col overflow-hidden transition-[translate,opacity] duration-300 motion-reduce:transition-none;
|
||||
}
|
||||
|
||||
&.sidebar-collapsed .sidebar-panel-inner {
|
||||
@apply -translate-x-full opacity-0;
|
||||
}
|
||||
|
||||
& .sidebar-list {
|
||||
@apply m-0 flex-1 list-none overflow-y-auto p-3;
|
||||
@apply m-0 flex-1 list-none space-y-0.5 overflow-y-auto p-3;
|
||||
|
||||
& li {
|
||||
@apply m-0 list-none;
|
||||
|
||||
&:first-child > .sidebar-category {
|
||||
@apply pt-1;
|
||||
}
|
||||
|
||||
&.active > .sidebar-link {
|
||||
@apply bg-header-interactive;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .sidebar-category {
|
||||
@apply text-muted-foreground hover:text-foreground flex cursor-pointer items-center gap-1 px-3 pt-4 pb-1 text-sm font-semibold transition-colors duration-150 select-none;
|
||||
|
||||
& svg {
|
||||
@apply size-3 shrink-0 transition-transform duration-200;
|
||||
}
|
||||
}
|
||||
|
||||
& li.section-collapsed > .sidebar-category svg {
|
||||
@apply -rotate-90;
|
||||
& .sidebar-list .nav-link {
|
||||
@apply truncate text-lg;
|
||||
}
|
||||
|
||||
& .sidebar-section {
|
||||
@apply grid grid-rows-[1fr] transition-[grid-template-rows] duration-200 ease-out;
|
||||
@apply grid grid-rows-[0fr] opacity-0 transition-[grid-template-rows,opacity] duration-200 ease-out motion-reduce:transition-none;
|
||||
}
|
||||
|
||||
& li.section-collapsed .sidebar-section {
|
||||
@apply grid-rows-[0fr];
|
||||
& .sidebar-group-open > .sidebar-section {
|
||||
@apply grid-rows-[1fr] opacity-100;
|
||||
}
|
||||
|
||||
/* Child links sit indented under their category — hierarchy comes from
|
||||
spacing alone, no guide line, for a calmer look. */
|
||||
& .sidebar-submenu {
|
||||
@apply m-0 min-h-0 list-none overflow-hidden p-0;
|
||||
|
||||
& li.active > .sidebar-link {
|
||||
@apply bg-header-interactive;
|
||||
}
|
||||
@apply m-0 min-h-0 list-none space-y-0.5 overflow-hidden p-0 pl-3;
|
||||
}
|
||||
|
||||
& .sidebar-link {
|
||||
@apply text-foreground hover:bg-header-interactive block rounded-xl px-3 py-1.5 no-underline transition-all duration-150;
|
||||
& .sidebar-submenu .nav-link {
|
||||
@apply text-muted-foreground hover:text-foreground rounded-lg px-3 py-1.5 text-sm;
|
||||
}
|
||||
|
||||
& .sidebar-submenu .nav-link-active {
|
||||
@apply bg-primary/10 text-primary hover:text-primary;
|
||||
}
|
||||
|
||||
& .sidebar-footer {
|
||||
@apply border-border/60 shrink-0 border-t p-3;
|
||||
|
||||
& .sidebar-link {
|
||||
/* Logout: muted at rest; on hover the base .nav-link pill background
|
||||
plus error-colored text is the intended compound state (matches v1). */
|
||||
& .nav-link {
|
||||
@apply text-muted-foreground hover:text-error-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Shared navigation vocabulary.
|
||||
*
|
||||
* One visual definition consumed by the desktop sidebar, the mobile
|
||||
* drawer and (incrementally) the other navigation modes. Mode files
|
||||
* (_layout.css, _overlay.css) keep positioning, sizing overrides and
|
||||
* show/hide state only — never duplicate these recipes there.
|
||||
*/
|
||||
|
||||
.nav-link {
|
||||
@apply text-foreground hover:bg-header-interactive block rounded-xl px-3 py-1.5 no-underline transition-all duration-150;
|
||||
}
|
||||
|
||||
/* Modifier applied on top of a base link class; repeats its own colors on hover so the base class's hover styles never bleed through. */
|
||||
.nav-link-active {
|
||||
@apply bg-primary/10 text-primary hover:bg-primary/10 hover:text-primary rounded-xl font-medium;
|
||||
}
|
||||
|
||||
/* Category rows are group labels, not buttons: no hover surface, just a
|
||||
text-color shift. Block highlights belong to child links exclusively.
|
||||
appearance-none + border-0 + shadow-none strip the theme's global
|
||||
<button> chrome (_button.css), which would otherwise box every row. */
|
||||
.nav-category {
|
||||
@apply text-muted-foreground hover:text-foreground focus-visible:ring-primary/40 flex w-full cursor-pointer appearance-none items-center gap-2 rounded-lg border-0 bg-transparent px-3 py-2 text-left text-lg font-semibold tracking-wide shadow-none transition-colors duration-150 select-none focus-visible:ring-2 focus-visible:outline-none;
|
||||
|
||||
/* Trailing chevron shares arrow-right.svg with the collapsible cards:
|
||||
points right when closed, rotates to point down when open. */
|
||||
@apply after:size-3.5 after:shrink-0 after:bg-current after:opacity-55 after:transition-[transform,opacity] after:duration-200 after:content-[''] after:[mask:url('@assets/icons/arrow-right.svg')_center/cover_no-repeat] hover:after:opacity-100 motion-reduce:after:transition-none;
|
||||
}
|
||||
|
||||
.nav-category-label {
|
||||
@apply min-w-0 flex-1 truncate;
|
||||
}
|
||||
|
||||
/* Open group: label darkens to foreground; the active child's pill is the
|
||||
sole location anchor, so no primary tint on the label itself. */
|
||||
.sidebar-group-open > .nav-category {
|
||||
@apply text-foreground after:rotate-90;
|
||||
}
|
||||
|
||||
/* The desktop sidebar no longer uses previews; the mobile drawer is the
|
||||
sole consumer and sets its own px/text-size (_overlay.css), so the base
|
||||
only carries the mode-agnostic bits. */
|
||||
.nav-category-preview {
|
||||
@apply text-muted-foreground/60 truncate pb-1;
|
||||
|
||||
& .nav-preview-current {
|
||||
@apply text-primary;
|
||||
}
|
||||
}
|
||||
@@ -23,33 +23,44 @@
|
||||
@apply max-md:shrink-0;
|
||||
|
||||
& .mobile-nav-link {
|
||||
@apply max-md:text-foreground max-md:hover:text-primary max-md:flex max-md:min-h-12 max-md:w-full max-md:items-center max-md:justify-between max-md:gap-4 max-md:px-0 max-md:py-2 max-md:text-lg max-md:leading-tight max-md:font-medium max-md:no-underline max-md:transition-colors max-md:duration-200;
|
||||
@apply max-md:text-foreground max-md:hover:text-primary max-md:flex max-md:min-h-12 max-md:w-full max-md:items-center max-md:justify-between max-md:gap-4 max-md:px-0 max-md:py-2 max-md:text-lg max-md:leading-tight max-md:font-medium max-md:no-underline max-md:transition-colors max-md:duration-200 max-md:motion-reduce:transition-none;
|
||||
}
|
||||
|
||||
& .mobile-nav-chevron {
|
||||
@apply max-md:text-muted-foreground max-md:flex max-md:size-4 max-md:shrink-0 max-md:items-center max-md:justify-center max-md:transition-transform max-md:duration-300 max-md:ease-in-out;
|
||||
|
||||
& svg {
|
||||
@apply max-md:size-4;
|
||||
}
|
||||
& .mobile-nav-link.nav-link-active {
|
||||
@apply max-md:px-3;
|
||||
}
|
||||
|
||||
&.active > .mobile-nav-link {
|
||||
@apply max-md:text-primary;
|
||||
/* Keep the sub-link's pl-4 indent aligned with its siblings; only
|
||||
the right side gains padding to balance the pill. */
|
||||
& .mobile-nav-sublink.nav-link-active {
|
||||
@apply max-md:pr-3;
|
||||
}
|
||||
|
||||
&.submenu-expanded {
|
||||
& .mobile-nav-chevron {
|
||||
@apply max-md:rotate-90;
|
||||
}
|
||||
/* Category rows reuse the sidebar chevron recipe: arrow-right.svg
|
||||
via mask, pointing right when collapsed, down when expanded.
|
||||
justify-between on the link pushes it to the row's end. */
|
||||
&.has-submenu > .mobile-nav-link {
|
||||
@apply max-md:after:size-4 max-md:after:shrink-0 max-md:after:bg-current max-md:after:opacity-55 max-md:after:transition-transform max-md:after:duration-200 max-md:after:content-[''] max-md:after:[mask:url('@assets/icons/arrow-right.svg')_center/cover_no-repeat] max-md:motion-reduce:after:transition-none;
|
||||
}
|
||||
|
||||
&.submenu-expanded > .mobile-nav-link {
|
||||
@apply max-md:after:rotate-90;
|
||||
}
|
||||
|
||||
& .nav-category-preview {
|
||||
@apply max-md:px-0 max-md:text-sm;
|
||||
}
|
||||
|
||||
&.submenu-expanded .nav-category-preview {
|
||||
@apply max-md:hidden;
|
||||
}
|
||||
|
||||
& .mobile-nav-submenu {
|
||||
@apply max-md:m-0 max-md:list-none max-md:overflow-hidden max-md:p-0 max-md:transition-[max-height,opacity] max-md:duration-300 max-md:ease-in-out;
|
||||
@apply max-md:m-0 max-md:list-none max-md:overflow-hidden max-md:p-0 max-md:transition-[max-height,opacity] max-md:duration-300 max-md:ease-in-out max-md:motion-reduce:transition-none;
|
||||
|
||||
& .mobile-nav-subitem {
|
||||
& .mobile-nav-sublink {
|
||||
@apply max-md:text-default-foreground max-md:hover:text-primary max-md:flex max-md:min-h-10 max-md:w-full max-md:items-center max-md:py-2 max-md:pr-0 max-md:pl-4 max-md:text-[0.9375rem] max-md:font-normal max-md:no-underline max-md:transition-colors max-md:duration-200;
|
||||
@apply max-md:text-default-foreground max-md:hover:text-primary max-md:flex max-md:min-h-10 max-md:w-full max-md:items-center max-md:py-2 max-md:pr-0 max-md:pl-4 max-md:text-[0.9375rem] max-md:font-normal max-md:no-underline max-md:transition-colors max-md:duration-200 max-md:motion-reduce:transition-none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,7 +75,7 @@
|
||||
}
|
||||
|
||||
& .mobile-nav-logout {
|
||||
@apply max-md:text-foreground max-md:hover:text-primary max-md:text-sm max-md:font-medium max-md:no-underline max-md:transition-colors max-md:duration-200;
|
||||
@apply max-md:text-foreground max-md:hover:text-primary max-md:text-sm max-md:font-medium max-md:no-underline max-md:transition-colors max-md:duration-200 max-md:motion-reduce:transition-none;
|
||||
}
|
||||
|
||||
& .theme-switcher {
|
||||
@@ -73,16 +84,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
& .mobile-nav-link,
|
||||
& .mobile-nav-chevron,
|
||||
& .mobile-nav-submenu,
|
||||
& .mobile-nav-sublink,
|
||||
& .mobile-nav-logout {
|
||||
@apply max-md:transition-none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
@import "./_base.css";
|
||||
@import "./_layout.css";
|
||||
|
||||
@import "./components/_nav.css";
|
||||
@import "./components/_switcher.css";
|
||||
@import "./components/_float-button.css";
|
||||
@import "./components/_button.css";
|
||||
|
||||
@@ -46,8 +46,28 @@ return baseclass.extend({
|
||||
});
|
||||
};
|
||||
|
||||
// Relies on the drawer being hidden via visibility/opacity (not
|
||||
// display:none): the submenu stays laid out, so scrollHeight is
|
||||
// measurable in the same tick the drawer opens.
|
||||
const expandActiveMobileGroup = () => {
|
||||
const item = document.querySelector(".mobile-nav-item.has-active");
|
||||
const submenu = item?.querySelector(".mobile-nav-submenu");
|
||||
|
||||
if (!item || !submenu) return;
|
||||
|
||||
item.classList.add("submenu-expanded");
|
||||
submenu.style.maxHeight = `${submenu.scrollHeight}px`;
|
||||
submenu.style.opacity = "1";
|
||||
};
|
||||
|
||||
const updateToggleState = (expanded) => {
|
||||
navigationToggle.classList.toggle("is-expanded", expanded);
|
||||
// The hamburger ↔ X morph is a mobile-drawer affordance; the desktop
|
||||
// sidebar toggle stays a static hamburger (see 2026-06-11 redesign
|
||||
// spec, superseding the unified-toggle "always X when expanded" rule).
|
||||
navigationToggle.classList.toggle(
|
||||
"is-expanded",
|
||||
expanded && !desktop.matches,
|
||||
);
|
||||
navigationToggle.setAttribute("aria-expanded", expanded);
|
||||
};
|
||||
|
||||
@@ -85,7 +105,8 @@ return baseclass.extend({
|
||||
overlay.classList.toggle("mobile-menu-open", expanded);
|
||||
document.body.style.overflow = expanded ? "hidden" : "";
|
||||
|
||||
if (!expanded) resetMobileSubmenus();
|
||||
if (expanded) expandActiveMobileGroup();
|
||||
else resetMobileSubmenus();
|
||||
|
||||
updateToggleState(expanded);
|
||||
};
|
||||
@@ -176,7 +197,7 @@ return baseclass.extend({
|
||||
children.forEach((child) => {
|
||||
const submenu = ui.menu.getChildren(child);
|
||||
const hasSubmenu = submenu.length > 0;
|
||||
const isActive = L.env.dispatchpath[1] === child.name;
|
||||
const isActive = this.isActivePath(child.name);
|
||||
|
||||
if (child.name === "logout") {
|
||||
footerAction.appendChild(
|
||||
@@ -192,63 +213,46 @@ return baseclass.extend({
|
||||
return;
|
||||
}
|
||||
|
||||
// "has-submenu" gates the drawer's expand chevron in _overlay.css.
|
||||
// "has-active" drives the auto-expand on drawer open.
|
||||
const itemClasses = [
|
||||
"mobile-nav-item",
|
||||
hasSubmenu ? "has-submenu" : "",
|
||||
isActive ? "active" : "",
|
||||
isActive && hasSubmenu ? "has-active" : "",
|
||||
].filter(Boolean);
|
||||
const linkChildren = [_(child.title)];
|
||||
|
||||
if (hasSubmenu) {
|
||||
linkChildren.push(
|
||||
E(
|
||||
"span",
|
||||
{
|
||||
class: "mobile-nav-chevron",
|
||||
"aria-hidden": "true",
|
||||
},
|
||||
[
|
||||
E(
|
||||
"svg",
|
||||
{
|
||||
viewBox: "0 0 20 20",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
"stroke-width": "1.8",
|
||||
"stroke-linecap": "round",
|
||||
"stroke-linejoin": "round",
|
||||
},
|
||||
[E("path", { d: "m7.5 5 5 5-5 5" })],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const li = E("li", { class: itemClasses.join(" ") }, [
|
||||
E(
|
||||
"a",
|
||||
{
|
||||
class: "mobile-nav-link",
|
||||
class: `mobile-nav-link${
|
||||
isActive && !hasSubmenu ? " nav-link-active" : ""
|
||||
}`,
|
||||
href: hasSubmenu ? "#" : L.url(url, child.name),
|
||||
},
|
||||
linkChildren,
|
||||
[_(child.title)],
|
||||
),
|
||||
]);
|
||||
|
||||
if (hasSubmenu) {
|
||||
li.appendChild(this.buildCategoryPreview(child.name, submenu));
|
||||
|
||||
const ul = E("ul", {
|
||||
class: "mobile-nav-submenu",
|
||||
style: "max-height: 0; opacity: 0;",
|
||||
});
|
||||
|
||||
submenu.forEach((item) => {
|
||||
const subActive = this.isActivePath(child.name, item.name);
|
||||
|
||||
ul.appendChild(
|
||||
E("li", { class: "mobile-nav-subitem" }, [
|
||||
E(
|
||||
"a",
|
||||
{
|
||||
class: "mobile-nav-sublink",
|
||||
class: `mobile-nav-sublink${
|
||||
subActive ? " nav-link-active" : ""
|
||||
}`,
|
||||
href: L.url(url, child.name, item.name),
|
||||
},
|
||||
[_(item.title)],
|
||||
@@ -348,100 +352,158 @@ return baseclass.extend({
|
||||
return ul;
|
||||
},
|
||||
|
||||
// page omitted/null => "is the current dispatch anywhere within this
|
||||
// top-level group"; page given => "is this exact page active".
|
||||
isActivePath(parent, page) {
|
||||
if (L.env.dispatchpath[1] !== parent) return false;
|
||||
|
||||
return page == null || L.env.dispatchpath[2] === page;
|
||||
},
|
||||
|
||||
// Decorative, aria-hidden summary used by collapsed mobile groups.
|
||||
buildCategoryPreview(parentName, submenu) {
|
||||
const nodes = [];
|
||||
|
||||
submenu.forEach((item, i) => {
|
||||
if (i) nodes.push(" · ");
|
||||
|
||||
nodes.push(
|
||||
this.isActivePath(parentName, item.name)
|
||||
? E("span", { class: "nav-preview-current" }, [_(item.title)])
|
||||
: _(item.title),
|
||||
);
|
||||
});
|
||||
|
||||
return E(
|
||||
"div",
|
||||
{ class: "nav-category-preview", "aria-hidden": "true" },
|
||||
nodes,
|
||||
);
|
||||
},
|
||||
|
||||
setSidebarSectionExpanded(item, expanded) {
|
||||
const category = item.querySelector(".nav-category");
|
||||
const section = item.querySelector(".sidebar-section");
|
||||
|
||||
item.classList.toggle("sidebar-group-open", expanded);
|
||||
category?.setAttribute("aria-expanded", expanded ? "true" : "false");
|
||||
section?.setAttribute("aria-hidden", expanded ? "false" : "true");
|
||||
|
||||
if (expanded) {
|
||||
section?.removeAttribute("inert");
|
||||
} else {
|
||||
section?.setAttribute("inert", "");
|
||||
}
|
||||
},
|
||||
|
||||
renderSidebar(children, url) {
|
||||
const list = document.querySelector("#sidebar-list");
|
||||
const footer = document.querySelector("#sidebar-footer");
|
||||
|
||||
if (!list) return;
|
||||
|
||||
const KEY = "aurora.sidebarSections";
|
||||
const collapsed = new Set(JSON.parse(localStorage.getItem(KEY) || "[]"));
|
||||
const crumb = [];
|
||||
|
||||
const link = (href, title) =>
|
||||
E("a", { class: "sidebar-link", href }, [_(title)]);
|
||||
const link = (href, title, active) =>
|
||||
E("a", { class: `nav-link${active ? " nav-link-active" : ""}`, href }, [
|
||||
_(title),
|
||||
]);
|
||||
|
||||
children.forEach((child) => {
|
||||
const submenu = ui.menu.getChildren(child);
|
||||
const inGroup = this.isActivePath(child.name);
|
||||
|
||||
if (L.env.dispatchpath[1] === child.name) {
|
||||
if (inGroup) {
|
||||
crumb.push(_(child.title));
|
||||
|
||||
const page = submenu.find(
|
||||
(item) => L.env.dispatchpath[2] === item.name,
|
||||
const page = submenu.find((item) =>
|
||||
this.isActivePath(child.name, item.name),
|
||||
);
|
||||
if (page) crumb.push(_(page.title));
|
||||
}
|
||||
|
||||
if (child.name === "logout") {
|
||||
(footer || list).appendChild(link(L.url(url, child.name), child.title));
|
||||
(footer || list).appendChild(
|
||||
link(L.url(url, child.name), child.title, false),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!submenu.length) {
|
||||
const active = L.env.dispatchpath[1] === child.name;
|
||||
|
||||
list.appendChild(
|
||||
E("li", { class: active ? "active" : "" }, [
|
||||
link(L.url(url, child.name), child.title),
|
||||
]),
|
||||
E("li", {}, [link(L.url(url, child.name), child.title, inGroup)]),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const sectionId = `sidebar-section-${String(child.name).replace(
|
||||
/[^A-Za-z0-9_-]/g,
|
||||
"-",
|
||||
)}`;
|
||||
const ul = E("ul", { class: "sidebar-submenu" });
|
||||
|
||||
submenu.forEach((item) => {
|
||||
const active =
|
||||
L.env.dispatchpath[1] === child.name &&
|
||||
L.env.dispatchpath[2] === item.name;
|
||||
|
||||
ul.appendChild(
|
||||
E("li", { class: active ? "active" : "" }, [
|
||||
link(L.url(url, child.name, item.name), item.title),
|
||||
E("li", {}, [
|
||||
link(
|
||||
L.url(url, child.name, item.name),
|
||||
item.title,
|
||||
this.isActivePath(child.name, item.name),
|
||||
),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
const sectionAttrs = {
|
||||
class: "sidebar-section",
|
||||
id: sectionId,
|
||||
"aria-hidden": inGroup ? "false" : "true",
|
||||
};
|
||||
|
||||
if (!inGroup) sectionAttrs.inert = "";
|
||||
|
||||
const groupClasses = [
|
||||
"sidebar-group",
|
||||
inGroup ? "sidebar-group-open" : "",
|
||||
].filter(Boolean);
|
||||
|
||||
list.appendChild(
|
||||
E(
|
||||
"li",
|
||||
{
|
||||
class: collapsed.has(child.name) ? "section-collapsed" : "",
|
||||
class: groupClasses.join(" "),
|
||||
"data-section": child.name,
|
||||
},
|
||||
[
|
||||
E(
|
||||
"span",
|
||||
"button",
|
||||
{
|
||||
class: "sidebar-category",
|
||||
role: "button",
|
||||
"aria-expanded": !collapsed.has(child.name),
|
||||
class: "nav-category",
|
||||
type: "button",
|
||||
"aria-expanded": inGroup ? "true" : "false",
|
||||
"aria-controls": sectionId,
|
||||
},
|
||||
[
|
||||
_(child.title),
|
||||
E(
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>',
|
||||
),
|
||||
],
|
||||
[E("span", { class: "nav-category-label" }, [_(child.title)])],
|
||||
),
|
||||
E("div", { class: "sidebar-section" }, [ul]),
|
||||
E("div", sectionAttrs, [ul]),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
list.addEventListener("click", (e) => {
|
||||
const category = e.target.closest(".sidebar-category");
|
||||
const item = category?.parentNode;
|
||||
const category = e.target.closest(".nav-category");
|
||||
const item = category?.closest(".sidebar-group");
|
||||
|
||||
if (!item?.dataset.section) return;
|
||||
if (!item || !list.contains(item)) return;
|
||||
|
||||
const isCollapsed = item.classList.toggle("section-collapsed");
|
||||
category.setAttribute("aria-expanded", !isCollapsed);
|
||||
const shouldOpen = !item.classList.contains("sidebar-group-open");
|
||||
|
||||
collapsed[isCollapsed ? "add" : "delete"](item.dataset.section);
|
||||
localStorage.setItem(KEY, JSON.stringify([...collapsed]));
|
||||
list.querySelectorAll(".sidebar-group-open").forEach((group) => {
|
||||
if (group !== item) this.setSidebarSectionExpanded(group, false);
|
||||
});
|
||||
|
||||
this.setSidebarSectionExpanded(item, shouldOpen);
|
||||
});
|
||||
|
||||
const crumbEl = document.querySelector("#header-crumb");
|
||||
@@ -454,6 +516,52 @@ return baseclass.extend({
|
||||
});
|
||||
},
|
||||
|
||||
// Shared scaffolding for the two desktop dropdown modes (mega-menu and
|
||||
// boxed-dropdown): builds the top-level `.menu` link + its `.desktop-nav`
|
||||
// panel. Hover/activation behaviour differs per mode and is wired by the
|
||||
// caller on the returned nodes.
|
||||
buildDropdownItem(child, url, ul) {
|
||||
const submenu = ui.menu.getChildren(child);
|
||||
const hasSubmenu = submenu.length > 0;
|
||||
|
||||
const li = E("li", {}, [
|
||||
E(
|
||||
"a",
|
||||
{
|
||||
class: "menu",
|
||||
href: hasSubmenu ? "#" : L.url(url, child.name),
|
||||
},
|
||||
[_(child.title)],
|
||||
),
|
||||
]);
|
||||
|
||||
ul.appendChild(li);
|
||||
|
||||
const menuLink = li.querySelector("a");
|
||||
let nav = null;
|
||||
|
||||
if (hasSubmenu) {
|
||||
nav = E("div", { class: "desktop-nav" }, [
|
||||
this.renderMainMenu(child, `${url}/${child.name}`, 1),
|
||||
]);
|
||||
li.appendChild(nav);
|
||||
menuLink.addEventListener("click", (e) => e.preventDefault());
|
||||
}
|
||||
|
||||
return { li, nav, menuLink, hasSubmenu };
|
||||
},
|
||||
|
||||
// Deactivate every open dropdown except the given one. Pass null for both
|
||||
// to close all (used by hideDesktopNav).
|
||||
deactivateDesktopNavExcept(nav, menuLink) {
|
||||
document.querySelectorAll(".desktop-nav").forEach((n) => {
|
||||
if (n !== nav) n.classList.remove("active");
|
||||
});
|
||||
document.querySelectorAll("#topmenu a").forEach((a) => {
|
||||
if (a !== menuLink) a.classList.remove("menu-active");
|
||||
});
|
||||
},
|
||||
|
||||
initMegaMenu(children, url, ul) {
|
||||
const container = document.querySelector(".desktop-menu-container");
|
||||
const overlay = document.querySelector(".desktop-menu-overlay");
|
||||
@@ -465,90 +573,50 @@ return baseclass.extend({
|
||||
let hideTimer = null;
|
||||
|
||||
children.forEach((child) => {
|
||||
const submenu = ui.menu.getChildren(child);
|
||||
const hasSubmenu = submenu.length > 0;
|
||||
|
||||
const li = E(
|
||||
"li",
|
||||
{
|
||||
class: hasSubmenu ? "has-desktop-nav" : "",
|
||||
},
|
||||
[
|
||||
E(
|
||||
"a",
|
||||
{
|
||||
class: "menu",
|
||||
href: hasSubmenu ? "#" : L.url(url, child.name),
|
||||
},
|
||||
[_(child.title)],
|
||||
),
|
||||
],
|
||||
const { li, nav, menuLink, hasSubmenu } = this.buildDropdownItem(
|
||||
child,
|
||||
url,
|
||||
ul,
|
||||
);
|
||||
if (!hasSubmenu) return;
|
||||
|
||||
ul.appendChild(li);
|
||||
li.addEventListener("mouseenter", () => {
|
||||
if (hideTimer) {
|
||||
clearTimeout(hideTimer);
|
||||
hideTimer = null;
|
||||
}
|
||||
|
||||
if (hasSubmenu) {
|
||||
const nav = E(
|
||||
"div",
|
||||
{
|
||||
class: "desktop-nav",
|
||||
},
|
||||
[this.renderMainMenu(child, `${url}/${child.name}`, 1)],
|
||||
);
|
||||
showTimer = setTimeout(() => {
|
||||
const wasActive = nav.classList.contains("active");
|
||||
|
||||
li.appendChild(nav);
|
||||
this.deactivateDesktopNavExcept(nav, menuLink);
|
||||
|
||||
const menuLink = li.querySelector("a");
|
||||
if (wasActive) return;
|
||||
|
||||
li.addEventListener("mouseenter", () => {
|
||||
if (hideTimer) {
|
||||
clearTimeout(hideTimer);
|
||||
hideTimer = null;
|
||||
menuLink.classList.add("menu-active");
|
||||
const navHeight = nav.scrollHeight;
|
||||
const headerHeight =
|
||||
header.querySelector(".header-content")?.offsetHeight || 56;
|
||||
const totalHeight = headerHeight + navHeight;
|
||||
|
||||
if (container) {
|
||||
container.style.setProperty(
|
||||
"--mega-menu-height",
|
||||
`${totalHeight}px`,
|
||||
);
|
||||
container.classList.add("active");
|
||||
overlay.classList.add("active");
|
||||
}
|
||||
nav.classList.add("active");
|
||||
}, 100);
|
||||
});
|
||||
|
||||
showTimer = setTimeout(() => {
|
||||
const wasActive = nav.classList.contains("active");
|
||||
|
||||
document.querySelectorAll(".desktop-nav").forEach((n) => {
|
||||
if (n !== nav) n.classList.remove("active");
|
||||
});
|
||||
|
||||
document.querySelectorAll("#topmenu a").forEach((a) => {
|
||||
if (a !== menuLink) a.classList.remove("menu-active");
|
||||
});
|
||||
|
||||
if (wasActive) return;
|
||||
|
||||
menuLink.classList.add("menu-active");
|
||||
header.classList.add("has-desktop-nav");
|
||||
const navHeight = nav.scrollHeight;
|
||||
const headerHeight =
|
||||
header.querySelector(".header-content")?.offsetHeight || 56;
|
||||
const totalHeight = headerHeight + navHeight;
|
||||
|
||||
if (container) {
|
||||
container.style.setProperty(
|
||||
"--mega-menu-height",
|
||||
`${totalHeight}px`,
|
||||
);
|
||||
container.classList.add("active");
|
||||
overlay.classList.add("active");
|
||||
}
|
||||
nav.classList.add("active");
|
||||
}, 100);
|
||||
});
|
||||
|
||||
li.addEventListener("mouseleave", () => {
|
||||
if (showTimer) {
|
||||
clearTimeout(showTimer);
|
||||
showTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
menuLink.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
}
|
||||
li.addEventListener("mouseleave", () => {
|
||||
if (showTimer) {
|
||||
clearTimeout(showTimer);
|
||||
showTimer = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const hideMenu = () => {
|
||||
@@ -577,98 +645,52 @@ return baseclass.extend({
|
||||
|
||||
initBoxedDropdown(children, url, ul) {
|
||||
children.forEach((child) => {
|
||||
const submenu = ui.menu.getChildren(child);
|
||||
const hasSubmenu = submenu.length > 0;
|
||||
const { li, nav, menuLink, hasSubmenu } = this.buildDropdownItem(
|
||||
child,
|
||||
url,
|
||||
ul,
|
||||
);
|
||||
if (!hasSubmenu) return;
|
||||
|
||||
const li = E("li", {}, [
|
||||
E(
|
||||
"a",
|
||||
{
|
||||
class: "menu",
|
||||
href: hasSubmenu ? "#" : L.url(url, child.name),
|
||||
},
|
||||
[_(child.title)],
|
||||
),
|
||||
]);
|
||||
let showTimer = null;
|
||||
let hideTimer = null;
|
||||
|
||||
ul.appendChild(li);
|
||||
li.addEventListener("mouseenter", () => {
|
||||
if (hideTimer) {
|
||||
clearTimeout(hideTimer);
|
||||
hideTimer = null;
|
||||
}
|
||||
|
||||
if (hasSubmenu) {
|
||||
const nav = E(
|
||||
"div",
|
||||
{
|
||||
class: "desktop-nav",
|
||||
},
|
||||
[this.renderMainMenu(child, `${url}/${child.name}`, 1)],
|
||||
);
|
||||
showTimer = setTimeout(() => {
|
||||
this.deactivateDesktopNavExcept(nav, menuLink);
|
||||
menuLink.classList.add("menu-active");
|
||||
nav.classList.add("active");
|
||||
}, 100);
|
||||
});
|
||||
|
||||
li.appendChild(nav);
|
||||
li.addEventListener("mouseleave", () => {
|
||||
if (showTimer) {
|
||||
clearTimeout(showTimer);
|
||||
showTimer = null;
|
||||
}
|
||||
|
||||
const menuLink = li.querySelector("a");
|
||||
let showTimer = null;
|
||||
let hideTimer = null;
|
||||
|
||||
li.addEventListener("mouseenter", () => {
|
||||
if (hideTimer) {
|
||||
clearTimeout(hideTimer);
|
||||
hideTimer = null;
|
||||
}
|
||||
|
||||
showTimer = setTimeout(() => {
|
||||
document.querySelectorAll(".desktop-nav").forEach((n) => {
|
||||
if (n !== nav) n.classList.remove("active");
|
||||
});
|
||||
|
||||
document.querySelectorAll("#topmenu a").forEach((a) => {
|
||||
if (a !== menuLink) a.classList.remove("menu-active");
|
||||
});
|
||||
|
||||
menuLink.classList.add("menu-active");
|
||||
nav.classList.add("active");
|
||||
}, 100);
|
||||
});
|
||||
|
||||
li.addEventListener("mouseleave", () => {
|
||||
if (showTimer) {
|
||||
clearTimeout(showTimer);
|
||||
showTimer = null;
|
||||
}
|
||||
|
||||
hideTimer = setTimeout(() => {
|
||||
nav.classList.remove("active");
|
||||
menuLink.classList.remove("menu-active");
|
||||
}, 150);
|
||||
});
|
||||
|
||||
menuLink.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
}
|
||||
hideTimer = setTimeout(() => {
|
||||
nav.classList.remove("active");
|
||||
menuLink.classList.remove("menu-active");
|
||||
}, 150);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
// Only ever called from mega-menu mode (the boxed dropdown closes itself
|
||||
// per-item on mouseleave), so it always performs the mega-menu cleanup.
|
||||
hideDesktopNav() {
|
||||
const navType = document.body?.dataset?.navType || "mega-menu";
|
||||
this.deactivateDesktopNavExcept(null, null);
|
||||
|
||||
document
|
||||
.querySelectorAll(".desktop-nav")
|
||||
.forEach((nav) => nav.classList.remove("active"));
|
||||
document
|
||||
.querySelectorAll("#topmenu a")
|
||||
.forEach((a) => a.classList.remove("menu-active"));
|
||||
|
||||
if (navType === "mega-menu") {
|
||||
document.querySelector("header")?.classList.remove("has-desktop-nav");
|
||||
|
||||
const container = document.querySelector(".desktop-menu-container");
|
||||
if (container) {
|
||||
container.classList.remove("active");
|
||||
}
|
||||
|
||||
document
|
||||
.querySelector(".desktop-menu-overlay")
|
||||
?.classList.remove("active");
|
||||
}
|
||||
.querySelector(".desktop-menu-container")
|
||||
?.classList.remove("active");
|
||||
document.querySelector(".desktop-menu-overlay")?.classList.remove("active");
|
||||
},
|
||||
|
||||
renderModeMenu(tree) {
|
||||
|
||||
@@ -10,4 +10,5 @@ yarn.lock
|
||||
.DS_Store
|
||||
|
||||
# Superpowers AI workflow docs
|
||||
docs/superpowers/
|
||||
docs/superpowers/
|
||||
.superpowers/
|
||||
@@ -8,8 +8,8 @@ include $(TOPDIR)/rules.mk
|
||||
LUCI_TITLE:=Aurora Theme (A modern browser theme built with Vite and Tailwind CSS)
|
||||
LUCI_DEPENDS:=+luci-base
|
||||
|
||||
PKG_VERSION:=0.12.5
|
||||
PKG_RELEASE:=22
|
||||
PKG_VERSION:=0.12.6
|
||||
PKG_RELEASE:=23
|
||||
PKG_LICENSE:=Apache-2.0
|
||||
|
||||
LUCI_MINIFY_CSS:=
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
/*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */
|
||||
@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-animation-delay:0s;--tw-animation-direction:normal;--tw-animation-duration:initial;--tw-animation-fill-mode:none;--tw-animation-iteration-count:1;--tw-enter-blur:0;--tw-enter-opacity:1;--tw-enter-rotate:0;--tw-enter-scale:1;--tw-enter-translate-x:0;--tw-enter-translate-y:0;--tw-exit-blur:0;--tw-exit-opacity:1;--tw-exit-rotate:0;--tw-exit-scale:1;--tw-exit-translate-x:0;--tw-exit-translate-y:0;--tw-leading:initial;--tw-font-weight:initial;--tw-duration:initial;--tw-tracking:initial;--tw-border-style:solid;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-ease:initial;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-content:"";--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-space-y-reverse:0;--tw-border-spacing-x:0;--tw-border-spacing-y:0}}:root,:host{--font-sans:var(--font-sans);--font-mono:var(--font-mono);--spacing:var(--spacing);--container-xs:20rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-base:1rem;--text-base--line-height:calc(1.5/1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25/1.875);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wider:.05em;--leading-tight:1.25;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--radius-2xl:calc(var(--radius-base)*2);--ease-out:cubic-bezier(0,0,.2,1);--ease-in-out:cubic-bezier(.4,0,.2,1);--animate-spin:spin 1s linear infinite;--blur-sm:8px;--blur-md:12px;--blur-lg:16px;--blur-xl:24px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--container-max-width:var(--container-max-width)}*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,Line truncated
|
||||
@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-animation-delay:0s;--tw-animation-direction:normal;--tw-animation-duration:initial;--tw-animation-fill-mode:none;--tw-animation-iteration-count:1;--tw-enter-blur:0;--tw-enter-opacity:1;--tw-enter-rotate:0;--tw-enter-scale:1;--tw-enter-translate-x:0;--tw-enter-translate-y:0;--tw-exit-blur:0;--tw-exit-opacity:1;--tw-exit-rotate:0;--tw-exit-scale:1;--tw-exit-translate-x:0;--tw-exit-translate-y:0;--tw-leading:initial;--tw-font-weight:initial;--tw-duration:initial;--tw-tracking:initial;--tw-border-style:solid;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-ease:initial;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-content:"";--tw-space-y-reverse:0;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-border-spacing-x:0;--tw-border-spacing-y:0}}:root,:host{--font-sans:var(--font-sans);--font-mono:var(--font-mono);--spacing:var(--spacing);--container-xs:20rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-base:1rem;--text-base--line-height:calc(1.5/1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25/1.875);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--leading-tight:1.25;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--radius-2xl:calc(var(--radius-base)*2);--ease-out:cubic-bezier(0,0,.2,1);--ease-in-out:cubic-bezier(.4,0,.2,1);--animate-spin:spin 1s linear infinite;--blur-sm:8px;--blur-md:12px;--blur-lg:16px;--blur-xl:24px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--container-max-width:var(--container-max-width)}*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{lLine truncated
|
||||
@@ -1 +1 @@
|
||||
"use strict";"require baseclass";"require ui";return baseclass.extend({__init__(){ui.menu.load().then(tree=>this.render(tree));this.initNavigationControls();this.initUciIndicator()},initUciIndicator(){const original=ui.changes?.setIndicator;if(!original)return;ui.changes.setIndicator=function(n){original.call(this,n);document.querySelector('[data-indicator="uci-changes"]')?.setAttribute("data-count",n||0)}},initNavigationControls(){const overlay=document.querySelector("#mobile-menu-overlay");const navigationToggle=document.querySelector("#navigation-toggle");if(!navigationToggle||!overlay)return;const desktop=window.matchMedia("(min-width: 768px)");const SIDEBAR_COLLAPSED_KEY="aurora.sidebarCollapsed";const isDesktopSidebar=()=>desktop.matches&&document.body.dataset.navType==="sidebar";const resetMobileSubmenus=()=>{document.querySelectorAll(".mobile-nav-item.submenu-expanded").forEach(item=>{item.classList.remove("submenu-expanded");const submenu=item.querySelector(".mobile-nav-submenu");if(submenu){submenu.style.maxHeight="0";submenu.style.opacity="0"}})};const updateToggleState=expanded=>{navigationToggle.classList.toggle("is-expanded",expanded);navigationToggle.setAttribute("aria-expanded",expanded)};const closeMobileNavigation=()=>{overlay.classList.remove("mobile-menu-open");document.body.style.overflow="";resetMobileSubmenus()};const getNavigationExpanded=()=>{if(isDesktopSidebar()){return!document.body.classList.contains("sidebar-collapsed")}return!desktop.matches&&overlay.classList.contains("mobile-menu-open")};const setNavigationExpanded=expanded=>{if(isDesktopSidebar()){closeMobileNavigation();const collapsed=!expanded;document.body.classList.toggle("sidebar-collapsed",collapsed);localStorage.setItem(SIDEBAR_COLLAPSED_KEY,collapsed);updateToggleState(expanded);return}if(desktop.matches){closeMobileNavigation();updateToggleState(false);return}overlay.classList.toggle("mobile-menu-open",expanded);document.body.style.overflow=expanded?"hidden":"";if(!expanded)resetMobileSubmenus();updateToggleState(expanded)};const toggleNavigation=()=>setNavigationExpanded(!getNavigationExpanded());const syncNavigationState=()=>{closeMobileNavigation();if(isDesktopSidebar()){const collapsed=localStorage.getItem(SIDEBAR_COLLAPSED_KEY)==="true";document.body.classList.toggle("sidebar-collapsed",collapsed);updateToggleState(!collapsed);return}updateToggleState(false)};navigationToggle.addEventListener("click",e=>{e.stopPropagation();toggleNavigation()});overlay.addEventListener("click",e=>{if(e.target===overlay)setNavigationExpanded(false)});document.addEventListener("keydown",e=>{if(e.key==="Escape"&&overlay.classList.contains("mobile-menu-open")){setNavigationExpanded(false)}});desktop.addEventListener("change",syncNavigationState);syncNavigationState();document.addEventListener("click",e=>{const link=e.target.closest(".mobile-nav-link");if(!link)return;const item=link.closest(".mobile-nav-item");const submenu=item?.querySelector(".mobile-nav-submenu");if(submenu){e.preventDefault();e.stopPropagation();const isExpanded=item.classList.contains("submenu-expanded");document.querySelectorAll(".mobile-nav-item.submenu-expanded").forEach(i=>{if(i!==item){i.classList.remove("submenu-expanded");const s=i.querySelector(".mobile-nav-submenu");if(s){s.style.maxHeight="0";s.style.opacity="0"}}});item.classList.toggle("submenu-expanded",!isExpanded);submenu.style.maxHeight=isExpanded?"0":`${submenu.scrollHeight}px`;submenu.style.opacity=isExpanded?"0":"1"}})},renderMobileMenu(tree,url){const list=document.querySelector("#mobile-nav-list");const footerAction=document.querySelector("#mobile-nav-footer-action");const children=ui.menu.getChildren(tree);if(!list||!footerAction||!children.length)return;list.innerHTML="";footerAction.innerHTML="";children.forEach(child=>{const submenu=ui.menu.getChildren(child);const hasSubmenu=submenu.length>0;const isActive=L.env.dispatchpath[1]===child.name;if(child.name==="logout"){footerAction.appendChild(E("a",{class:"mobile-nav-logout",href:L.url(url,child.name)},[_(child.title)]));return}const itemClasses=["mobile-nav-item",hasSubmenu?"has-submenu":"",isActive?"active":""].filter(Boolean);const linkChildren=[_(child.title)];if(hasSubmenu){linkChildren.push(E("span",{class:"mobile-nav-chevron","aria-hidden":"true"},[E("svg",{viewBox:"0 0 20 20",fill:"none",stroke:"currentColor","stroke-width":"1.8","stroke-linecap":"round","stroke-linejoin":"round"},[E("path",{d:"m7.5 5 5 5-5 5"})])]))}const li=E("li",{class:itemClasses.join(" ")},[E("a",{class:"mobile-nav-link",href:hasSubmenu?"#":L.url(url,child.name)},linkChildren)]);if(hasSubmenu){const ul=E("ul",{class:"mobile-nav-submenu",style:"max-height: 0; opacity: 0;"});submenu.forEach(item=>{ul.appendChild(E("li",{class:"mobile-nav-subitem"},[E("a",{class:"mobile-nav-sublink",href:L.url(url,child.name,item.name)},[_(item.title)])]))});li.appendChild(ul)}list.appendChild(li)})},render(tree){this.renderModeMenu(tree);if(L.env.dispatchpath.length>=3){lLine truncated
|
||||
"use strict";"require baseclass";"require ui";return baseclass.extend({__init__(){ui.menu.load().then(tree=>this.render(tree));this.initNavigationControls();this.initUciIndicator()},initUciIndicator(){const original=ui.changes?.setIndicator;if(!original)return;ui.changes.setIndicator=function(n){original.call(this,n);document.querySelector('[data-indicator="uci-changes"]')?.setAttribute("data-count",n||0)}},initNavigationControls(){const overlay=document.querySelector("#mobile-menu-overlay");const navigationToggle=document.querySelector("#navigation-toggle");if(!navigationToggle||!overlay)return;const desktop=window.matchMedia("(min-width: 768px)");const SIDEBAR_COLLAPSED_KEY="aurora.sidebarCollapsed";const isDesktopSidebar=()=>desktop.matches&&document.body.dataset.navType==="sidebar";const resetMobileSubmenus=()=>{document.querySelectorAll(".mobile-nav-item.submenu-expanded").forEach(item=>{item.classList.remove("submenu-expanded");const submenu=item.querySelector(".mobile-nav-submenu");if(submenu){submenu.style.maxHeight="0";submenu.style.opacity="0"}})};const expandActiveMobileGroup=()=>{const item=document.querySelector(".mobile-nav-item.has-active");const submenu=item?.querySelector(".mobile-nav-submenu");if(!item||!submenu)return;item.classList.add("submenu-expanded");submenu.style.maxHeight=`${submenu.scrollHeight}px`;submenu.style.opacity="1"};const updateToggleState=expanded=>{navigationToggle.classList.toggle("is-expanded",expanded&&!desktop.matches);navigationToggle.setAttribute("aria-expanded",expanded)};const closeMobileNavigation=()=>{overlay.classList.remove("mobile-menu-open");document.body.style.overflow="";resetMobileSubmenus()};const getNavigationExpanded=()=>{if(isDesktopSidebar()){return!document.body.classList.contains("sidebar-collapsed")}return!desktop.matches&&overlay.classList.contains("mobile-menu-open")};const setNavigationExpanded=expanded=>{if(isDesktopSidebar()){closeMobileNavigation();const collapsed=!expanded;document.body.classList.toggle("sidebar-collapsed",collapsed);localStorage.setItem(SIDEBAR_COLLAPSED_KEY,collapsed);updateToggleState(expanded);return}if(desktop.matches){closeMobileNavigation();updateToggleState(false);return}overlay.classList.toggle("mobile-menu-open",expanded);document.body.style.overflow=expanded?"hidden":"";if(expanded)expandActiveMobileGroup();else resetMobileSubmenus();updateToggleState(expanded)};const toggleNavigation=()=>setNavigationExpanded(!getNavigationExpanded());const syncNavigationState=()=>{closeMobileNavigation();if(isDesktopSidebar()){const collapsed=localStorage.getItem(SIDEBAR_COLLAPSED_KEY)==="true";document.body.classList.toggle("sidebar-collapsed",collapsed);updateToggleState(!collapsed);return}updateToggleState(false)};navigationToggle.addEventListener("click",e=>{e.stopPropagation();toggleNavigation()});overlay.addEventListener("click",e=>{if(e.target===overlay)setNavigationExpanded(false)});document.addEventListener("keydown",e=>{if(e.key==="Escape"&&overlay.classList.contains("mobile-menu-open")){setNavigationExpanded(false)}});desktop.addEventListener("change",syncNavigationState);syncNavigationState();document.addEventListener("click",e=>{const link=e.target.closest(".mobile-nav-link");if(!link)return;const item=link.closest(".mobile-nav-item");const submenu=item?.querySelector(".mobile-nav-submenu");if(submenu){e.preventDefault();e.stopPropagation();const isExpanded=item.classList.contains("submenu-expanded");document.querySelectorAll(".mobile-nav-item.submenu-expanded").forEach(i=>{if(i!==item){i.classList.remove("submenu-expanded");const s=i.querySelector(".mobile-nav-submenu");if(s){s.style.maxHeight="0";s.style.opacity="0"}}});item.classList.toggle("submenu-expanded",!isExpanded);submenu.style.maxHeight=isExpanded?"0":`${submenu.scrollHeight}px`;submenu.style.opacity=isExpanded?"0":"1"}})},renderMobileMenu(tree,url){const list=document.querySelector("#mobile-nav-list");const footerAction=document.querySelector("#mobile-nav-footer-action");const children=ui.menu.getChildren(tree);if(!list||!footerAction||!children.length)return;list.innerHTML="";footerAction.innerHTML="";children.forEach(child=>{const submenu=ui.menu.getChildren(child);const hasSubmenu=submenu.length>0;const isActive=this.isActivePath(child.name);if(child.name==="logout"){footerAction.appendChild(E("a",{class:"mobile-nav-logout",href:L.url(url,child.name)},[_(child.title)]));return}const itemClasses=["mobile-nav-item",hasSubmenu?"has-submenu":"",isActive&&hasSubmenu?"has-active":""].filter(Boolean);const li=E("li",{class:itemClasses.join(" ")},[E("a",{class:`mobile-nav-link${isActive&&!hasSubmenu?" nav-link-active":""}`,href:hasSubmenu?"#":L.url(url,child.name)},[_(child.title)])]);if(hasSubmenu){li.appendChild(this.buildCategoryPreview(child.name,submenu));const ul=E("ul",{class:"mobile-nav-submenu",style:"max-height: 0; opacity: 0;"});submenu.forEach(item=>{const subActive=this.isActivePath(child.name,item.name);ul.appendChild(E("li",{class:"mobileLine truncated
|
||||
@@ -9,7 +9,7 @@ include $(TOPDIR)/rules.mk
|
||||
LUCI_TITLE:=Spectra Theme
|
||||
LUCI_DEPENDS:=+curl +php8 +php8-cgi +php8-fpm +php8-mod-curl +php8-mod-zip +php8-mod-mbstring +ffmpeg
|
||||
PKG_VERSION:=27.574.74123~9e4b7f2
|
||||
PKG_RELEASE:=4
|
||||
PKG_RELEASE:=5
|
||||
|
||||
LUCI_MINIFY_CSS:=0
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
location /spectra/ {
|
||||
location ~ \.php$ {
|
||||
fastcgi_param HTTP_PROXY "";
|
||||
fastcgi_connect_timeout 300s;
|
||||
fastcgi_read_timeout 300s;
|
||||
fastcgi_send_timeout 300s;
|
||||
fastcgi_buffer_size 32k;
|
||||
fastcgi_buffers 4 32k;
|
||||
fastcgi_busy_buffers_size 64k;
|
||||
fastcgi_temp_file_write_size 64k;
|
||||
include fastcgi_params;
|
||||
fastcgi_pass 127.0.0.1:1026;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=mihomo-alpha
|
||||
PKG_VERSION:=2026.06.10
|
||||
PKG_RELEASE:=11
|
||||
PKG_VERSION:=2026.06.11
|
||||
PKG_RELEASE:=12
|
||||
|
||||
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
|
||||
PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION)
|
||||
PKG_SOURCE_PROTO:=git
|
||||
PKG_SOURCE_URL:=https://github.com/MetaCubeX/mihomo.git
|
||||
PKG_SOURCE_VERSION:=1686d5633fc2f945fe0e753f3f6916df04b3a3b2
|
||||
PKG_SOURCE_VERSION:=084a09e4af60d6579a97ef344b31ddd62af44c1c
|
||||
PKG_MIRROR_HASH:=skip
|
||||
|
||||
PKG_LICENSE:=GPL3.0+
|
||||
@@ -18,7 +18,7 @@ PKG_BUILD_DEPENDS:=golang/host
|
||||
PKG_BUILD_PARALLEL:=1
|
||||
PKG_BUILD_FLAGS:=no-mips16
|
||||
|
||||
PKG_BUILD_VERSION:=alpha-1686d56
|
||||
PKG_BUILD_VERSION:=alpha-084a09e
|
||||
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)-$(PKG_VERSION)
|
||||
PKG_BUILD_TIME:=$(shell date -u -Iseconds)
|
||||
|
||||
|
||||
+2
-2
@@ -10,12 +10,12 @@ include $(INCLUDE_DIR)/kernel.mk
|
||||
|
||||
PKG_NAME:=natflow
|
||||
PKG_VERSION:=20260531
|
||||
PKG_RELEASE:=12
|
||||
PKG_RELEASE:=13
|
||||
|
||||
PKG_SOURCE:=$(PKG_VERSION).tar.xz
|
||||
PKG_SOURCE_URL:=https://github.com/ptpt52/natflow.git
|
||||
PKG_SOURCE_PROTO:=git
|
||||
PKG_SOURCE_VERSION:=4b0712931b2c14556b3ac4b6aaf921aeda07846c
|
||||
PKG_SOURCE_VERSION:=15621bcb65719e12dc30c36cae15545e00f4fd6e
|
||||
PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION)
|
||||
PKG_MAINTAINER:=Chen Minqiang <ptpt52@gmail.com>
|
||||
PKG_LICENSE:=GPL-2.0
|
||||
|
||||
Reference in new issue
Block a user