First Push

This commit is contained in:
喆OvO
2026-08-06 11:11:11 +08:00
commit 0fef309972
551 changed files with 15793 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
.idea/
.reasonix/
__pycache__/
*.pyc
*.db
*.db.backup
.venv/
venv/
env/
.DS_Store
审计报告.md
+49
View File
@@ -0,0 +1,49 @@
# Database
*.db
*.db-journal
*.db-wal
*.db-shm
*.sqlite
*.sqlite3
*.db.backup
data.db.pre_upgrade_test
# Virtual environment
.venv/
venv/
env/
ENV/
# macOS
.DS_Store
.DS_Store?
._*
.AppleDouble
.LSOverride
.Spotlight-V100
.Trashes
# Python
__pycache__/
*.py[cod]
*.egg-info/
dist/
build/
*.egg
*.so
# IDE / editor
.vscode/
.idea/
*.swp
*.swo
*~
*.orig
# Temp files
*.tmp
*.temp
*.bak
*.log
.reasonix
prompt.md
+357
View File
@@ -0,0 +1,357 @@
import SwiftUI
import WebKit
import AuthenticationServices
import UIKit
// MARK: - AppSettings(域名存储:UserDefaults)
enum AppSettings {
static let baseURLKey = "dashboard_base_url"
/// 规范化用户输入的域名:补 https://、去尾斜杠、校验格式。
static func normalizedBaseURL(_ raw: String) -> URL? {
var s = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if s.isEmpty { return nil }
if !s.hasPrefix("http://") && !s.hasPrefix("https://") { s = "https://" + s }
while s.hasSuffix("/") { s.removeLast() }
guard let url = URL(string: s),
let scheme = url.scheme, scheme == "http" || scheme == "https",
url.host != nil else { return nil }
return url
}
}
// MARK: - Coordinator
class WebViewCoordinator: NSObject, WKNavigationDelegate {
weak var webView: WKWebView?
var onPageFinished: (() -> Void)?
var onDashboardIntercepted: ((URL) -> Void)?
var onPasskeyIntercepted: ((URL) -> Void)?
func load(url: URL) {
// Caller must set onPageFinished BEFORE calling load()
webView?.load(URLRequest(url: url))
}
func webView(_: WKWebView, didFinish _: WKNavigation!) { onPageFinished?() }
func webView(_: WKWebView, didFail _: WKNavigation!, withError _: Error) { onPageFinished?() }
func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError _: Error) { onPageFinished?() }
func webView(_: WKWebView, decidePolicyFor navAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
guard let url = navAction.request.url else { decisionHandler(.allow); return }
// dashboard:// 回调(Passkey 小窗完成/失败)
if url.scheme == "dashboard" {
onDashboardIntercepted?(url)
decisionHandler(.cancel)
return
}
// /login 不再拦截:登录页在 WKWebView 内直接渲染(与网页版一致),
// cookie 直接进入主容器会话;仅当页面内发起 Passkey 时,中间件
// 跳 /passkey,这里拦截并在 ASWebAuthenticationSession 小窗中打开。
if url.path == "/passkey" || url.path.hasPrefix("/passkey/") {
onPasskeyIntercepted?(url)
decisionHandler(.cancel)
return
}
decisionHandler(.allow)
}
}
// MARK: - 登录小窗(ASWebAuthenticationSession)
class AuthSessionManager: NSObject, ASWebAuthenticationPresentationContextProviding {
private var currentSession: ASWebAuthenticationSession?
func start(url: URL, scheme: String, completion: @escaping (URL?) -> Void) {
let s = ASWebAuthenticationSession(url: url, callbackURLScheme: scheme) { cb, _ in
self.currentSession = nil
completion(cb)
}
// 每次小窗使用全新的临时会话,不含旧 cookie —— 修复"App 已登出但小窗
// 仍带旧 cookie 直接进入主页 → 无法登录 → 跳回 /login → 无限循环"的问题。
s.prefersEphemeralWebBrowserSession = true
s.presentationContextProvider = self
currentSession = s
s.start()
}
func presentationAnchor(for _: ASWebAuthenticationSession) -> ASPresentationAnchor {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }.flatMap { $0.windows }
.first { $0.isKeyWindow } ?? ASPresentationAnchor()
}
}
struct WebView: UIViewRepresentable {
let url: URL
let coordinator: WebViewCoordinator
var onPageFinished: (() -> Void)?
func makeUIView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
config.mediaTypesRequiringUserActionForPlayback = []
config.allowsInlineMediaPlayback = true
// 注入 iOS 套壳 App 标志:Web 端 Passkey 中间件据此判定"当前运行在 App 内",
// 从而跳转 /passkey 专有页面走原生 ASAuthorizationController。
// 用注入标志而非覆盖 User-Agent,避免破坏真实 UA 导致服务器/反代误判。
let script = WKUserScript(source: "window.__CAL_DISPLAY_APP__ = true;",
injectionTime: .atDocumentStart,
forMainFrameOnly: true)
config.userContentController.addUserScript(script)
let wv = WKWebView(frame: .zero, configuration: config)
wv.navigationDelegate = context.coordinator
wv.scrollView.bounces = true
context.coordinator.webView = wv
context.coordinator.onPageFinished = onPageFinished
wv.load(URLRequest(url: url))
return wv
}
func updateUIView(_: WKWebView, context _: Context) {}
func makeCoordinator() -> WebViewCoordinator { coordinator }
}
// MARK: - 域名设置 / 欢迎页(iPhone 上下排列,iPad 左右排列)
struct DomainSetupView: View {
@Environment(\.horizontalSizeClass) private var sizeClass
@AppStorage(AppSettings.baseURLKey) private var baseURLString = ""
@State private var input = ""
@State private var showInput = false // false=欢迎页(已有域名);true=填写页
@State private var error: String?
var onEnter: () -> Void
private var hasDomain: Bool { !baseURLString.isEmpty }
private var displayURL: String {
AppSettings.normalizedBaseURL(baseURLString)?.absoluteString ?? baseURLString
}
var body: some View {
ZStack {
Color(red: 15/255, green: 17/255, blue: 23/255).ignoresSafeArea()
// iPhone(compact)上下排列;iPad(regular)左右排列
if sizeClass == .compact {
VStack(spacing: 24) { content }.padding(32)
} else {
HStack(spacing: 40) { content }.padding(56)
}
}
.onAppear {
showInput = !hasDomain
if hasDomain { input = baseURLString }
}
}
@ViewBuilder private var content: some View {
if hasDomain && !showInput {
// ─── 欢迎页 ───
Group {
Text("🎛️").font(.system(size: 48))
Text("欢迎回来!").font(.largeTitle.weight(.semibold)).foregroundColor(.white)
Text(displayURL).font(.subheadline).foregroundColor(.white.opacity(0.55))
Button(action: { onEnter() }) {
Text("继续").font(.headline).foregroundColor(.white)
.frame(minWidth: 160).padding(.vertical, 12).padding(.horizontal, 32)
.background(Color(red: 74/255, green: 144/255, blue: 217/255))
.cornerRadius(12)
}
Button(action: { showInput = true; input = baseURLString }) {
Text("更改域名").font(.subheadline).foregroundColor(.white.opacity(0.7))
.padding(.vertical, 8).padding(.horizontal, 20)
}
}
} else {
// ─── 填写页 ───
Group {
Text("您好!请填写连接域名").font(.title2.weight(.semibold)).foregroundColor(.white)
TextField("https://your-domain.com", text: $input)
.textFieldStyle(PlainTextFieldStyle())
.keyboardType(.URL)
.autocapitalization(.none)
.foregroundColor(.white)
.padding(14)
.background(Color.white.opacity(0.08))
.cornerRadius(12)
.frame(maxWidth: 420)
if let error = error {
Text(error).font(.footnote).foregroundColor(Color(red: 224/255, green: 85/255, blue: 85/255))
}
Button(action: save) {
Text("保存").font(.headline).foregroundColor(.white)
.frame(minWidth: 160).padding(.vertical, 12).padding(.horizontal, 32)
.background(Color(red: 74/255, green: 144/255, blue: 217/255))
.cornerRadius(12)
}
}
}
}
private func save() {
guard let url = AppSettings.normalizedBaseURL(input) else {
error = "域名格式无效,请输入如 dashboard.example.com"
return
}
baseURLString = url.absoluteString
error = nil
onEnter()
}
}
// MARK: - MainWebView(主界面:WebView + 登录/Passkey 拦截)
struct MainWebView: View {
let baseURL: URL
@State private var showSplash = true
@State private var coordinator = WebViewCoordinator()
@State private var authSession = AuthSessionManager()
var body: some View {
ZStack {
WebView(
url: baseURL,
coordinator: coordinator,
onPageFinished: {
DispatchQueue.main.async {
withAnimation(.easeOut(duration: 0.35)) { showSplash = false }
}
}
)
.ignoresSafeArea()
.opacity(showSplash ? 0 : 1)
if showSplash { SplashView().transition(.opacity) }
}
.ignoresSafeArea()
.id(baseURL.absoluteString) // 域名变化时重建 WKWebView
.onAppear {
coordinator.onDashboardIntercepted = { [self] in handleDashboard(url: $0) }
coordinator.onPasskeyIntercepted = { [self] in handlePasskey(url: $0) }
}
}
// dashboard:// 回调:仅 Passkey 小窗使用(登录已改为主容器内完成,
// 不再需要 dashboard://callback + session-token 交换)。
private func handleDashboard(url callbackURL: URL) {
guard callbackURL.host == "passkey" else { return }
handlePasskeyCallback(callbackURL)
}
// /passkey 被 WKNavigationDelegate 拦截后:用 ASWebAuthenticationSession 小窗
// 打开该页面(Safari 环境原生支持 WebAuthn/Passkey,无需 App 关联域名);
// 小窗为 ephemeral 会话(prefersEphemeralWebBrowserSession),每次全新、
// 不携带旧 cookie,避免会话串扰。
private func handlePasskey(url passkeyURL: URL) {
DispatchQueue.main.async {
authSession.start(url: passkeyURL, scheme: "dashboard") { [self] cb in
guard let cb = cb else {
// 用户取消小窗 → 通知页面中间件取消
DispatchQueue.main.async {
coordinator.webView?.evaluateJavaScript(
"window.PasskeyBridge && window.PasskeyBridge.__iosCancel && window.PasskeyBridge.__iosCancel('认证已取消');",
completionHandler: nil)
}
return
}
handleDashboard(url: cb) // dashboard://passkey?sid=... 或 ?error=...
}
}
}
// dashboard://passkey 回调:小窗完成(?sid=)或失败(?error=)。
// credential 较大不随 URL 回传,而是从服务端一次性 result 接口取回。
private func handlePasskeyCallback(_ url: URL) {
guard let comps = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return }
let errorMsg = comps.queryItems?.first(where: { $0.name == "error" })?.value
let sid = comps.queryItems?.first(where: { $0.name == "sid" })?.value
DispatchQueue.main.async {
guard let wv = self.coordinator.webView else { return }
if let errorMsg = errorMsg {
wv.evaluateJavaScript(
"window.PasskeyBridge && window.PasskeyBridge.__iosCancel && window.PasskeyBridge.__iosCancel(" + Self.jsStringLiteral(errorMsg) + ");",
completionHandler: nil)
} else if let sid = sid {
self.fetchPasskeyResult(sid: sid, webView: wv)
}
}
}
private func fetchPasskeyResult(sid: String, webView: WKWebView) {
var comps = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
comps.path = "/api/passkey/stash/\(sid)/result"
guard let url = comps.url else { return }
var req = URLRequest(url: url)
req.timeoutInterval = 10
URLSession.shared.dataTask(with: req) { data, _, error in
DispatchQueue.main.async {
if let error = error {
webView.evaluateJavaScript(
"window.PasskeyBridge && window.PasskeyBridge.__iosCancel && window.PasskeyBridge.__iosCancel(" + Self.jsStringLiteral(error.localizedDescription) + ");",
completionHandler: nil)
return
}
guard let data = data,
let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
let credential = obj["credential"],
let credData = try? JSONSerialization.data(withJSONObject: credential),
let json = String(data: credData, encoding: .utf8) else {
webView.evaluateJavaScript(
"window.PasskeyBridge && window.PasskeyBridge.__iosCancel && window.PasskeyBridge.__iosCancel('Passkey 结果获取失败');",
completionHandler: nil)
return
}
// json 是合法 JSON 字面量,可直接作为 JS 实参
webView.evaluateJavaScript(
"window.PasskeyBridge && window.PasskeyBridge.__iosComplete && window.PasskeyBridge.__iosComplete(" + json + ");",
completionHandler: nil)
}
}.resume()
}
/// 把字符串编码为合法的 JS 字符串字面量(含引号与转义)。
private static func jsStringLiteral(_ s: String) -> String {
guard let data = try? JSONSerialization.data(withJSONObject: [s]),
let arr = String(data: data, encoding: .utf8),
arr.count >= 2 else { return "\"\"" }
return String(arr.dropFirst().dropLast())
}
}
// MARK: - ContentView(入口:无域名 → 填写页;有域名 → 欢迎页/主界面)
struct ContentView: View {
@AppStorage(AppSettings.baseURLKey) private var baseURLString = ""
@State private var inApp = false
var body: some View {
Group {
if inApp, let url = AppSettings.normalizedBaseURL(baseURLString) {
MainWebView(baseURL: url)
} else {
DomainSetupView(onEnter: { inApp = true })
}
}
.onAppear {
// Dashboard = always-on display, prevent auto-lock
UIApplication.shared.isIdleTimerDisabled = true
}
.onDisappear {
UIApplication.shared.isIdleTimerDisabled = false
}
}
}
// MARK: - Splash
struct SplashView: View {
var body: some View {
ZStack {
Color(red: 15/255, green: 17/255, blue: 23/255).ignoresSafeArea()
VStack(spacing: 28) {
Text("Dashboard").font(.system(size: 30, weight: .semibold)).foregroundColor(.white)
if #available(iOS 15.0, *) { ProgressView().progressViewStyle(CircularProgressViewStyle(tint: .white)).scaleEffect(1.3) }
else { UIActivity() }
Text("加载中...").font(.system(size: 14)).foregroundColor(.white.opacity(0.5))
}
}
}
}
struct UIActivity: UIViewRepresentable {
func makeUIView(context _: Context) -> UIActivityIndicatorView {
let v = UIActivityIndicatorView(style: .medium); v.color = .white; v.startAnimating(); return v
}
func updateUIView(_: UIActivityIndicatorView, context _: Context) {}
}
+19
View File
@@ -0,0 +1,19 @@
FROM python:3.11-slim
# System dependencies
RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources && \
apt-get update && apt-get install -y --no-install-recommends \
git \
libgomp1 \
&& rm -rf /var/lib/apt/lists/*
# Clone project
RUN git clone https://gitee.com/zhe-ovo/iot-dboard.git /app
WORKDIR /app
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
EXPOSE 5000
CMD ["python", "start.py"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 喆OvO
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+272
View File
@@ -0,0 +1,272 @@
# Dashboard - 多人副屏系统
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
---
## 项目简介
多用户副屏系统,支持日历、天气、闹钟、番茄钟、倒计时等功能。每个用户拥有完全隔离的数据与独立的显示端/控制端:
- **显示端** (`/`) — 大屏/触屏设备展示信息
- **控制端** (`/control`) — 完整配置面板
- **Root 管理员** (`id=0`) — 首个自动创建的账户,拥有全部功能与用户管理权限
---
## 功能
- **多用户**:Root 管理员可注册/编辑/删除用户;所有用户数据完全隔离,普通用户无管理权限
- **两种注册方式**:管理员代注册(正式账户,可代配 TOTP/Passkey);用户自助申请(管理员确认后生成临时账户)
- **临时账户**:服务器持有临时 TOTP 密钥,管理员仅可见实时验证码;用户登录后被强制引导配置自己的登录方式,配置完成即正式生效
- **日历**:支持订阅 iCalendar (.ics) 链接及本地文件上传,自动同步日程
- **天气**:基于和风天气 (QWeather) API,含逐小时/分钟级预报
- **闹钟**:多组闹钟设定,自动弹出提醒,支持自定义铃声
- **番茄钟**:定时提醒,自动继续,断网重连自动恢复
- **倒计时**:可自定义时长,到时提醒
- **自定义展示页**:支持多组自定义 HTML 模板,一键切换
---
## 🔐 安全特性
支持**双因素认证 (MFA)**,适合公网部署:
| 方式 | 说明 |
|------|------|
| **Passkey (WebAuthn)** | 指纹、面容、安全密钥等设备内置生物/硬件认证 |
| **TOTP** | Google Authenticator / Microsoft Authenticator 等标准应用 |
| **一次性恢复码** | 8 个恢复码,每个仅可使用一次 |
| **临时密码** | 首次启动为 Root 自动生成 6 位随机密码,显示在控制台 |
**登录流程**:
```
首次启动(无数据库)→ 自动创建 Root 管理员 (id=0),控制台打印临时密码
↓
访问 /control → 输入 6 位数字(自动识别 TOTP / 临时账户 TOTP / Root 临时密码)
↓
Root 临时密码 → 强制跳转 /mfa-setup 配置 MFA
正式用户 TOTP/Passkey → 直接进入控制面板
临时账户 TOTP → 强制跳转 /mfa-setup 配置登录方式,配置完成后临时 TOTP 立即失效
```
**用户管理(仅 Root)**:
- 管理员代注册:填写信息 → 正式账户,可随后代配置 TOTP(生成密钥+二维码)或 Passkey(注册仪式在当前浏览器完成)
- 用户自助申请:用户在 `/register-request` 填写信息 → 管理员确认 → 生成临时账户(服务器持有临时 TOTP 密钥,管理员仅看到实时验证码)
- 注册/编辑备注/删除用户均需**二次验证管理员身份**(Passkey 优先,无 Passkey 时用 TOTP)
- 删除用户会级联删除其全部数据并使其会话立即失效
- 用户 ID 对用户透明(仅在备注默认为 ID 时可见一次),普通用户只能看到自己的数据
**安全防护**:
- 登录频率限制(渐进式封禁:5次→5分钟、10次→15分钟、15次→24小时)
- CSRF 防护(Origin/Referer 校验)
- 敏感操作二次验证(用户管理、MFA 配置需重新确认身份)
- 会话 Cookie HttpOnly + SameSite=Lax + Secure(HTTPS 下)
- 会话版本按用户存储:删除用户 / "踢掉所有显示端" 立即失效
- 内容安全策略(CSP)限制脚本/样式来源
- 铃声上传仅允许音频格式+MIME 校验
- 第三方日历数据 HTML 转义防止 XSS
---
## 快速开始
### 开发环境
```bash
# 1. 安装依赖
pip install -r requirements.txt
# 2. 直接运行(调试模式)
python start.py
# 3. 访问
# 显示端: http://localhost:5000
# 控制端: http://localhost:5000/control
```
### 生产部署 (推荐 Nginx + Certbot)
```
客户端 (HTTPS)
│
▼ 443
Nginx (SSL termination + Certbot)
│ proxy_pass http://127.0.0.1:5000
│
▼ 5000 (HTTP)
Flask (127.0.0.1:5000)
│
▼
data.db (SQLite)
```
#### 1. Nginx 配置
```nginx
server {
listen 443 ssl;
server_name your-domain.com;
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:5000;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# 可选:限制上传大小
client_max_body_size 5M;
}
# HTTP → HTTPS 强制跳转
server {
listen 80;
server_name your-domain.com;
return 301 https://$host$request_uri;
}
```
#### 2. 启动 Flask
```bash
# 生产模式启动
FLASK_HTTPS=yes python start.py
# 可选:固定 SECRET_KEY(保持跨重启会话有效)
export FLASK_SECRET_KEY="$(openssl rand -hex 32)"
```
#### 3. Certbot 申请证书
```bash
sudo certbot --nginx -d your-domain.com
```
---
---
## Docker 部署
### 快速启动
```bash
# 构建并启动(后台运行,首次会自动从 Gitee clone 代码)
docker compose up -d
# 查看启动日志(获取临时密码)
docker compose logs app
# 停止
docker compose down
```
访问 `http://你的IP:5000`,日志中会打印临时密码。
### 更新代码
```bash
# 方法一:进入容器内 git pull(推荐)
docker compose exec app git pull
docker compose restart app
# 方法二:重建镜像(较慢)
docker compose down
docker compose build --no-cache
docker compose up -d
```
### 环境变量配置
编辑 `docker-compose.yml` 的 `environment` 段:
```yaml
environment:
- FLASK_BIND=0.0.0.0
- FLASK_SECRET_KEY=your-secret-here # 可选:固定密钥
- FLASK_HTTPS=yes # 如果前面有 HTTPS 反代
```
### 持久化数据
- **数据库**:`./data/` 目录挂载到 `/app/data/`,数据库文件 `data.db` 自动在此创建
- **项目代码**:命名卷 `app_code` 挂载到 `/app`,首次自动初始化为镜像内克隆的代码,之后 `git pull` 的修改永久保留数据库文件 `data.db` 会自动在该目录下创建,重启/升级后数据保留。
```bash
# 数据文件位置
ls -la data/
# data.db ← 数据库
```
### 搭配 Nginx 反代(生产)
使用宿主机上的 Nginx(或额外 Nginx 容器)做 SSL 终结。配置与前文 Nginx 部分相同,只需将 `proxy_pass` 指向 Docker 宿主机 IP:5000:
```nginx
location / {
proxy_pass http://宿主机IP:5000;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
}
```
> ⚠️ 注意:容器内 Flask 绑定 `0.0.0.0:5000`,宿主机可直接访问 `localhost:5000`。如需限制仅 Nginx 访问,在 `docker-compose.yml` 中将 `ports` 改为 `127.0.0.1:5000:5000`。
---
## 环境变量
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `FLASK_HTTPS` | 空 | 设为 `yes`/`true` 启用 Secure Cookie(Nginx 反代时启用) |
| `FLASK_SECRET_KEY` | 自动随机生成 | 固定密钥使会话跨重启保持有效 |
| `FLASK_BIND` | `127.0.0.1` | 监听地址,Docker 部署需设为 `0.0.0.0` |
| `FLASK_DB_PATH` | `./data.db` | 数据库文件路径,Docker 挂载数据卷时需同步修改 |
| `FLASK_DEBUG` | 空 | 设为 `true` 开启 Flask 调试模式(**生产环境禁用**) |
---
## 文件结构
```
├── run.py # 入口(端口 5001,仅开发)
├── start.py # 入口(端口 5000,推荐生产使用)
├── requirements.txt
├── backend/
│ ├── app.py # Flask 应用工厂 + 安全配置
│ ├── auth.py # MFA 认证(TOTP/Passkey/恢复码/临时密码)
│ ├── webauthn_mgr.py # WebAuthn (Passkey) 注册与认证
│ ├── jwt_util.py # Ed25519 JWT 签名(和风天气)
│ ├── models.py # SQLite 数据库模型与迁移
│ ├── routes.py # 全部 API 路由 + CSRF 防护
│ └── *_mgr.py # 各功能模块管理器
└── frontend/
├── templates/ # HTML 模板
└── static/
├── css/ # 样式
├── js/ # JavaScript(纯原生,无框架)
└── ringtones/ # 自定义铃声
```
---
### 数据库迁移(升级无需重写数据)
启动时会自动检测并迁移数据库:旧版单用户数据库的数据自动归属 Root (id=0);
新增的字段一律带 DEFAULT 并在迁移时自动填充;`camera_config` 等已删除的表自动丢弃。
旧版 4 位 PIN 不会被迁移。运行测试:`python3 tests/test_multi_user.py`。
## 注意
- **Passkey (WebAuthn) 需要 HTTPS**(localhost 除外)
- 旧版 4 位 PIN 已弃用,数据库迁移时旧 PIN 不会被迁移
### License
MIT
View File
+142
View File
@@ -0,0 +1,142 @@
"""Alarm and clock management."""
import os
from backend.time_util import now as tz_now
from flask import current_app
from backend.models import get_db
# Per-user "already triggered today" state: {user_id: (date_str, set)}
_triggered_state: dict[int, tuple[str, set]] = {}
def _uid() -> int:
"""Resolve the current user (request session or scheduler thread)."""
try:
from backend.auth import current_user_id
return current_user_id() or 0
except Exception:
return 0
def get_alarms(user_id: int = None) -> list:
if user_id is None:
user_id = _uid()
db = get_db(current_app.config["DB_PATH"])
rows = [dict(r) for r in db.execute(
"SELECT * FROM alarms WHERE user_id=? ORDER BY time", (user_id,)
).fetchall()]
db.close()
return rows
def add_alarm(label: str, time: str, days: str = "1,2,3,4,5,6,7", enabled: bool = True,
sound: str = "default", once: bool = False, user_id: int = None) -> dict:
if user_id is None:
user_id = _uid()
db = get_db(current_app.config["DB_PATH"])
cur = db.execute(
"INSERT INTO alarms (user_id, label, time, days, enabled, sound, once) VALUES (?, ?, ?, ?, ?, ?, ?)",
(user_id, label, time, days, 1 if enabled else 0, sound, 1 if once else 0)
)
db.commit()
row = dict(db.execute("SELECT * FROM alarms WHERE id=?", (cur.lastrowid,)).fetchone())
db.close()
return row
def update_alarm(alarm_id: int, label: str = None, time: str = None,
days: str = None, enabled: bool = None, sound: str = None, once: bool = None,
user_id: int = None):
if user_id is None:
user_id = _uid()
db = get_db(current_app.config["DB_PATH"])
fields = []
vals = []
for key, val in [("label", label), ("time", time), ("days", days),
("enabled", 1 if enabled else 0 if enabled is not None else None),
("sound", sound),
("once", 1 if once else 0 if once is not None else None)]:
if val is not None:
fields.append(f"{key}=?")
vals.append(val)
ok = False
if fields:
vals.append(user_id)
vals.append(alarm_id)
cur = db.execute(f"UPDATE alarms SET {', '.join(fields)} WHERE user_id=? AND id=?", vals)
db.commit()
ok = cur.rowcount > 0
db.close()
return ok
def delete_alarm(alarm_id: int, user_id: int = None) -> bool:
if user_id is None:
user_id = _uid()
db = get_db(current_app.config["DB_PATH"])
cur = db.execute("DELETE FROM alarms WHERE user_id=? AND id=?", (user_id, alarm_id))
db.commit()
db.close()
return cur.rowcount > 0
def check_alarms(user_id: int = None) -> list:
if user_id is None:
user_id = _uid()
global _triggered_state
db = get_db(current_app.config["DB_PATH"])
now = tz_now()
today_str = now.strftime("%Y%m%d")
triggered_date, triggered_today = _triggered_state.get(user_id, ("", set()))
if triggered_date != today_str:
triggered_today = set()
triggered_date = today_str
current_time = now.strftime("%H:%M")
weekday = str(now.isoweekday())
rows = db.execute(
"SELECT * FROM alarms WHERE user_id=? AND enabled=1 AND time=?",
(user_id, current_time)
).fetchall()
triggered = []
for r in rows:
days_list = r["days"].split(",")
if weekday in days_list:
alarm_id = r["id"]
if alarm_id in triggered_today:
continue
triggered_today.add(alarm_id)
triggered.append(dict(r))
# Once-only alarms: auto-disable after trigger
if r["once"]:
db.execute("UPDATE alarms SET enabled=0 WHERE user_id=? AND id=?", (user_id, alarm_id))
db.commit()
_triggered_state[user_id] = (triggered_date, triggered_today)
db.close()
if triggered:
current_app.logger.info(f"Triggering {len(triggered)} alarms: {[a['label'] for a in triggered]}")
current_app.event_bus.publish("alarm_triggered", {
"alarms": triggered,
"ts": now.isoformat(),
}, user_id=user_id)
return triggered
def get_ringtone_path(sound_name: str) -> str | None:
if not sound_name or sound_name == "default":
return None
ringtone_dir = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"frontend", "static", "ringtones"
)
path = os.path.join(ringtone_dir, sound_name)
if os.path.isfile(path):
return path
return None
+110
View File
@@ -0,0 +1,110 @@
"""Flask application factory and SSE event system."""
import json
import os
import queue
import secrets
import sqlite3
import threading
from datetime import datetime, timedelta
from backend.time_util import now as tz_now, strptime as tz_strptime
from flask import Flask
from flask_cors import CORS
from werkzeug.middleware.proxy_fix import ProxyFix
from backend.models import init_db
from backend.routes import register_routes
from backend.events import EventBus
from backend.weather_mgr import fetch_weather, fetch_hourly
def create_app(db_path: str = "data.db") -> Flask:
app = Flask(
__name__,
template_folder="../frontend/templates",
static_folder="../frontend/static",
)
# Generate a secure random key at startup.
# Set FLASK_SECRET_KEY env var for a fixed key (sessions survive restarts).
app.config["SECRET_KEY"] = os.environ.get("FLASK_SECRET_KEY") or secrets.token_hex(32)
# Defensive check: ensure key is never empty
if not app.config["SECRET_KEY"]:
app.config["SECRET_KEY"] = secrets.token_hex(32)
app.config["DB_PATH"] = db_path
# Trust Nginx X-Forwarded-* headers (x_for=1, x_proto=1, x_host=1)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
# Session hardening for production
https = os.environ.get("FLASK_HTTPS", "").lower() in ("1", "true", "yes")
app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
app.config["SESSION_COOKIE_SECURE"] = https
app.config["SESSION_PERMANENT"] = False
# Strict CORS: only allow same-origin requests (no wildcard for cookie auth)
CORS(app, supports_credentials=True, origins=[]) # empty = same-origin only
# Content size limit: 5MB for uploads
app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024
# Session lifetime: 24 hours idle timeout
app.config["PERMANENT_SESSION_LIFETIME"] = 86400
# Global event bus
app.event_bus = EventBus()
init_db(db_path)
register_routes(app)
# Security headers on every response
@app.after_request
def _security_headers(resp):
resp.headers["X-Content-Type-Options"] = "nosniff"
resp.headers["X-Frame-Options"] = "DENY"
resp.headers["Referrer-Policy"] = "same-origin"
# Strict CSP limits inline styles/scripts — relax if custom HTML used
resp.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"connect-src 'self'; "
"frame-ancestors 'none'"
)
return resp
# Startup weather fetch: only if cache is stale (>= 30 min) or empty
with app.app_context():
_startup_weather(app, db_path)
return app
def _startup_weather(app, db_path: str):
try:
conn = sqlite3.connect(db_path)
row = conn.execute(
"SELECT updated_at FROM weather_cache WHERE user_id=0 ORDER BY id DESC LIMIT 1"
).fetchone()
conn.close()
except Exception:
row = None
should_fetch = True
if row and row[0]:
try:
updated = tz_strptime(row[0], "%Y-%m-%d %H:%M:%S")
age = tz_now() - updated
if age < timedelta(minutes=30):
should_fetch = False
app.logger.info(
f"Startup: weather cache is {int(age.total_seconds()/60)} min old, skip fetch"
)
except Exception:
pass
if should_fetch:
try:
fetch_weather()
fetch_hourly()
app.logger.info("Startup: weather fetched (cache stale or empty)")
except Exception as e:
app.logger.warning(f"Startup weather fetch failed: {e}")
+446
View File
@@ -0,0 +1,446 @@
"""MFA-based authentication: TOTP, Passkey (WebAuthn), recovery codes, temp password.
Multi-user support:
- Sessions carry the authenticated `user_id` (Root = 0).
- The unified login (6-digit code) searches every active user's TOTP secret,
then temp accounts' temp-TOTP secrets, then Root's temp password.
- Session version is per-user (stored in the users table), so deleting a user
or kicking all sessions immediately invalidates their cookies.
"""
import functools
import secrets
import string
import time
from flask import session, redirect, url_for, request, jsonify, current_app, g
from backend.models import (
get_auth_config, update_auth_config, get_temp_password, set_temp_password,
clear_temp_password, get_recovery_codes, set_recovery_codes, has_mfa_configured,
get_user_session_version, user_exists, is_admin_user, get_db,
)
# ─── Session keys ────────────────────────────
SKEY_AUTH = "mfa_authenticated" # bool: fully authenticated
SKEY_TEMP = "mfa_temp_pending" # bool: logged in with temp pw / temp account, config required
SKEY_VIEW = "mfa_view" # str: "control" or "display"
SKEY_REAUTH = "mfa_reauth_at" # float: timestamp of last re-authentication
SKEY_VERSION = "mfa_session_ver" # int: session version (for kick_all invalidation)
SKEY_USER = "user_id" # int: id of the authenticated user
def _db_path(preferred: str = None) -> str:
if preferred:
return preferred
return current_app.config.get("DB_PATH", "data.db")
def _validate_version():
"""If the session version is stale (kick_all / user deleted), clear the session."""
uid = session.get(SKEY_USER)
if uid is None:
return
# Cache the validation result for the duration of this request.
if getattr(g, "_sv_ok", False) and getattr(g, "_sv_uid", None) == uid:
return
ver = session.get(SKEY_VERSION)
if ver is None:
return
stored = get_user_session_version(uid)
if stored == -1 or ver != stored:
clear_session()
return
g._sv_ok = True
g._sv_uid = uid
def is_authenticated() -> bool:
"""Returns True if the user has completed MFA authentication."""
_validate_version()
return session.get(SKEY_AUTH) is True
def is_temp_pending() -> bool:
"""True if logged in with a temp password or temp account and must set up a login method."""
_validate_version()
return session.get(SKEY_TEMP) is True
def current_user_id() -> int | None:
"""Return the id of the current user (session user, or scheduler thread user)."""
try:
from flask import has_request_context
if has_request_context():
_validate_version()
uid = session.get(SKEY_USER)
if uid is not None:
return int(uid)
except Exception:
pass
try:
from backend import time_util
return time_util.get_thread_user()
except Exception:
return None
def current_remark() -> str:
"""Return the current user's remark for display (never the raw id unless it IS the remark)."""
uid = current_user_id()
if uid is None:
return ""
try:
from backend.models import get_user
u = get_user(uid)
return (u or {}).get("remark", "") or ""
except Exception:
return ""
def is_admin() -> bool:
"""Only Root (id 0) — and any future admin role — can manage users."""
uid = current_user_id()
if uid is None:
return False
return is_admin_user(uid)
def set_authenticated(view: str = "control", user_id: int = None):
if user_id is None:
user_id = session.get(SKEY_USER) or 0
session[SKEY_AUTH] = True
session[SKEY_TEMP] = False
session[SKEY_VIEW] = view
session[SKEY_USER] = user_id
session[SKEY_VERSION] = get_user_session_version(user_id)
# A fresh authentication must not inherit the temp-setup re-auth marker.
session.pop(SKEY_REAUTH, None)
session.permanent = True
def set_temp_pending(view: str = "control", user_id: int = None):
"""Mark the session as logged-in-but-must-configure (temp password / temp account)."""
if user_id is None:
user_id = session.get(SKEY_USER) or 0
session[SKEY_AUTH] = False
session[SKEY_TEMP] = True
session[SKEY_VIEW] = view
session[SKEY_USER] = user_id
session[SKEY_VERSION] = get_user_session_version(user_id)
session.permanent = True
def clear_session():
session.pop(SKEY_AUTH, None)
session.pop(SKEY_TEMP, None)
session.pop(SKEY_VIEW, None)
session.pop(SKEY_REAUTH, None)
session.pop(SKEY_USER, None)
session.pop(SKEY_VERSION, None)
# ─── Re-authentication guard ─────────────────
# Sensitive operations (user management, MFA changes) require re-confirming
# identity within the last N seconds. TOTP-first with a Passkey fallback
# (the fallback ceremony is handled by the routes layer).
_REAUTH_TTL = 300 # 5 minutes
def require_reauth():
"""Check if recent re-authentication is still valid.
Skips the check for temp_pending users (first-time MFA setup flow).
"""
if is_temp_pending():
mark_reauth()
return True
last = session.get(SKEY_REAUTH)
if last and (time.time() - last) < _REAUTH_TTL:
return True
return False
def mark_reauth():
"""Mark re-authentication as done now."""
session[SKEY_REAUTH] = time.time()
# ─── Decorators ──────────────────────────────
def _unauthorized(request):
if request.is_json or request.path.startswith("/api/"):
return jsonify({"error": "Unauthorized", "login_required": True}), 401
view_param = session.get(SKEY_VIEW, "control")
return redirect(url_for("api.login_page", next=request.path, view=view_param))
def login_required(view):
"""Require full MFA authentication. Redirects to login if not authenticated."""
@functools.wraps(view)
def wrapped(*args, **kwargs):
if is_authenticated():
return view(*args, **kwargs)
# If logged in with temp password / temp account, redirect to MFA setup
if is_temp_pending():
if request.is_json or request.path.startswith("/api/"):
return jsonify({"error": "MFA configuration required", "mfa_required": True}), 403
return redirect(url_for("api.mfa_setup_page"))
return _unauthorized(request)
return wrapped
def login_or_temp_required(view):
"""Allow access if fully authenticated OR in temp-pending state (MFA setup)."""
@functools.wraps(view)
def wrapped(*args, **kwargs):
if is_authenticated() or is_temp_pending():
return view(*args, **kwargs)
return _unauthorized(request)
return wrapped
def display_login_required(view):
"""Lighter auth check for display-end API calls."""
@functools.wraps(view)
def wrapped(*args, **kwargs):
if is_authenticated():
return view(*args, **kwargs)
return _unauthorized(request)
return wrapped
def admin_required(view):
"""Require full authentication AND administrator (Root) role."""
@functools.wraps(view)
def wrapped(*args, **kwargs):
if not is_authenticated():
return _unauthorized(request)
if not is_admin():
return jsonify({"error": "Forbidden: 仅管理员可执行此操作"}), 403
return view(*args, **kwargs)
return wrapped
# ─── Unified Login ───────────────────────────
def verify_login(raw: str, db_path: str = None) -> dict | None:
"""Unified login: tries every user's TOTP, then temp accounts' temp-TOTP,
then Root's temp password.
Returns:
{'method': 'totp'|'temp_password', 'user_id': int, 'temp_account': bool}
or None on failure.
"""
if not raw or not isinstance(raw, str) or not raw.isdigit() or len(raw) != 6:
return None
db_path = db_path or current_app.config.get("DB_PATH", "data.db")
conn = get_db(db_path)
try:
import pyotp
# Load all users + their auth rows in ONE query (avoids per-user connections).
rows = conn.execute(
"SELECT u.id AS user_id, u.is_temp, u.temp_totp_secret, "
"a.totp_secret, a.totp_enabled, a.temp_password "
"FROM users u LEFT JOIN auth_config a ON a.user_id = u.id "
"WHERE u.status='active'"
).fetchall()
# 1. Formal users' TOTP secrets
for r in rows:
if r["totp_enabled"] and r["totp_secret"]:
if pyotp.TOTP(r["totp_secret"]).verify(raw, valid_window=1):
return {"method": "totp", "user_id": r["user_id"], "temp_account": False}
# 2. Temp accounts' temp-TOTP (server-only secret; admin sees only the live code)
for r in rows:
if r["is_temp"] and r["temp_totp_secret"]:
if pyotp.TOTP(r["temp_totp_secret"]).verify(raw, valid_window=1):
return {"method": "totp", "user_id": r["user_id"], "temp_account": True}
# 3. Root temp password (initial bootstrap)
root_row = next((r for r in rows if r["user_id"] == 0), None)
stored_pw = (root_row["temp_password"] or "") if root_row else ""
if stored_pw and secrets.compare_digest(raw, stored_pw):
return {"method": "temp_password", "user_id": 0, "temp_account": False}
return None
finally:
conn.close()
# ─── Temp Password ───────────────────────────
def generate_temp_password(length: int = 6) -> str:
"""Generate a random numeric temp password."""
return "".join(secrets.choice(string.digits) for _ in range(length))
def verify_temp_password(raw: str, db_path: str = None, user_id: int = 0) -> bool:
"""Verify a temporary password for a given user (only Root uses one)."""
if not raw or not isinstance(raw, str) or not raw.isdigit() or len(raw) != 6:
return False
db_path = db_path or current_app.config.get("DB_PATH", "data.db")
stored = get_temp_password(db_path, user_id)
if not stored:
return False
return secrets.compare_digest(raw, stored)
# ─── Rate Limiter ─────────────────────────────
# Progressive per-IP ban: 5err→5min, 10err→15min, 15+err→24hr
_RATE_LIMIT_STORE: dict[str, dict] = {} # ip -> {"failures": int, "banned_until": float}
def _get_client_ip() -> str:
"""Extract client IP for rate limiting.
Relies on ProxyFix + Nginx config to set remote_addr correctly.
"""
if not current_app:
return "unknown"
return request.remote_addr or "unknown"
def _ban_duration_sec(failures: int) -> int:
"""Progressive ban duration based on consecutive failure count."""
if failures < 5:
return 0
if failures < 10:
return 5 * 60 # 5 minutes
if failures < 15:
return 15 * 60 # 15 minutes
return 24 * 60 * 60 # 24 hours
def check_rate_limit(ip: str = None) -> dict:
"""Check if this IP is currently rate-limited."""
if ip is None:
ip = _get_client_ip()
entry = _RATE_LIMIT_STORE.get(ip)
if not entry:
return {"ok": True}
now = time.time()
banned_until = entry.get("banned_until", 0)
if now < banned_until:
remaining = int(banned_until - now)
if remaining < 60:
msg = f"登录尝试过多,请等待 {remaining} 秒"
elif remaining < 3600:
msg = f"登录尝试过多,请等待 {remaining // 60} 分钟"
else:
msg = f"登录尝试过多,请等待 {remaining // 3600} 小时"
return {"ok": False, "message": msg, "retry_after": remaining}
# Ban expired — reset to current tier (keep failure count so next ban is higher tier)
entry["banned_until"] = 0
return {"ok": True}
def record_failure(ip: str = None):
"""Record a failed login attempt. Applies progressive ban if threshold reached."""
if ip is None:
ip = _get_client_ip()
now = time.time()
entry = _RATE_LIMIT_STORE.get(ip, {"failures": 0, "banned_until": 0})
entry["failures"] += 1
duration = _ban_duration_sec(entry["failures"])
if duration > 0:
entry["banned_until"] = now + duration
if current_app:
current_app.logger.warning(
f"Rate limit: IP {ip} banned for {duration // 60}min "
f"after {entry['failures']} failures"
)
_RATE_LIMIT_STORE[ip] = entry
def record_success(ip: str = None):
"""Reset failure counter on successful login."""
if ip is None:
ip = _get_client_ip()
_RATE_LIMIT_STORE.pop(ip, None)
# ─── TOTP ────────────────────────────────────
def verify_totp(raw: str, db_path: str = None, user_id: int = 0) -> bool:
"""Verify a TOTP 6-digit code against a specific user's stored secret."""
if not raw or not isinstance(raw, str) or not raw.isdigit() or len(raw) != 6:
return False
db_path = db_path or current_app.config.get("DB_PATH", "data.db")
cfg = get_auth_config(db_path, user_id)
secret = cfg.get("totp_secret", "")
if not secret:
return False
import pyotp
totp = pyotp.TOTP(secret)
return totp.verify(raw, valid_window=1)
def current_totp_code(user_id: int, db_path: str = None) -> str:
"""Compute the current live TOTP code for a user's secret (admin helper)."""
db_path = db_path or current_app.config.get("DB_PATH", "data.db")
cfg = get_auth_config(db_path, user_id)
secret = cfg.get("totp_secret", "")
if not secret:
return ""
import pyotp
return pyotp.TOTP(secret).now()
def current_temp_totp_code(user_id: int, db_path: str = None) -> str:
"""Compute the current live temp-TOTP code for a temp account (admin helper)."""
db_path = db_path or current_app.config.get("DB_PATH", "data.db")
from backend.models import get_temp_totp_secret
secret = get_temp_totp_secret(user_id, db_path)
if not secret:
return ""
import pyotp
return pyotp.TOTP(secret).now()
# ─── Recovery Codes ──────────────────────────
def generate_recovery_codes(count: int = 8) -> list:
"""Generate N random alphanumeric recovery codes, each 10 chars."""
alphabet = string.ascii_uppercase + string.digits
codes = []
for _ in range(count):
code = "".join(secrets.choice(alphabet) for _ in range(10))
formatted = f"{code[:4]}-{code[4:]}"
codes.append(formatted)
return codes
def verify_recovery_code(raw: str, db_path: str = None) -> int | None:
"""Verify and consume a recovery code. Returns the user id on success, else None."""
if not raw or not isinstance(raw, str):
return None
normalized = raw.replace("-", "").upper()
if len(normalized) != 10:
return None
db_path = db_path or current_app.config.get("DB_PATH", "data.db")
conn = get_db(db_path)
try:
rows = conn.execute(
"SELECT u.id AS user_id, a.recovery_codes FROM users u "
"LEFT JOIN auth_config a ON a.user_id = u.id "
"WHERE u.status='active'"
).fetchall()
for r in rows:
raw_codes = r["recovery_codes"] or ""
if not raw_codes:
continue
codes = raw_codes.split(",")
for i, stored in enumerate(codes):
stored_norm = stored.replace("-", "").upper()
if secrets.compare_digest(normalized, stored_norm):
codes.pop(i)
conn.execute(
"UPDATE auth_config SET recovery_codes=? WHERE user_id=?",
(",".join(codes), r["user_id"])
)
conn.commit()
return r["user_id"]
return None
finally:
conn.close()
+471
View File
@@ -0,0 +1,471 @@
"""Calendar management: ICS upload, subscription, groups, reminders.
Every function is scoped to an explicit `user_id` so each user only ever
sees/mutates their own data (defense in depth: rows are filtered by user_id
even though they also join user-scoped groups/sources).
"""
import os
import hashlib
import tempfile
from datetime import datetime, timedelta, date
from backend.time_util import now as tz_now
from flask import current_app
from backend.models import get_db
def _uid() -> int:
"""Resolve the current user for scheduler-thread calls."""
try:
from backend.auth import current_user_id
return current_user_id() or 0
except Exception:
return 0
def _require_group_owner(db, user_id: int, group_id: int):
"""Return the group row only if it belongs to the user (else None).
Prevents cross-user writes through unvalidated group ids."""
return db.execute(
"SELECT id FROM calendar_groups WHERE id=? AND user_id=?",
(group_id, user_id)
).fetchone()
def get_groups(user_id: int = None):
if user_id is None:
user_id = _uid()
db = get_db(current_app.config["DB_PATH"])
groups = [dict(r) for r in db.execute(
"SELECT * FROM calendar_groups WHERE user_id=? ORDER BY id", (user_id,)
).fetchall()]
for g in groups:
g["sources"] = [dict(r) for r in db.execute(
"SELECT id, type, name, url, last_sync FROM calendar_sources "
"WHERE group_id=? AND user_id=?", (g["id"], user_id)
).fetchall()]
db.close()
return groups
def create_group(name: str, color: str = "#4A90D9", user_id: int = None) -> dict:
if user_id is None:
user_id = _uid()
db = get_db(current_app.config["DB_PATH"])
cur = db.execute(
"INSERT INTO calendar_groups (user_id, name, color) VALUES (?, ?, ?)",
(user_id, name, color)
)
db.commit()
gid = cur.lastrowid
row = dict(db.execute("SELECT * FROM calendar_groups WHERE id=?", (gid,)).fetchone())
db.close()
return row
def update_group(group_id: int, name: str = None, color: str = None,
enabled: bool = None, user_id: int = None):
if user_id is None:
user_id = _uid()
db = get_db(current_app.config["DB_PATH"])
fields = []
vals = []
if name is not None:
fields.append("name=?")
vals.append(name)
if color is not None:
fields.append("color=?")
vals.append(color)
if enabled is not None:
fields.append("enabled=?")
vals.append(1 if enabled else 0)
ok = False
if fields:
vals.append(user_id)
vals.append(group_id)
cur = db.execute(f"UPDATE calendar_groups SET {', '.join(fields)} WHERE user_id=? AND id=?", vals)
db.commit()
ok = cur.rowcount > 0
db.close()
return ok
def delete_group(group_id: int, user_id: int = None) -> bool:
if user_id is None:
user_id = _uid()
db = get_db(current_app.config["DB_PATH"])
db.execute("DELETE FROM calendar_events WHERE user_id=? AND source_id IN "
"(SELECT id FROM calendar_sources WHERE user_id=? AND group_id=?)",
(user_id, user_id, group_id))
db.execute("DELETE FROM calendar_sources WHERE user_id=? AND group_id=?", (user_id, group_id))
db.execute("DELETE FROM reminders WHERE user_id=? AND calendar_group_id=?", (user_id, group_id))
cur = db.execute("DELETE FROM calendar_groups WHERE user_id=? AND id=?", (user_id, group_id))
db.commit()
db.close()
return cur.rowcount > 0
def upload_ics(group_id: int, name: str, ics_content: str, user_id: int = None):
if user_id is None:
user_id = _uid()
db = get_db(current_app.config["DB_PATH"])
if not _require_group_owner(db, user_id, group_id):
db.close()
raise ValueError("分组不存在或无权访问")
cur = db.execute(
"INSERT INTO calendar_sources (user_id, group_id, type, name, ics_data) "
"VALUES (?, ?, 'upload', ?, ?)",
(user_id, group_id, name, ics_content)
)
source_id = cur.lastrowid
db.commit()
_parse_and_store_events(db, user_id, source_id, ics_content)
db.close()
current_app.event_bus.publish("calendar_updated", {"group_id": group_id}, user_id=user_id)
def subscribe_ics(group_id: int, name: str, url: str, user_id: int = None):
if user_id is None:
user_id = _uid()
from backend.url_util import is_safe_public_url
if not is_safe_public_url(url):
raise ValueError("订阅地址无效或不安全(仅允许公网 http/https 地址)")
db = get_db(current_app.config["DB_PATH"])
if not _require_group_owner(db, user_id, group_id):
db.close()
raise ValueError("分组不存在或无权访问")
cur = db.execute(
"INSERT INTO calendar_sources (user_id, group_id, type, name, url) "
"VALUES (?, ?, 'subscription', ?, ?)",
(user_id, group_id, name, url)
)
source_id = cur.lastrowid
db.commit()
db.close()
sync_subscription(source_id, user_id)
def sync_subscription(source_id: int, user_id: int = None):
"""Pull and parse a remote ICS subscription."""
if user_id is None:
user_id = _uid()
db = get_db(current_app.config["DB_PATH"])
row = db.execute("SELECT * FROM calendar_sources WHERE id=? AND user_id=?",
(source_id, user_id)).fetchone()
if not row or row["type"] != "subscription" or not row["url"]:
db.close()
return
try:
from backend.url_util import safe_http_get
resp = safe_http_get(row["url"], timeout=15)
if resp is None:
current_app.logger.warning(f"Sync skipped: unsafe URL for source {source_id}")
return
ics_data = resp.text
# Clear old events for this source
db.execute("DELETE FROM calendar_events WHERE user_id=? AND source_id=?",
(user_id, source_id))
_parse_and_store_events(db, user_id, source_id, ics_data)
db.execute("UPDATE calendar_sources SET last_sync=datetime('now') WHERE id=? AND user_id=?",
(source_id, user_id))
db.commit()
current_app.event_bus.publish("calendar_updated", {"group_id": row["group_id"]}, user_id=user_id)
except Exception as e:
current_app.logger.error(f"Sync failed for source {source_id}: {e}")
finally:
db.close()
def sync_all_subscriptions(user_id: int = None):
"""Sync all subscription sources for one user (or all users when user_id is None)."""
if user_id is None:
db = get_db(current_app.config["DB_PATH"])
rows = db.execute("SELECT user_id, id FROM calendar_sources WHERE type='subscription' AND url IS NOT NULL").fetchall()
db.close()
for r in rows:
sync_subscription(r["id"], r["user_id"])
return
db = get_db(current_app.config["DB_PATH"])
sources = db.execute(
"SELECT id FROM calendar_sources WHERE user_id=? AND type='subscription' AND url IS NOT NULL",
(user_id,)
).fetchall()
db.close()
for s in sources:
sync_subscription(s["id"], user_id)
def delete_source(source_id: int, user_id: int = None) -> bool:
if user_id is None:
user_id = _uid()
db = get_db(current_app.config["DB_PATH"])
db.execute("DELETE FROM calendar_events WHERE user_id=? AND source_id=?", (user_id, source_id))
cur = db.execute("DELETE FROM calendar_sources WHERE user_id=? AND id=?", (user_id, source_id))
db.commit()
db.close()
return cur.rowcount > 0
def get_events(group_ids: list = None, days: int = 7, user_id: int = None):
"""Get events for enabled groups, limited to N days ahead."""
if user_id is None:
user_id = _uid()
db = get_db(current_app.config["DB_PATH"])
now = tz_now().strftime("%Y%m%dT000000")
end = (tz_now() + timedelta(days=days)).strftime("%Y%m%dT235959")
if group_ids:
placeholders = ','.join('?' * len(group_ids))
rows = db.execute(f"""
SELECT e.*, s.group_id FROM calendar_events e
JOIN calendar_sources s ON e.source_id = s.id
JOIN calendar_groups g ON s.group_id = g.id
WHERE e.user_id=? AND g.user_id=? AND g.enabled=1
AND s.group_id IN ({placeholders})
AND e.dtend >= ? AND e.dtstart <= ?
ORDER BY e.dtstart
""", (user_id, user_id, *group_ids, now, end)).fetchall()
else:
rows = db.execute("""
SELECT e.*, s.group_id FROM calendar_events e
JOIN calendar_sources s ON e.source_id = s.id
JOIN calendar_groups g ON s.group_id = g.id
WHERE e.user_id=? AND g.user_id=? AND g.enabled=1
AND e.dtend >= ? AND e.dtstart <= ?
ORDER BY e.dtstart
""", (user_id, user_id, now, end)).fetchall()
db.close()
return [dict(r) for r in rows]
def get_upcoming_reminders(user_id: int = None):
"""Check for calendar events whose reminders are due."""
if user_id is None:
user_id = _uid()
db = get_db(current_app.config["DB_PATH"])
now = tz_now()
rows = db.execute("""
SELECT e.*, r.minutes_before, g.name as group_name FROM calendar_events e
JOIN calendar_sources s ON e.source_id = s.id
JOIN calendar_groups g ON s.group_id = g.id
JOIN reminders r ON r.calendar_group_id = g.id
WHERE e.user_id=? AND g.user_id=? AND g.enabled=1 AND r.enabled=1
""", (user_id, user_id)).fetchall()
db.close()
due = []
for r in rows:
try:
dt = datetime.strptime(r["dtstart"], "%Y%m%dT%H%M%S")
remind_at = dt - timedelta(minutes=r["minutes_before"])
if remind_at <= now < dt:
due.append(dict(r))
except (ValueError, TypeError):
pass
return due
def set_reminder(group_id: int, minutes_before: int, enabled: bool = True, user_id: int = None):
if user_id is None:
user_id = _uid()
db = get_db(current_app.config["DB_PATH"])
if not _require_group_owner(db, user_id, group_id):
db.close()
raise ValueError("分组不存在或无权访问")
existing = db.execute("SELECT id FROM reminders WHERE user_id=? AND calendar_group_id=?",
(user_id, group_id)).fetchone()
if existing:
db.execute("UPDATE reminders SET minutes_before=?, enabled=? WHERE user_id=? AND calendar_group_id=?",
(minutes_before, 1 if enabled else 0, user_id, group_id))
else:
db.execute("INSERT INTO reminders (user_id, calendar_group_id, minutes_before, enabled) "
"VALUES (?, ?, ?, ?)",
(user_id, group_id, minutes_before, 1 if enabled else 0))
db.commit()
db.close()
def get_or_create_manual_source(group_id: int, user_id: int = None) -> int:
"""Get or create a 'manual' source for the group."""
if user_id is None:
user_id = _uid()
db = get_db(current_app.config["DB_PATH"])
row = db.execute(
"SELECT id FROM calendar_sources WHERE user_id=? AND group_id=? AND type='manual'",
(user_id, group_id)
).fetchone()
if row:
db.close()
return row["id"]
cur = db.execute(
"INSERT INTO calendar_sources (user_id, group_id, type, name) VALUES (?, ?, 'manual', '手动添加')",
(user_id, group_id)
)
db.commit()
sid = cur.lastrowid
db.close()
return sid
def _normalize_dt(dt_str: str) -> str:
"""Ensure date string has the 'T' separator if it looks like YYYYMMDDHHMMSS."""
if not dt_str or 'T' in dt_str:
return dt_str
if len(dt_str) >= 14:
return dt_str[:8] + 'T' + dt_str[8:]
return dt_str
def add_event(group_id: int, summary: str, dtstart: str, dtend: str = None,
description: str = "", location: str = "", is_all_day: bool = False,
user_id: int = None) -> dict:
"""Manually add a single event to a group."""
if user_id is None:
user_id = _uid()
dtstart = _normalize_dt(dtstart)
dtend = _normalize_dt(dtend)
db = get_db(current_app.config["DB_PATH"])
if not _require_group_owner(db, user_id, group_id):
db.close()
raise ValueError("分组不存在或无权访问")
source_id = get_or_create_manual_source(group_id, user_id)
if dtend is None:
dtend = dtstart
cur = db.execute("""
INSERT INTO calendar_events (user_id, source_id, summary, dtstart, dtend, description, location, is_all_day)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (user_id, source_id, summary, dtstart, dtend, description, location, 1 if is_all_day else 0))
db.commit()
eid = cur.lastrowid
row = dict(db.execute("SELECT * FROM calendar_events WHERE id=?", (eid,)).fetchone())
db.close()
current_app.event_bus.publish("calendar_updated", {"group_id": group_id}, user_id=user_id)
return row
def get_events_by_group(group_id: int, days: int = 30, user_id: int = None) -> list:
"""Get all events for a specific group."""
if user_id is None:
user_id = _uid()
db = get_db(current_app.config["DB_PATH"])
now = tz_now().strftime("%Y%m%dT000000")
end = (tz_now() + timedelta(days=days)).strftime("%Y%m%dT235959")
rows = db.execute("""
SELECT e.*, s.group_id FROM calendar_events e
JOIN calendar_sources s ON e.source_id = s.id
WHERE e.user_id=? AND s.user_id=? AND s.group_id=? AND e.dtend >= ? AND e.dtstart <= ?
ORDER BY e.dtstart
""", (user_id, user_id, group_id, now, end)).fetchall()
db.close()
return [dict(r) for r in rows]
def update_event(event_id: int, summary: str = None, dtstart: str = None,
dtend: str = None, description: str = None, location: str = None,
user_id: int = None) -> bool:
if user_id is None:
user_id = _uid()
dtstart = _normalize_dt(dtstart)
dtend = _normalize_dt(dtend)
db = get_db(current_app.config["DB_PATH"])
fields = []
vals = []
for key, val in [("summary", summary), ("dtstart", dtstart), ("dtend", dtend),
("description", description), ("location", location)]:
if val is not None:
fields.append(f"{key}=?")
vals.append(val)
if not fields:
db.close()
return False
vals.append(user_id)
vals.append(event_id)
cur = db.execute(f"UPDATE calendar_events SET {', '.join(fields)} WHERE user_id=? AND id=?", vals)
db.commit()
ok = cur.rowcount > 0
# Get group_id for event broadcast
row = db.execute("""
SELECT s.group_id FROM calendar_events e
JOIN calendar_sources s ON e.source_id = s.id
WHERE e.user_id=? AND e.id=?
""", (user_id, event_id)).fetchone()
db.close()
if ok and row:
current_app.event_bus.publish("calendar_updated", {"group_id": row["group_id"]}, user_id=user_id)
return ok
def delete_event(event_id: int, user_id: int = None) -> bool:
if user_id is None:
user_id = _uid()
db = get_db(current_app.config["DB_PATH"])
row = db.execute("""
SELECT s.group_id FROM calendar_events e
JOIN calendar_sources s ON e.source_id = s.id
WHERE e.user_id=? AND e.id=?
""", (user_id, event_id)).fetchone()
cur = db.execute("DELETE FROM calendar_events WHERE user_id=? AND id=?", (user_id, event_id))
db.commit()
db.close()
if cur.rowcount > 0 and row:
current_app.event_bus.publish("calendar_updated", {"group_id": row["group_id"]}, user_id=user_id)
return cur.rowcount > 0
def _parse_and_store_events(db, user_id: int, source_id: int, ics_data: str):
"""Parse ICS content and store events in DB, expanding recurring events."""
from icalendar import Calendar
import recurring_ical_events
try:
cal = Calendar.from_ical(ics_data)
except Exception:
return
# Determine the expansion window: from start of today up to 365 days ahead
today = tz_now().date()
window_start = datetime(today.year, today.month, today.day)
window_end = window_start + timedelta(days=365)
try:
expanded = recurring_ical_events.of(cal).between(window_start, window_end)
except Exception:
expanded = []
for comp in expanded:
if comp.name != "VEVENT":
continue
uid = str(comp.get("uid", ""))
summary = str(comp.get("summary", ""))
description = str(comp.get("description", ""))
location = str(comp.get("location", ""))
dtstart = comp.get("dtstart")
dtend = comp.get("dtend")
if dtstart is None:
continue
dtstart_str = _format_dt(dtstart.dt)
if dtend:
dtend_str = _format_dt(dtend.dt)
else:
dtend_str = dtstart_str
is_all_day = 1 if (hasattr(dtstart.dt, 'date') and not hasattr(dtstart.dt, 'time')) else 0
db.execute("""
INSERT INTO calendar_events (user_id, source_id, uid, summary, description, location, dtstart, dtend, is_all_day)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (user_id, source_id, uid, summary, description, location, dtstart_str, dtend_str, is_all_day))
db.commit()
def _format_dt(dt_val) -> str:
"""Format a datetime/date to the 'YYYYMMDDTHHMMSS' string."""
from datetime import date, datetime as dt_cls
if isinstance(dt_val, dt_cls):
return dt_val.strftime("%Y%m%dT%H%M%S")
elif isinstance(dt_val, date):
return dt_val.strftime("%Y%m%dT000000")
return str(dt_val)
+119
View File
@@ -0,0 +1,119 @@
"""Countdown timer management — duration-based, once-only."""
import time
from datetime import datetime, timedelta
from backend.time_util import isoformat as tz_isoformat
from flask import current_app
from backend.models import get_db
def _uid() -> int:
"""Resolve the current user (request session or scheduler thread)."""
try:
from backend.auth import current_user_id
return current_user_id() or 0
except Exception:
return 0
def get_countdowns(user_id: int = None) -> list:
if user_id is None:
user_id = _uid()
db = get_db(current_app.config["DB_PATH"])
rows = [dict(r) for r in db.execute(
"SELECT * FROM countdowns WHERE user_id=? ORDER BY id DESC", (user_id,)
).fetchall()]
db.close()
return rows
def add_countdown(label: str, duration_seconds: int,
enabled: bool = True, sound: str = "default", user_id: int = None) -> dict:
if user_id is None:
user_id = _uid()
end_ts = str(time.time() + duration_seconds)
db = get_db(current_app.config["DB_PATH"])
cur = db.execute(
"INSERT INTO countdowns (user_id, label, duration_seconds, end_time, enabled, sound) "
"VALUES (?, ?, ?, ?, ?, ?)",
(user_id, label, duration_seconds, end_ts, 1 if enabled else 0, sound)
)
db.commit()
row = dict(db.execute("SELECT * FROM countdowns WHERE id=?", (cur.lastrowid,)).fetchone())
db.close()
return row
def update_countdown(cd_id: int, label: str = None, duration_seconds: int = None,
enabled: bool = None, sound: str = None, user_id: int = None):
if user_id is None:
user_id = _uid()
db = get_db(current_app.config["DB_PATH"])
fields = []
vals = []
if duration_seconds is not None:
fields.append("duration_seconds=?")
vals.append(duration_seconds)
fields.append("end_time=?")
vals.append(str(time.time() + duration_seconds))
for key, val in [("label", label),
("enabled", 1 if enabled else 0 if enabled is not None else None),
("sound", sound)]:
if val is not None:
fields.append(f"{key}=?")
vals.append(val)
ok = False
if fields:
vals.append(user_id)
vals.append(cd_id)
cur = db.execute(f"UPDATE countdowns SET {', '.join(fields)} WHERE user_id=? AND id=?", vals)
db.commit()
ok = cur.rowcount > 0
db.close()
return ok
def delete_countdown(cd_id: int, user_id: int = None) -> bool:
if user_id is None:
user_id = _uid()
db = get_db(current_app.config["DB_PATH"])
cur = db.execute("DELETE FROM countdowns WHERE user_id=? AND id=?", (user_id, cd_id))
db.commit()
db.close()
return cur.rowcount > 0
def check_countdowns(user_id: int = None) -> list:
"""Check for countdowns whose end_time has passed. Auto-disable after trigger."""
if user_id is None:
user_id = _uid()
now_ts = str(time.time())
db = get_db(current_app.config["DB_PATH"])
rows = db.execute(
"SELECT * FROM countdowns WHERE user_id=? AND enabled=1 AND end_time <= ? AND end_time != ''",
(user_id, now_ts)
).fetchall()
triggered = []
for r in rows:
cd_id = r["id"]
triggered.append(dict(r))
db.execute("UPDATE countdowns SET enabled=0 WHERE user_id=? AND id=?", (user_id, cd_id))
db.commit()
db.close()
if triggered:
current_app.logger.info(
f"Countdowns triggered: {[c['label'] for c in triggered]}"
)
current_app.event_bus.publish("countdown_triggered", {
"countdowns": triggered,
"ts": tz_isoformat(),
}, user_id=user_id)
return triggered
+49
View File
@@ -0,0 +1,49 @@
"""Server-Sent Event bus for pushing updates to displays.
Events are scoped by user_id so that each user's display only receives
updates about their own data (alarms, calendar, pomodoro, ...).
"""
import json
import queue
import threading
class EventBus:
"""Simple pub/sub event system with SSE support.
publish(event_type, data, user_id=None): when user_id is given, only
subscribers registered for that user receive the event.
"""
def __init__(self):
self._subscribers: list[tuple[int | None, queue.Queue]] = []
self._lock = threading.Lock()
def subscribe(self, user_id: int | None = None) -> queue.Queue:
"""Subscribe to events. user_id=None receives ALL events (broadcast)."""
q: queue.Queue = queue.Queue()
with self._lock:
self._subscribers.append((user_id, q))
return q
def unsubscribe(self, q: queue.Queue):
with self._lock:
self._subscribers = [(uid, sq) for uid, sq in self._subscribers if sq is not q]
def publish(self, event_type: str, data: dict, user_id: int | None = None):
msg = json.dumps({"type": event_type, "data": data})
with self._lock:
dead = []
for sub_uid, q in self._subscribers:
if sub_uid is not None and user_id is not None and sub_uid != user_id:
continue
try:
q.put_nowait(msg)
except queue.Full:
dead.append((sub_uid, q))
for item in dead:
self._subscribers.remove(item)
def broadcast(self):
"""Send heartbeat to all subscribers."""
self.publish("heartbeat", {"ts": __import__("time").time()})
+55
View File
@@ -0,0 +1,55 @@
"""Ed25519 key pair generation and JWT signing for QWeather API."""
from cryptography.hazmat.primitives.asymmetric import ed25519
from cryptography.hazmat.primitives import serialization
import jwt
import time
def generate_key_pair() -> tuple:
"""Generate an Ed25519 key pair.
Returns:
(private_key_pem: str, public_key_pem: str)
"""
private_key = ed25519.Ed25519PrivateKey.generate()
public_key = private_key.public_key()
private_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode("utf-8")
public_pem = public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
).decode("utf-8")
return private_pem, public_pem
def sign_jwt(private_key_pem: str, credential_id: str, project_id: str,
expiry_seconds: int = 3600) -> str:
"""Sign a JWT for QWeather API authentication.
Args:
private_key_pem: Ed25519 private key in PEM format.
credential_id: QWeather credential ID (kid).
project_id: QWeather project ID (sub).
expiry_seconds: Token validity in seconds (max 86400 per QWeather).
Returns:
Signed JWT string.
"""
now = int(time.time())
payload = {
"sub": project_id,
"iat": now,
"exp": now + min(expiry_seconds, 86400),
}
headers = {
"alg": "EdDSA",
"kid": credential_id,
}
token = jwt.encode(payload, private_key_pem, algorithm="EdDSA", headers=headers)
return token
+774
View File
@@ -0,0 +1,774 @@
"""SQLite database models, initialization, and user-scoped helpers.
Multi-user design:
- The `users` table stores every account. Root (ID 0) is the administrator and
is created automatically on first boot (fresh DB) or when migrating an
existing single-user database (all pre-existing data becomes Root's data).
- Every per-user table carries a `user_id INTEGER NOT NULL DEFAULT 0` column,
so legacy single-user rows are automatically attributed to Root on migration
and any NEW column added in the future gets a DEFAULT (the migration fills
missing columns with their SCHEMA defaults).
- `auth_config` is keyed by `user_id` (legacy `id=1` row maps to user 0).
"""
import sqlite3
import os
import sys
SCHEMA = """
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
remark TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('admin','user')),
is_temp INTEGER NOT NULL DEFAULT 0,
temp_totp_secret TEXT DEFAULT '',
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','pending')),
session_version INTEGER NOT NULL DEFAULT 0,
theme TEXT NOT NULL DEFAULT 'dark' CHECK(theme IN ('dark','light','auto')),
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS auth_config (
user_id INTEGER PRIMARY KEY DEFAULT 0,
passkey_credential_id TEXT DEFAULT '',
passkey_public_key TEXT DEFAULT '',
totp_secret TEXT DEFAULT '',
totp_enabled INTEGER DEFAULT 0,
recovery_codes TEXT DEFAULT '',
temp_password TEXT DEFAULT '',
must_configure_mfa INTEGER DEFAULT 1
);
CREATE TABLE IF NOT EXISTS calendar_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0,
name TEXT NOT NULL,
color TEXT DEFAULT '#4A90D9',
enabled INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS calendar_sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0,
group_id INTEGER NOT NULL,
type TEXT NOT NULL CHECK(type IN ('upload', 'subscription', 'manual')),
name TEXT NOT NULL,
url TEXT,
ics_data TEXT,
last_sync TEXT,
FOREIGN KEY (group_id) REFERENCES calendar_groups(id)
);
CREATE TABLE IF NOT EXISTS calendar_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0,
source_id INTEGER NOT NULL,
uid TEXT,
summary TEXT,
description TEXT,
location TEXT,
dtstart TEXT,
dtend TEXT,
is_all_day INTEGER DEFAULT 0,
FOREIGN KEY (source_id) REFERENCES calendar_sources(id)
);
CREATE TABLE IF NOT EXISTS reminders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0,
calendar_group_id INTEGER NOT NULL,
minutes_before INTEGER DEFAULT 15,
enabled INTEGER DEFAULT 1,
FOREIGN KEY (calendar_group_id) REFERENCES calendar_groups(id)
);
CREATE TABLE IF NOT EXISTS weather_config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0,
api_key TEXT DEFAULT '',
city TEXT DEFAULT 'Beijing',
lat REAL,
lon REAL,
units TEXT DEFAULT 'metric',
update_interval INTEGER DEFAULT 1800,
jwt_private_key TEXT DEFAULT '',
jwt_public_key TEXT DEFAULT '',
credential_id TEXT DEFAULT '',
project_id TEXT DEFAULT '',
qweather_host TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS weather_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0,
data TEXT,
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS weather_hourly_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0,
data TEXT,
fetched_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS weather_minutely_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0,
data TEXT,
fetched_at TEXT
);
CREATE TABLE IF NOT EXISTS weather_warning_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0,
data TEXT,
fetched_at TEXT
);
CREATE TABLE IF NOT EXISTS clock_config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0,
city TEXT DEFAULT 'Beijing',
timezone TEXT DEFAULT 'Asia/Shanghai',
is_primary INTEGER DEFAULT 1
);
CREATE TABLE IF NOT EXISTS alarms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0,
label TEXT DEFAULT '',
time TEXT NOT NULL,
days TEXT DEFAULT '1,2,3,4,5,6,7',
enabled INTEGER DEFAULT 1,
sound TEXT DEFAULT 'default',
once INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS countdowns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0,
label TEXT DEFAULT '',
duration_seconds INTEGER DEFAULT 300,
end_time TEXT DEFAULT '',
enabled INTEGER DEFAULT 1,
sound TEXT DEFAULT 'default'
);
CREATE TABLE IF NOT EXISTS display_config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0,
mode TEXT NOT NULL DEFAULT 'default' CHECK(mode IN ('default', 'custom')),
custom_html TEXT DEFAULT '',
custom_html_1 TEXT DEFAULT '',
custom_html_2 TEXT DEFAULT '',
custom_html_3 TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS pomodoro_state (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0,
running INTEGER DEFAULT 0,
phase TEXT DEFAULT 'work' CHECK(phase IN ('work', 'break')),
work_minutes INTEGER DEFAULT 25,
break_minutes INTEGER DEFAULT 5,
long_break_minutes INTEGER DEFAULT 15,
cycles_before_long_break INTEGER DEFAULT 4,
end_time TEXT,
remaining_seconds INTEGER DEFAULT 1500,
cycles INTEGER DEFAULT 0,
enabled INTEGER DEFAULT 0,
display_controls_enabled INTEGER DEFAULT 1,
cycles_date TEXT DEFAULT '',
work_label TEXT DEFAULT '工作中',
break_label TEXT DEFAULT '休息中'
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_pomodoro_state_user ON pomodoro_state(user_id);
"""
# Tables whose rows are completely per-user (used for cascade delete).
USER_DATA_TABLES = [
"auth_config", "calendar_groups", "calendar_sources", "calendar_events",
"reminders", "weather_config", "weather_cache", "weather_hourly_cache",
"weather_minutely_cache", "weather_warning_cache", "clock_config",
"alarms", "countdowns", "display_config", "pomodoro_state",
]
def get_db(db_path: str = "data.db") -> sqlite3.Connection:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
def _db_path(preferred: str = None) -> str:
"""Get the actual database path, preferring current_app config if available."""
if preferred:
return preferred
try:
from flask import current_app
return current_app.config.get("DB_PATH", "data.db")
except Exception:
return "data.db"
# ─── Users ───────────────────────────────────
def get_users(db_path: str = None) -> list:
"""Return all users ordered by id."""
conn = get_db(_db_path(db_path))
rows = [dict(r) for r in conn.execute("SELECT * FROM users ORDER BY id").fetchall()]
conn.close()
return rows
def get_active_user_ids(db_path: str = None) -> list:
"""Return ids of all active (non-pending) users. Used by the scheduler."""
conn = get_db(_db_path(db_path))
rows = conn.execute("SELECT id FROM users WHERE status='active' ORDER BY id").fetchall()
conn.close()
return [r["id"] for r in rows]
def get_user(user_id: int, db_path: str = None) -> dict | None:
conn = get_db(_db_path(db_path))
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
conn.close()
return dict(row) if row else None
def user_exists(user_id: int, db_path: str = None) -> bool:
conn = get_db(_db_path(db_path))
row = conn.execute("SELECT 1 FROM users WHERE id=?", (user_id,)).fetchone()
conn.close()
return row is not None
def is_admin_user(user_id: int, db_path: str = None) -> bool:
"""Root is always admin; otherwise check the role column."""
if user_id == 0:
return True
conn = get_db(_db_path(db_path))
row = conn.execute("SELECT role FROM users WHERE id=?", (user_id,)).fetchone()
conn.close()
return bool(row and row["role"] == "admin")
def create_user(remark: str = "", role: str = "user", is_temp: int = 0,
temp_totp_secret: str = "", status: str = "active",
db_path: str = None) -> int:
"""Create a user. Returns the new user id.
The remark defaults to the user's own id (the only place an id is shown).
"""
conn = get_db(_db_path(db_path))
cur = conn.execute(
"INSERT INTO users (remark, role, is_temp, temp_totp_secret, status) VALUES (?, ?, ?, ?, ?)",
(remark, role, 1 if is_temp else 0, temp_totp_secret, status)
)
uid = cur.lastrowid
if not remark:
conn.execute("UPDATE users SET remark=? WHERE id=?", (str(uid), uid))
conn.commit()
conn.close()
return uid
def update_user(user_id: int, remark: str = None, role: str = None,
is_temp: int = None, temp_totp_secret: str = None,
status: str = None, theme: str = None, db_path: str = None):
conn = get_db(_db_path(db_path))
fields = []
vals = []
for key, val in [("remark", remark), ("role", role), ("is_temp", is_temp),
("temp_totp_secret", temp_totp_secret), ("status", status),
("theme", theme)]:
if val is not None:
fields.append(f"{key}=?")
vals.append(val)
if fields:
vals.append(user_id)
conn.execute(f"UPDATE users SET {', '.join(fields)} WHERE id=?", vals)
conn.commit()
conn.close()
def delete_user(user_id: int, db_path: str = None):
"""Delete a user and ALL of their data. Root (id 0) cannot be deleted.
Deletion order respects foreign keys (children before parents).
"""
if user_id == 0:
return
conn = get_db(_db_path(db_path))
# Children first so FK constraints never reject a delete.
ordered = [
"calendar_events", "calendar_sources", "reminders", "calendar_groups",
"weather_cache", "weather_hourly_cache", "weather_minutely_cache",
"weather_warning_cache", "weather_config", "clock_config",
"alarms", "countdowns", "display_config", "pomodoro_state", "auth_config",
]
try:
for table in ordered:
conn.execute(f"DELETE FROM {table} WHERE user_id=?", (user_id,))
conn.execute("DELETE FROM users WHERE id=?", (user_id,))
conn.commit()
finally:
conn.close()
def get_user_session_version(user_id: int, db_path: str = None) -> int:
conn = get_db(_db_path(db_path))
row = conn.execute("SELECT session_version FROM users WHERE id=?", (user_id,)).fetchone()
conn.close()
return row["session_version"] if row else -1
def bump_user_session_version(user_id: int, db_path: str = None) -> int:
"""Invalidate all sessions of one user by incrementing their version."""
conn = get_db(_db_path(db_path))
conn.execute("UPDATE users SET session_version = session_version + 1 WHERE id=?", (user_id,))
conn.commit()
row = conn.execute("SELECT session_version FROM users WHERE id=?", (user_id,)).fetchone()
conn.close()
return row["session_version"] if row else -1
def bump_all_session_versions(db_path: str = None) -> int:
"""Invalidate every session (admin 'kick all' action)."""
conn = get_db(_db_path(db_path))
conn.execute("UPDATE users SET session_version = session_version + 1")
conn.commit()
conn.close()
return 0
def is_temp_account(user_id: int, db_path: str = None) -> bool:
"""True if the account is a temp account (awaiting first login-method setup)."""
conn = get_db(_db_path(db_path))
row = conn.execute("SELECT is_temp FROM users WHERE id=?", (user_id,)).fetchone()
conn.close()
return bool(row and row["is_temp"])
def get_temp_totp_secret(user_id: int, db_path: str = None) -> str:
conn = get_db(_db_path(db_path))
row = conn.execute("SELECT temp_totp_secret FROM users WHERE id=?", (user_id,)).fetchone()
conn.close()
return (row["temp_totp_secret"] or "") if row else ""
def finalize_temp_account(user_id: int, db_path: str = None):
"""Convert a temp account into a formal account:
invalidates the temp TOTP and clears the temp flag.
"""
conn = get_db(_db_path(db_path))
conn.execute(
"UPDATE users SET is_temp=0, temp_totp_secret='' WHERE id=?",
(user_id,)
)
conn.execute(
"UPDATE auth_config SET must_configure_mfa=0 WHERE user_id=?",
(user_id,)
)
conn.commit()
conn.close()
# ─── Display config (per user) ──────────────
def _ensure_display_row(conn, user_id: int):
row = conn.execute("SELECT id FROM display_config WHERE user_id=?", (user_id,)).fetchone()
if row is None:
conn.execute(
"INSERT INTO display_config (user_id, mode) VALUES (?, 'default')",
(user_id,)
)
def get_display_config(db_path: str = None, user_id: int = 0) -> dict:
db = get_db(_db_path(db_path))
row = db.execute(
"SELECT mode, custom_html, custom_html_1, custom_html_2, custom_html_3 "
"FROM display_config WHERE user_id=?",
(user_id,)
).fetchone()
if row is None:
db.execute(
"INSERT INTO display_config (user_id, mode, custom_html, custom_html_1, custom_html_2, custom_html_3) "
"VALUES (?, 'default', '', '', '', '')",
(user_id,)
)
db.commit()
cfg = {"mode": "default", "custom_html": "", "custom_html_1": "", "custom_html_2": "", "custom_html_3": ""}
else:
cfg = {
"mode": row["mode"],
"custom_html": row["custom_html"] or "",
"custom_html_1": row["custom_html_1"] or "",
"custom_html_2": row["custom_html_2"] or "",
"custom_html_3": row["custom_html_3"] or "",
}
db.close()
return cfg
def set_display_mode(mode: str, db_path: str = None, user_id: int = 0) -> bool:
if mode not in ("default", "custom"):
return False
db = get_db(_db_path(db_path))
_ensure_display_row(db, user_id)
db.execute("UPDATE display_config SET mode=? WHERE user_id=?", (mode, user_id))
db.commit()
db.close()
return True
def set_custom_html(html: str, db_path: str = None, user_id: int = 0) -> bool:
db = get_db(_db_path(db_path))
_ensure_display_row(db, user_id)
db.execute("UPDATE display_config SET custom_html=? WHERE user_id=?", (html, user_id))
db.commit()
db.close()
return True
def set_custom_html_template(index: int, html: str, db_path: str = None, user_id: int = 0) -> bool:
if index not in (1, 2, 3):
return False
db = get_db(_db_path(db_path))
_ensure_display_row(db, user_id)
db.execute(
f"UPDATE display_config SET custom_html_{index}=? WHERE user_id=?",
(html, user_id)
)
db.commit()
db.close()
return True
def apply_custom_html(index: int, db_path: str = None, user_id: int = 0) -> bool:
"""Copy template N (0=main custom_html, 1-3=custom_html_1-3) into custom_html and set mode to custom."""
db = get_db(_db_path(db_path))
row = db.execute(
"SELECT custom_html, custom_html_1, custom_html_2, custom_html_3 "
"FROM display_config WHERE user_id=?",
(user_id,)
).fetchone()
if row is None:
db.close()
return False
if index == 0:
html = row["custom_html"] or ""
elif index == 1:
html = row["custom_html_1"] or ""
elif index == 2:
html = row["custom_html_2"] or ""
elif index == 3:
html = row["custom_html_3"] or ""
else:
db.close()
return False
db.execute("UPDATE display_config SET mode='custom', custom_html=? WHERE user_id=?", (html, user_id))
db.commit()
db.close()
return True
# ─── Auth / MFA helpers (per user) ──────────
def get_auth_config(db_path: str = None, user_id: int = 0) -> dict:
"""Return the full auth_config row for a user, with defaults for missing fields."""
conn = get_db(_db_path(db_path))
row = conn.execute("SELECT * FROM auth_config WHERE user_id = ?", (user_id,)).fetchone()
if row is None:
conn.execute(
"""INSERT INTO auth_config (user_id, passkey_credential_id, passkey_public_key,
totp_secret, totp_enabled, recovery_codes, temp_password, must_configure_mfa)
VALUES (?, '', '', '', 0, '', '', 1)""",
(user_id,)
)
conn.commit()
cfg = {
"user_id": user_id,
"passkey_credential_id": "",
"passkey_public_key": "",
"totp_secret": "",
"totp_enabled": 0,
"recovery_codes": "",
"temp_password": "",
"must_configure_mfa": 1,
}
else:
cfg = {
"user_id": row["user_id"],
"passkey_credential_id": row["passkey_credential_id"] or "",
"passkey_public_key": row["passkey_public_key"] or "",
"totp_secret": row["totp_secret"] or "",
"totp_enabled": bool(row["totp_enabled"]),
"recovery_codes": row["recovery_codes"] or "",
"temp_password": row["temp_password"] or "",
"must_configure_mfa": bool(row["must_configure_mfa"]),
}
conn.close()
return cfg
def update_auth_config(user_id: int = 0, **kwargs):
"""Update auth_config fields. Keys match DB column names."""
db_path = _db_path(kwargs.pop("_db_path", None))
conn = get_db(db_path)
row = conn.execute("SELECT user_id FROM auth_config WHERE user_id = ?", (user_id,)).fetchone()
fields = []
vals = []
for key, val in kwargs.items():
if key in ("passkey_credential_id", "passkey_public_key", "totp_secret",
"recovery_codes", "temp_password", "must_configure_mfa", "totp_enabled"):
fields.append(f"{key}=?")
vals.append(val)
if not fields:
conn.close()
return
if row is None:
cols = ["user_id"] + [f.split("=")[0] for f in fields]
col_str = ", ".join(cols)
ph = ", ".join("?" * len(cols))
conn.execute(f"INSERT INTO auth_config ({col_str}) VALUES ({ph})", (user_id, *vals))
else:
vals.append(user_id)
conn.execute(f"UPDATE auth_config SET {', '.join(fields)} WHERE user_id=?", vals)
conn.commit()
conn.close()
def get_temp_password(db_path: str = None, user_id: int = 0) -> str:
cfg = get_auth_config(db_path, user_id)
return cfg["temp_password"]
def set_temp_password(password: str, db_path: str = None, user_id: int = 0):
update_auth_config(user_id, temp_password=password, _db_path=db_path)
def clear_temp_password(db_path: str = None, user_id: int = 0):
update_auth_config(user_id, temp_password="", must_configure_mfa=0, _db_path=db_path)
def get_recovery_codes(db_path: str = None, user_id: int = 0) -> list:
cfg = get_auth_config(db_path, user_id)
raw = cfg["recovery_codes"]
if not raw:
return []
return raw.split(",")
def set_recovery_codes(codes: list, db_path: str = None, user_id: int = 0):
update_auth_config(user_id, recovery_codes=",".join(codes), _db_path=db_path)
def has_mfa_configured(db_path: str = None, user_id: int = 0) -> bool:
cfg = get_auth_config(db_path, user_id)
return bool(cfg["passkey_credential_id"]) or bool(cfg["totp_enabled"])
def find_user_by_credential_id(credential_id: str, db_path: str = None) -> int | None:
"""Find the user who owns a given WebAuthn credential id (for login)."""
conn = get_db(_db_path(db_path))
row = conn.execute(
"SELECT a.user_id FROM auth_config a "
"JOIN users u ON u.id = a.user_id "
"WHERE a.passkey_credential_id = ? AND u.status = 'active'",
(credential_id,)
).fetchone()
conn.close()
return row["user_id"] if row else None
# ─── DB init / migration ─────────────────────
def _ensure_defaults(conn):
"""Insert default rows for config tables and the Root user when missing.
Generates a random 6-digit temp password when Root's auth_config is created
(or when a migrated DB has no MFA configured yet).
"""
import secrets
import string
# Root user (id 0)
root = conn.execute("SELECT id FROM users WHERE id=0").fetchone()
if root is None:
conn.execute(
"INSERT OR IGNORE INTO users (id, remark, role, status) VALUES (0, 'Root', 'admin', 'active')"
)
# weather_config for Root
count = conn.execute("SELECT COUNT(*) FROM weather_config WHERE user_id=0").fetchone()[0]
if count == 0:
conn.execute("INSERT INTO weather_config (user_id, city, lat, lon) "
"VALUES (0, 'Beijing', 39.9042, 116.4074)")
# clock_config for Root
count = conn.execute("SELECT COUNT(*) FROM clock_config WHERE user_id=0").fetchone()[0]
if count == 0:
conn.execute("INSERT INTO clock_config (user_id, city, timezone, is_primary) "
"VALUES (0, 'Beijing', 'Asia/Shanghai', 1)")
# auth_config for Root
row = conn.execute("SELECT * FROM auth_config WHERE user_id=0").fetchone()
mfa_configured = bool(row and (row["passkey_credential_id"] or row["totp_enabled"]))
if row is None:
temp_pw = "".join(secrets.choice(string.digits) for _ in range(6))
conn.execute(
"INSERT INTO auth_config (user_id, passkey_credential_id, passkey_public_key, "
"totp_secret, totp_enabled, recovery_codes, temp_password, must_configure_mfa) "
"VALUES (0, '', '', '', 0, '', ?, 1)",
(temp_pw,)
)
_EMITTED_PASSWORDS.append(temp_pw)
print(f"\n{'='*50}", file=sys.stderr)
print(f" 🔐 Root 初始临时密码: {temp_pw}", file=sys.stderr)
print(f" ⚠️ 请使用此密码登录 Root 并配置双因素认证", file=sys.stderr)
print(f"{'='*50}\n", file=sys.stderr)
else:
# Migrated DB. If Root already has MFA, no temp password is needed.
if mfa_configured:
if row["temp_password"] or row["must_configure_mfa"]:
conn.execute(
"UPDATE auth_config SET temp_password='', must_configure_mfa=0 WHERE user_id=0"
)
elif (not row["temp_password"]) and row["must_configure_mfa"]:
# Migrated DB without MFA — generate a fresh temp password.
temp_pw = "".join(secrets.choice(string.digits) for _ in range(6))
conn.execute(
"UPDATE auth_config SET temp_password=? WHERE user_id=0",
(temp_pw,)
)
_EMITTED_PASSWORDS.append(temp_pw)
print(f"\n{'='*50}", file=sys.stderr)
print(f" 🔐 迁移已存在数据库 — Root 临时密码: {temp_pw}", file=sys.stderr)
print(f" ⚠️ 请使用此密码登录 Root 并配置双因素认证", file=sys.stderr)
print(f"{'='*50}\n", file=sys.stderr)
conn.commit()
# Stores passwords that were just generated, for the startup script to print.
_EMITTED_PASSWORDS: list = []
def _table_columns(conn, table_name):
return [row["name"] for row in conn.execute(f"PRAGMA table_info({table_name})")]
def _migrate_auth_config(conn_new, conn_old):
"""Special-case migration for auth_config: the legacy table is keyed by
`id` (single user, id=1), the new table is keyed by `user_id`.
The legacy single-user row belongs to Root (user 0).
"""
old_cols = _table_columns(conn_old, "auth_config")
new_cols = _table_columns(conn_new, "auth_config")
if not old_cols:
return
rows = conn_old.execute("SELECT * FROM auth_config").fetchall()
for row in rows:
d = dict(row)
if "user_id" in old_cols:
uid = d.get("user_id") or 0
else:
# Legacy single-user DB: the id=1 row belongs to Root (user 0).
old_id = d.get("id", 1)
uid = 0 if old_id == 1 else (old_id or 0)
common = [c for c in new_cols if c in old_cols and c not in ("id", "user_id")]
if not common:
continue
col_str = ", ".join(common)
placeholders = ", ".join("?" * len(common))
conn_new.execute(
f"INSERT OR IGNORE INTO auth_config (user_id, {col_str}) VALUES (?, {placeholders})",
(uid, *[d[c] for c in common])
)
def init_db(db_path: str = "data.db"):
"""Initialize the database.
- No database file: create it from the latest SCHEMA and create Root.
- Existing database: rebuild it from the latest SCHEMA, migrate compatible
data (legacy single-user data becomes Root's), fill missing columns with
SCHEMA defaults, then replace the old file (keeping a backup).
"""
if not os.path.exists(db_path):
conn = get_db(db_path)
conn.executescript(SCHEMA)
_ensure_defaults(conn)
conn.close()
return
# Migrate by rebuilding: create a fresh DB with the latest schema,
# copy compatible data from the old DB, then replace the old DB.
new_db_path = db_path + ".new"
backup_path = db_path + ".backup"
# Clean up any stale temporary database from a previous failed run.
if os.path.exists(new_db_path):
os.remove(new_db_path)
conn_new = get_db(new_db_path)
conn_new.executescript(SCHEMA)
conn_old = get_db(db_path)
# Disable FK enforcement while copying; rows are copied table by table.
conn_old.execute("PRAGMA foreign_keys=OFF")
conn_new.execute("PRAGMA foreign_keys=OFF")
tables = conn_old.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
).fetchall()
for (table_name,) in tables:
# Tables that no longer exist in the new schema are dropped (e.g. camera_config).
exists_new = conn_new.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
(table_name,)
).fetchone()
if not exists_new:
continue
if table_name == "auth_config":
_migrate_auth_config(conn_new, conn_old)
continue
old_cols = _table_columns(conn_old, table_name)
new_cols = _table_columns(conn_new, table_name)
common_cols = [c for c in new_cols if c in old_cols]
if not common_cols:
continue
col_str = ", ".join(common_cols)
placeholders = ", ".join("?" * len(common_cols))
sql = f"INSERT OR IGNORE INTO {table_name} ({col_str}) VALUES ({placeholders})"
for row in conn_old.execute(f"SELECT {col_str} FROM {table_name}"):
try:
conn_new.execute(sql, tuple(row))
except Exception:
# Ignore rows that cannot be migrated (e.g. constraint violations).
pass
conn_new.commit()
# Now fill defaults for any tables that are still empty + Root bootstrap.
_ensure_defaults(conn_new)
conn_old.close()
conn_new.close()
# Replace old DB with the new one, keeping a backup.
if os.path.exists(backup_path):
os.remove(backup_path)
os.rename(db_path, backup_path)
os.rename(new_db_path, db_path)
+312
View File
@@ -0,0 +1,312 @@
"""Pomodoro timer management: state persistence, countdown, cycle control.
State is stored per user (one row per user_id).
"""
import json
import os
import time
from datetime import datetime, timedelta
from backend.time_util import now as tz_now
from flask import current_app
from backend.models import get_db
DEFAULT_WORK_MINUTES = 25
DEFAULT_BREAK_MINUTES = 5
DEFAULT_LONG_BREAK_MINUTES = 15
DEFAULT_CYCLES_BEFORE_LONG_BREAK = 4
def _uid() -> int:
"""Resolve the current user (request session or scheduler thread)."""
try:
from backend.auth import current_user_id
return current_user_id() or 0
except Exception:
return 0
def _get_db():
return get_db(current_app.config["DB_PATH"])
def get_state(user_id: int = None) -> dict:
"""Return current pomodoro state from DB.
Cycles reset at the start of each new day (daily auto-clear).
"""
if user_id is None:
user_id = _uid()
db = _get_db()
row = db.execute("SELECT * FROM pomodoro_state WHERE user_id = ?", (user_id,)).fetchone()
if row is None:
db.close()
return _default_state()
today = tz_now().strftime("%Y%m%d")
cycles_date = row["cycles_date"] or ""
cycles = row["cycles"]
# Daily reset: if no date recorded or it's a new day, clear cycles
if cycles_date != today:
cycles = 0
cycles_date = today
db.execute(
"UPDATE pomodoro_state SET cycles=0, cycles_date=? WHERE user_id=?",
(today, user_id),
)
db.commit()
db.close()
long_break_minutes = row["long_break_minutes"] or DEFAULT_LONG_BREAK_MINUTES
cycles_before_long_break = row["cycles_before_long_break"] or DEFAULT_CYCLES_BEFORE_LONG_BREAK
# Compute next break type for display
if row["phase"] == "work":
next_break_long = ((cycles + 1) % cycles_before_long_break) == 0
else:
next_break_long = ((cycles + 2) % cycles_before_long_break) == 0
return {
"running": bool(row["running"]),
"phase": row["phase"], # 'work' or 'break'
"work_minutes": row["work_minutes"],
"break_minutes": row["break_minutes"],
"long_break_minutes": long_break_minutes,
"cycles_before_long_break": cycles_before_long_break,
"next_break_type": "long" if next_break_long else "short",
"end_time": row["end_time"],
"remaining_seconds": row["remaining_seconds"],
"cycles": cycles,
"cycles_date": cycles_date,
"enabled": bool(row["enabled"]),
"display_controls_enabled": bool(row["display_controls_enabled"]),
"work_label": row["work_label"] or "工作中",
"break_label": row["break_label"] or "休息中",
}
def _default_state() -> dict:
return {
"running": False,
"phase": "work",
"work_minutes": DEFAULT_WORK_MINUTES,
"break_minutes": DEFAULT_BREAK_MINUTES,
"long_break_minutes": DEFAULT_LONG_BREAK_MINUTES,
"cycles_before_long_break": DEFAULT_CYCLES_BEFORE_LONG_BREAK,
"next_break_type": "short",
"end_time": None,
"remaining_seconds": DEFAULT_WORK_MINUTES * 60,
"cycles": 0,
"enabled": False,
"display_controls_enabled": True,
"cycles_date": "",
"work_label": "工作中",
"break_label": "休息中",
}
def _ensure_row(db, user_id: int):
row = db.execute("SELECT id FROM pomodoro_state WHERE user_id = ?", (user_id,)).fetchone()
if row is None:
db.execute(
"""INSERT INTO pomodoro_state
(user_id, running, phase, work_minutes, break_minutes, long_break_minutes, cycles_before_long_break, end_time, remaining_seconds, cycles, enabled, display_controls_enabled, cycles_date, work_label, break_label)
VALUES (?, 0, 'work', ?, ?, ?, ?, NULL, ?, 0, 0, 1, '', '工作中', '休息中')""",
(user_id, DEFAULT_WORK_MINUTES, DEFAULT_BREAK_MINUTES, DEFAULT_LONG_BREAK_MINUTES,
DEFAULT_CYCLES_BEFORE_LONG_BREAK, DEFAULT_WORK_MINUTES * 60),
)
db.commit()
def create_or_update(work_minutes: int = None, break_minutes: int = None,
long_break_minutes: int = None, cycles_before_long_break: int = None,
enabled: bool = None, display_controls_enabled: bool = None,
work_label: str = None, break_label: str = None,
user_id: int = None) -> dict:
"""Create or update pomodoro settings. Stops the timer and does not auto-start."""
if user_id is None:
user_id = _uid()
db = _get_db()
_ensure_row(db, user_id)
# Apply new settings first so the reset below uses the updated durations.
fields = []
vals = []
if work_minutes is not None:
fields.append("work_minutes=?")
vals.append(work_minutes)
if break_minutes is not None:
fields.append("break_minutes=?")
vals.append(break_minutes)
if long_break_minutes is not None:
fields.append("long_break_minutes=?")
vals.append(long_break_minutes)
if cycles_before_long_break is not None:
fields.append("cycles_before_long_break=?")
vals.append(cycles_before_long_break)
if enabled is not None:
fields.append("enabled=?")
vals.append(1 if enabled else 0)
if display_controls_enabled is not None:
fields.append("display_controls_enabled=?")
vals.append(1 if display_controls_enabled else 0)
if work_label is not None:
fields.append("work_label=?")
vals.append(work_label)
if break_label is not None:
fields.append("break_label=?")
vals.append(break_label)
if fields:
vals.append(user_id)
db.execute(f"UPDATE pomodoro_state SET {', '.join(fields)} WHERE user_id=?", vals)
# Stop the timer and reset to the work phase. Do not auto-start after saving.
db.execute(
"""UPDATE pomodoro_state
SET running=0, phase='work', end_time=NULL, remaining_seconds=work_minutes*60, enabled=0
WHERE user_id=?""",
(user_id,)
)
db.commit()
state = get_state(user_id)
db.close()
current_app.event_bus.publish("pomodoro_updated", state, user_id=user_id)
return state
def start(user_id: int = None) -> dict:
"""Start or resume the pomodoro timer."""
if user_id is None:
user_id = _uid()
db = _get_db()
_ensure_row(db, user_id)
state = get_state(user_id)
remaining = state.get("remaining_seconds") or (state["work_minutes"] * 60)
end_time = str(time.time() + remaining)
db.execute(
"UPDATE pomodoro_state SET running=1, end_time=?, remaining_seconds=?, enabled=1 WHERE user_id=?",
(end_time, remaining, user_id),
)
db.commit()
db.close()
updated = get_state(user_id)
current_app.event_bus.publish("pomodoro_updated", updated, user_id=user_id)
return updated
def pause(user_id: int = None) -> dict:
"""Pause the pomodoro timer, saving remaining time."""
if user_id is None:
user_id = _uid()
db = _get_db()
_ensure_row(db, user_id)
state = get_state(user_id)
remaining = _calc_remaining(state)
db.execute(
"UPDATE pomodoro_state SET running=0, end_time=NULL, remaining_seconds=? WHERE user_id=?",
(remaining, user_id),
)
db.commit()
db.close()
updated = get_state(user_id)
current_app.event_bus.publish("pomodoro_updated", updated, user_id=user_id)
return updated
def stop(user_id: int = None) -> dict:
"""Stop and reset the pomodoro timer."""
if user_id is None:
user_id = _uid()
db = _get_db()
_ensure_row(db, user_id)
db.execute(
"UPDATE pomodoro_state SET running=0, phase='work', end_time=NULL, remaining_seconds=work_minutes*60, enabled=0 WHERE user_id=?",
(user_id,)
)
db.commit()
db.close()
updated = get_state(user_id)
current_app.event_bus.publish("pomodoro_updated", updated, user_id=user_id)
return updated
def _advance_phase(user_id: int = None) -> dict:
"""Advance to the next phase (work ↔ break), start the timer, persist, notify.
Determines short vs long break based on cycles_before_long_break setting."""
if user_id is None:
user_id = _uid()
db = _get_db()
_ensure_row(db, user_id)
state = get_state(user_id)
if state["phase"] == "work":
# Work → break: decide short or long
cycles_before_long = state.get("cycles_before_long_break", DEFAULT_CYCLES_BEFORE_LONG_BREAK)
is_long_break = ((state["cycles"] + 1) % cycles_before_long) == 0
if is_long_break:
duration = state.get("long_break_minutes", DEFAULT_LONG_BREAK_MINUTES) * 60
else:
duration = state["break_minutes"] * 60
next_phase = "break"
cycles = state["cycles"] # cycles unchanged on work→break
else:
# Break → work: always use work duration
duration = state["work_minutes"] * 60
next_phase = "work"
cycles = state["cycles"] + 1
end_time = str(time.time() + duration)
today = tz_now().strftime("%Y%m%d")
db.execute(
"UPDATE pomodoro_state SET running=1, phase=?, end_time=?, remaining_seconds=?, cycles=?, cycles_date=? WHERE user_id=?",
(next_phase, end_time, duration, cycles, today, user_id),
)
db.commit()
db.close()
updated = get_state(user_id)
current_app.event_bus.publish("pomodoro_updated", updated, user_id=user_id)
return updated
def skip(user_id: int = None) -> dict:
"""Skip the current phase and start the next phase immediately (work ↔ break)."""
return _advance_phase(user_id)
def dismiss(user_id: int = None) -> dict:
"""Dismiss ringing and auto-advance to next phase."""
return _advance_phase(user_id)
def _calc_remaining(state: dict) -> int:
"""Calculate remaining seconds from state."""
if not state.get("running"):
return state.get("remaining_seconds", state["work_minutes"] * 60)
end_time_str = state.get("end_time")
if not end_time_str:
return state.get("remaining_seconds", state["work_minutes"] * 60)
try:
remaining = int(float(end_time_str) - time.time())
return max(0, remaining)
except (ValueError, TypeError):
return state.get("remaining_seconds", state["work_minutes"] * 60)
def check_and_trigger(user_id: int = None) -> bool:
"""Called by scheduler to check if pomodoro timer has expired.
Returns True if triggered (ringing should start)."""
if user_id is None:
user_id = _uid()
db = _get_db()
_ensure_row(db, user_id)
state = get_state(user_id)
db.close()
if not state.get("running") or not state.get("enabled"):
return False
remaining = _calc_remaining(state)
if remaining > 0:
return False
# Timer expired; auto-advance phase on the backend once, then notify displays.
updated = dismiss(user_id)
current_app.event_bus.publish("pomodoro_due", updated, user_id=user_id)
return True
+1672
View File
File diff suppressed because it is too large Load Diff
+86
View File
@@ -0,0 +1,86 @@
"""Background scheduler for periodic tasks.
All user-scoped jobs iterate over every active user (with that user's
timezone/context active) so each user's alarms, reminders, calendars,
pomodoro, countdowns and weather are serviced independently.
"""
from apscheduler.schedulers.background import BackgroundScheduler
from backend.calendar_mgr import sync_all_subscriptions, get_upcoming_reminders
from backend.weather_mgr import fetch_weather, fetch_hourly
from backend.alarm_mgr import check_alarms
from backend.countdown_mgr import check_countdowns
from backend.pomodoro_mgr import check_and_trigger
from backend.models import get_db
from backend.time_util import set_thread_user, clear_thread_user
_scheduler: BackgroundScheduler | None = None
def _each_user(app, fn):
"""Run fn(user_id) for every active user inside the app context."""
with app.app_context():
db = get_db(app.config.get("DB_PATH", "data.db"))
rows = db.execute("SELECT id FROM users WHERE status='active' ORDER BY id").fetchall()
db.close()
for u in rows:
uid = u["id"]
try:
set_thread_user(uid)
fn(uid)
except Exception as e:
app.logger.error(f"Scheduler error for user {uid}: {e}")
finally:
clear_thread_user()
def start_scheduler(app):
global _scheduler
_scheduler = BackgroundScheduler()
@_scheduler.scheduled_job("interval", seconds=10, id="check_alarms")
def _check_alarms():
_each_user(app, check_alarms)
@_scheduler.scheduled_job("interval", minutes=1, id="check_reminders")
def _check_reminders():
def _run(uid):
due = get_upcoming_reminders(uid)
if due:
app.event_bus.publish("reminder_due", {"events": due}, user_id=uid)
app.logger.info(f"Reminders due for user {uid}: {len(due)} events")
_each_user(app, _run)
@_scheduler.scheduled_job("interval", minutes=30, id="sync_calendars")
def _sync_calendars():
with app.app_context():
sync_all_subscriptions()
@_scheduler.scheduled_job("cron", minute="0,30", id="fetch_weather")
def _fetch_weather():
_each_user(app, fetch_weather)
@_scheduler.scheduled_job("interval", seconds=1, id="check_pomodoro")
def _check_pomodoro():
_each_user(app, check_and_trigger)
@_scheduler.scheduled_job("cron", minute="1", id="fetch_hourly")
def _fetch_hourly():
_each_user(app, fetch_hourly)
@_scheduler.scheduled_job("interval", seconds=1, id="check_countdowns")
def _check_countdowns():
def _run(uid):
triggered = check_countdowns(uid)
if triggered:
app.logger.info(f"Countdowns triggered for user {uid}: {[c['label'] for c in triggered]}")
_each_user(app, _run)
_scheduler.start()
def stop_scheduler():
global _scheduler
if _scheduler:
_scheduler.shutdown(wait=False)
_scheduler = None
+114
View File
@@ -0,0 +1,114 @@
"""日出日落时间计算(NOAA 简化算法)与白天/夜晚判定。
用于"自动"主题模式:根据用户天气配置的经纬度 + 本地时区,判断当前
时刻处于白天(浅色主题)还是夜晚(深色主题)。
算法来源:Ed Williams《Sunrise/Sunset Algorithm》
(https://edwilliams.org/sunrise_sunset_algorithm.htm)
"""
import math
from datetime import datetime, timezone, timedelta
# 太阳天顶角:日出日落定义(含大气折射)
ZENITH = 90.833
def _day_of_year(dt: datetime) -> int:
"""计算一年中的第几天(NOAA 公式)。"""
y, m, d = dt.year, dt.month, dt.day
n1 = math.floor(275 * m / 9)
n2 = math.floor((m + 9) / 12)
n3 = 1 + math.floor((y - 4 * math.floor(y / 4) + 2) / 3)
return n1 - (n2 * n3) + d - 30
def _hour_angle(lat: float, lon: float, day_of_year: int, is_sunrise: bool) -> float | None:
"""返回日出/日落的 UTC 小时(0~24),None 表示极昼/极夜(当天无日出日落)。"""
lng_hour = lon / 15.0
if is_sunrise:
t = day_of_year + ((6 - lng_hour) / 24.0)
else:
t = day_of_year + ((18 - lng_hour) / 24.0)
m = (0.9856 * t) - 3.289
l = (m + 1.916 * math.sin(math.radians(m))
+ 0.020 * math.sin(math.radians(2 * m)) + 282.634) % 360
ra = math.degrees(math.atan(0.91764 * math.tan(math.radians(l)))) % 360
# 校正 RA 象限
l_quad = math.floor(l / 90) * 90
ra_quad = math.floor(ra / 90) * 90
ra = ra + (l_quad - ra_quad)
ra /= 15.0 # 转为小时
sin_dec = 0.39782 * math.sin(math.radians(l))
cos_dec = math.cos(math.asin(sin_dec))
cos_h = ((math.cos(math.radians(ZENITH)) - sin_dec * math.sin(math.radians(lat)))
/ (cos_dec * math.cos(math.radians(lat))))
if cos_h > 1 or cos_h < -1:
return None # 极昼/极夜
if is_sunrise:
h = 360 - math.degrees(math.acos(cos_h))
else:
h = math.degrees(math.acos(cos_h))
h /= 15.0
# NOAA:T = H + RA - 0.06571*t - 6.622(小时),UT = T - lngHour(mod 24)
local_t = h + ra - 0.06571 * t - 6.622
return (local_t - lng_hour) % 24.0
def sun_times_utc(lat: float, lon: float, date: datetime) -> tuple[datetime | None, datetime | None]:
"""返回 (日出UTC, 日落UTC),极昼/极夜时对应项为 None。
date 为任意带时区 datetime(取其日期)。"""
lat = float(lat)
lon = float(lon)
day = _day_of_year(date.astimezone(timezone.utc))
sunrise_h = _hour_angle(lat, lon, day, True)
sunset_h = _hour_angle(lat, lon, day, False)
if sunrise_h is None and sunset_h is None:
return None, None # 极昼或极夜,调用方按本地小时粗判
def to_utc(h: float) -> datetime:
base = date.astimezone(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
return base + timedelta(hours=h)
sunrise = to_utc(sunrise_h) if sunrise_h is not None else None
sunset = to_utc(sunset_h) if sunset_h is not None else None
return sunrise, sunset
def is_daytime(lat: float, lon: float, now_local: datetime) -> bool:
"""判断本地时刻是否为白天。
now_local 为带时区的本地 datetime(使用用户配置时区)。
用 UTC 小时比较(忽略日期归属),自动处理日出/日落跨 UTC 午夜的情况。
"""
now_utc = now_local.astimezone(timezone.utc)
sunrise, sunset = sun_times_utc(lat, lon, now_local)
if sunrise is None or sunset is None:
# 极昼/极夜:根据本地小时粗判(6-18 视为白天)
return 6 <= now_local.hour < 18
sr_h = sunrise.hour + sunrise.minute / 60.0
ss_h = sunset.hour + sunset.minute / 60.0
now_h = now_utc.hour + now_utc.minute / 60.0
if ss_h < sr_h:
ss_h += 24.0 # 跨 UTC 午夜(东半球)
if now_h < sr_h:
now_h += 24.0
return sr_h <= now_h <= ss_h
def day_fraction_today(lat: float, lon: float, now_local: datetime) -> float:
"""当天白昼占全天比例(0~1),用于自动模式的平滑切换(预留)。"""
sunrise, sunset = sun_times_utc(lat, lon, now_local)
if sunrise is None or sunset is None:
return 0.5
total = (sunset - sunrise).total_seconds()
return max(0.0, min(1.0, total / 86400.0))
+267
View File
@@ -0,0 +1,267 @@
"""Timezone-aware datetime utilities.
Central source for all local-time operations in the project.
Reads the configured timezone from clock_config table (default: Asia/Shanghai).
All backend modules should use these functions instead of bare datetime.now().
Two time domains:
- **Local time** (configurable offset) — for date boundaries, display timestamps,
user-facing schedule logic. Use now(), today(), strftime(), strptime().
- **Unix timestamps** (UTC, always) — for interval math, expiry, cooldowns.
Use ts_now(), ts_from_local(), ts_to_local().
The timezone is cached per-request via Flask app config to avoid DB lookups.
"""
import time as _time
import threading
from datetime import datetime, timedelta, timezone, date
from typing import Union
import pytz
# Thread-local "current user" context (set by the scheduler while it processes
# a specific user's job; request threads resolve the user from the session).
_TLS = threading.local()
def set_thread_user(user_id):
"""Set the current user for this thread (scheduler per-user iteration)."""
_TLS.user_id = user_id
def clear_thread_user():
try:
del _TLS.user_id
except AttributeError:
pass
def get_thread_user():
return getattr(_TLS, "user_id", None)
def _ctx_user_id():
"""Resolve the current user id: request session first, then thread-local."""
try:
from flask import has_request_context, session
if has_request_context():
uid = session.get("user_id")
if uid is not None:
return int(uid)
except Exception:
pass
return getattr(_TLS, "user_id", None) or 0
# ─── Timezone loading ───────────────────────
_DEFAULT_TZ_STR = "Asia/Shanghai"
_DEFAULT_UTC_OFFSET = timezone(timedelta(hours=8))
def _load_tz() -> "pytz.BaseTzInfo":
"""Load timezone from the current user's clock_config. Falls back to Asia/Shanghai."""
try:
from flask import current_app
db_path = current_app.config.get("DB_PATH", "data.db")
from backend.models import get_db
db = get_db(db_path)
row = db.execute(
"SELECT timezone FROM clock_config WHERE is_primary=1 AND user_id=?",
(_ctx_user_id(),)
).fetchone()
db.close()
if row and row["timezone"]:
tz_str = row["timezone"].strip()
return pytz.timezone(tz_str)
except Exception:
pass
return pytz.timezone(_DEFAULT_TZ_STR)
def get_tz() -> "pytz.BaseTzInfo":
"""Return the configured timezone object. Cached per user in app config."""
try:
from flask import current_app
uid = _ctx_user_id()
if hasattr(current_app, "config") and "_timezone_cache" in current_app.config:
cache = current_app.config["_timezone_cache"]
if uid in cache:
return cache[uid]
tz = _load_tz()
if hasattr(current_app, "config"):
cache = current_app.config.setdefault("_timezone_cache", {})
cache[uid] = tz
return tz
except Exception:
return pytz.timezone(_DEFAULT_TZ_STR)
def clear_tz_cache():
"""Force reload timezone on next call. Call after timezone config changes."""
try:
from flask import current_app
current_app.config.pop("_timezone_cache", None)
except Exception:
pass
def set_tz(tz_str: str) -> bool:
"""Set the timezone in clock_config DB and clear cache.
Args:
tz_str: IANA timezone name (e.g. "Asia/Shanghai", "America/New_York")
or UTC offset string (e.g. "UTC+8", "UTC-5")
Returns:
True on success, False on invalid timezone.
"""
# Normalize "UTC+X" / "UTC-X" to pytz-compatible
normalized = tz_str.strip()
if normalized.upper().startswith("UTC") or normalized.upper().startswith("UT"):
try:
cleaned = normalized.upper().replace("UTC", "").replace("UT", "").strip()
sign = 1
if cleaned.startswith("+"):
cleaned = cleaned[1:]
elif cleaned.startswith("-"):
sign = -1
cleaned = cleaned[1:]
hours = int(cleaned)
# Convert to an IANA-like name: pytz has FixedOffset but easiest is
# just use a fixed-offset timezone
import pytz
tz = pytz.FixedOffset(sign * hours * 60) # FixedOffset expects minutes
except (ValueError, IndexError):
return False
else:
# IANA name — validate by trying to load
try:
import pytz
tz = pytz.timezone(normalized)
except Exception:
return False
# Save to DB
try:
from flask import current_app
db_path = current_app.config.get("DB_PATH", "data.db")
from backend.models import get_db
db = get_db(db_path)
uid = _ctx_user_id()
db.execute(
"UPDATE clock_config SET timezone=? WHERE is_primary=1 AND user_id=?",
(normalized, uid)
)
if db.total_changes == 0:
db.execute(
"INSERT INTO clock_config (user_id, city, timezone, is_primary) VALUES (?, '', ?, 1)",
(uid, normalized)
)
db.commit()
db.close()
clear_tz_cache()
return True
except Exception:
return False
def get_tz_str() -> str:
"""Return the configured timezone as a string."""
try:
from flask import current_app
db_path = current_app.config.get("DB_PATH", "data.db")
from backend.models import get_db
db = get_db(db_path)
row = db.execute(
"SELECT timezone FROM clock_config WHERE is_primary=1 AND user_id=?",
(_ctx_user_id(),)
).fetchone()
db.close()
if row and row["timezone"]:
return row["timezone"]
except Exception:
pass
return _DEFAULT_TZ_STR
# ─── Local time functions ───────────────────
# Use these for date boundaries, display timestamps, schedule logic.
def now() -> datetime:
"""Return current datetime in the configured timezone.
This is the primary replacement for bare `datetime.now()`.
"""
return datetime.now(get_tz())
def today() -> date:
"""Return current date in the configured timezone."""
return now().date()
def today_str(fmt: str = "%Y%m%d") -> str:
"""Return today's date formatted as string.
Common format: "%Y%m%d" for DB comparisons, "%Y-%m-%d" for displays.
"""
return now().strftime(fmt)
def strftime(fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
"""Return current datetime formatted as string in local timezone."""
return now().strftime(fmt)
def strptime(date_string: str, fmt: str) -> datetime:
"""Parse a string into a timezone-aware datetime.
The result is in the configured timezone. If the input string
has no timezone info, it's assumed to be in local time.
"""
dt = datetime.strptime(date_string, fmt)
return get_tz().localize(dt)
def isoformat() -> str:
"""Return current time in ISO 8601 format with timezone offset."""
return now().isoformat()
def localize(dt: datetime) -> datetime:
"""Convert a naive datetime to timezone-aware in configured timezone.
If dt is already timezone-aware, it's converted to local timezone.
"""
tz = get_tz()
if dt.tzinfo is None:
return tz.localize(dt)
return dt.astimezone(tz)
# ─── Unix timestamp utilities ───────────────
# These are timezone-independent — always UTC for epoch math.
def ts_now() -> float:
"""Return current Unix timestamp (seconds since epoch, UTC).
Equivalent to time.time(). Use for expiry, cooldowns, interval math.
"""
return _time.time()
def ts_from_local(dt: datetime = None) -> float:
"""Convert a local-timezone datetime to Unix timestamp.
If dt is None, returns current timestamp (same as ts_now()).
"""
if dt is None:
return _time.time()
if dt.tzinfo is None:
dt = localize(dt)
return dt.timestamp()
def ts_to_local(ts: float) -> datetime:
"""Convert a Unix timestamp to local timezone datetime."""
return datetime.fromtimestamp(ts, get_tz())
+86
View File
@@ -0,0 +1,86 @@
"""URL safety helpers — prevent SSRF from user-supplied fetch URLs
(calendar subscriptions, weather API hosts)."""
import ipaddress
import socket
from urllib.parse import urlparse
ALLOWED_SCHEMES = ("http", "https")
# Hosts that are always fine to reach (QWeather SaaS / common cloud APIs).
# Everything else is resolved and must be a public address.
_ALWAYS_ALLOWED = {
"devapi.qweather.com",
"geoapi.qweather.com",
"qweather.com",
}
def _host_is_private(hostname: str) -> bool:
"""Resolve a hostname and return True if ANY address is non-public."""
try:
infos = socket.getaddrinfo(hostname, None)
except Exception:
# Unresolvable — treat as unsafe (fail closed for fetches).
return True
for info in infos:
ip = ipaddress.ip_address(info[4][0])
if (ip.is_private or ip.is_loopback or ip.is_link_local
or ip.is_multicast or ip.is_reserved or ip.is_unspecified):
return True
return False
def is_safe_public_url(url: str) -> bool:
"""True if the URL is http(s) and resolves to public addresses only."""
if not url or not isinstance(url, str):
return False
try:
p = urlparse(url.strip())
except Exception:
return False
if p.scheme not in ALLOWED_SCHEMES or not p.hostname:
return False
host = p.hostname.lower().rstrip(".")
if host in _ALWAYS_ALLOWED or host.endswith(".qweather.com"):
return True
if p.port and p.port not in (80, 443):
return False
return not _host_is_private(host)
def is_safe_https_host(host: str) -> bool:
"""True if the value is a bare https hostname (no scheme/path) and public."""
if not host or not isinstance(host, str):
return False
host = host.strip().lower().rstrip(".")
if not host or "://" in host or "/" in host or " " in host:
return False
if host in _ALWAYS_ALLOWED or host.endswith(".qweather.com"):
return True
return not _host_is_private(host)
def safe_http_get(url: str, timeout: float = 15, max_redirects: int = 3):
"""GET a URL, re-validating safety on every redirect hop.
Returns the final `requests.Response` (status 200) or None if any hop is
unsafe/unresolvable or the redirect chain exceeds max_redirects.
Raises requests.HTTPError for non-2xx final responses.
"""
import requests
from urllib.parse import urljoin
current = (url or "").strip()
for _ in range(max_redirects + 1):
if not is_safe_public_url(current):
return None
resp = requests.get(current, timeout=timeout, allow_redirects=False)
if resp.status_code in (301, 302, 303, 307, 308):
location = resp.headers.get("Location", "")
if not location:
return None
current = urljoin(current, location)
continue
resp.raise_for_status()
return resp
return None
+680
View File
@@ -0,0 +1,680 @@
"""QWeather client: JWT auth, real-time weather, city search, intelligent caching."""
import json
import requests
from datetime import datetime
from backend.time_util import now as tz_now, strftime as tz_strftime, today_str as tz_today_str, strptime as tz_strptime, localize as tz_localize
from flask import current_app
from backend.models import get_db
from backend.jwt_util import sign_jwt, generate_key_pair
# QWeather API host (shared dev host, or user's dedicated host)
QWEATHER_API = "https://devapi.qweather.com"
QWEATHER_GEO_API = "https://geoapi.qweather.com"
# Cache TTL in seconds (default 30 min)
CACHE_TTL = 1800
# ─── Config ────────────────────────────────
def _uid() -> int:
"""Resolve the current user (request session or scheduler thread)."""
try:
from backend.auth import current_user_id
return current_user_id() or 0
except Exception:
return 0
def get_config(user_id: int = None) -> dict:
if user_id is None:
user_id = _uid()
db = get_db(current_app.config["DB_PATH"])
row = db.execute("SELECT * FROM weather_config WHERE user_id=?", (user_id,)).fetchone()
db.close()
return dict(row) if row else {"user_id": user_id}
def update_config(api_key: str = None,
city: str = None, lat: float = None, lon: float = None,
units: str = None, update_interval: int = None,
credential_id: str = None, project_id: str = None,
qweather_host: str = None, user_id: int = None):
if user_id is None:
user_id = _uid()
if qweather_host is not None and qweather_host.strip():
from backend.url_util import is_safe_https_host
if not is_safe_https_host(qweather_host):
raise ValueError("API Host 无效或不安全:仅允许公网域名(如 abc123.def.qweatherapi.com)")
db = get_db(current_app.config["DB_PATH"])
fields = []
vals = []
for key, val in [
("api_key", api_key),
("city", city), ("lat", lat), ("lon", lon),
("units", units), ("update_interval", update_interval),
("credential_id", credential_id), ("project_id", project_id),
("qweather_host", qweather_host),
]:
if val is not None:
fields.append(f"{key}=?")
vals.append(val)
row = db.execute("SELECT id FROM weather_config WHERE user_id=?", (user_id,)).fetchone()
if row is None:
db.execute("INSERT INTO weather_config (user_id) VALUES (?)", (user_id,))
if fields:
vals.append(user_id)
db.execute(f"UPDATE weather_config SET {', '.join(fields)} WHERE user_id=?", vals)
db.commit()
db.close()
def generate_weather_keys() -> dict:
"""Generate new Ed25519 key pair for QWeather JWT auth.
Returns:
dict with public_key (private key stored in DB)
"""
priv, pub = generate_key_pair()
user_id = _uid()
db = get_db(current_app.config["DB_PATH"])
row = db.execute("SELECT id FROM weather_config WHERE user_id=?", (user_id,)).fetchone()
if row is None:
db.execute("INSERT INTO weather_config (user_id) VALUES (?)", (user_id,))
db.execute(
"UPDATE weather_config SET jwt_private_key=?, jwt_public_key=? WHERE user_id=?",
(priv, pub, user_id)
)
db.commit()
db.close()
return {"public_key": pub}
# ─── JWT Token ─────────────────────────────
def _get_auth_header(cfg: dict) -> dict:
"""Build the Authorization header for QWeather API.
仅和风天气。优先 JWT(credential_id + project_id + jwt_private_key),
否则回退到和风旧版 API Key(X-QW-Api-Key)。
"""
priv_key = cfg.get("jwt_private_key", "")
kid = cfg.get("credential_id", "")
sub = cfg.get("project_id", "")
if priv_key and kid and sub:
token = sign_jwt(priv_key, kid, sub)
return {"Authorization": f"Bearer {token}"}
api_key = cfg.get("api_key", "")
if api_key:
return {"X-QW-Api-Key": api_key}
return {}
# ─── API Host ──────────────────────────────
def _api_host(cfg: dict) -> str:
host = cfg.get("qweather_host", "").strip()
from backend.url_util import is_safe_https_host
if host and is_safe_https_host(host):
return f"https://{host}"
return QWEATHER_API
def _geo_host(cfg: dict) -> str:
# If user has a dedicated host, GeoAPI is under the same host
host = cfg.get("qweather_host", "").strip()
from backend.url_util import is_safe_https_host
if host and is_safe_https_host(host):
return f"https://{host}"
return QWEATHER_GEO_API
# ─── City Lookup ───────────────────────────
def city_lookup(query: str, number: int = 10) -> list:
"""Search cities via QWeather GeoAPI.
Returns list of {name, id, lat, lon, adm1, adm2, country}
"""
cfg = get_config()
headers = _get_auth_header(cfg)
if not headers:
return []
host = _geo_host(cfg)
url = f"{host}/v2/city/lookup"
params = {
"location": query,
"number": min(number, 20),
"lang": "zh",
}
try:
resp = requests.get(url, params=params, headers=headers, timeout=8)
if resp.status_code != 200:
current_app.logger.error(f"City lookup failed: {resp.status_code} {resp.text}")
return []
data = resp.json()
if data.get("code") != "200":
return []
results = []
for loc in data.get("location", []):
results.append({
"name": loc.get("name", ""),
"id": loc.get("id", ""),
"lat": loc.get("lat", ""),
"lon": loc.get("lon", ""),
"adm1": loc.get("adm1", ""),
"adm2": loc.get("adm2", ""),
"country": loc.get("country", ""),
})
return results
except Exception as e:
current_app.logger.error(f"City lookup error: {e}")
return []
# ─── Weather Fetch ─────────────────────────
def fetch_weather(force: bool = False, user_id: int = None) -> dict:
"""Fetch real-time weather from QWeather and cache it.
Args:
force: If True, skip fallback cache on error — return error directly.
"""
if user_id is None:
user_id = _uid()
return _fetch_qweather(get_config(user_id), force=force, user_id=user_id)
def _fetch_qweather(cfg: dict, force: bool = False, user_id: int = None) -> dict:
"""Fetch weather from QWeather v7/weather/now."""
headers = _get_auth_header(cfg)
if not headers:
return {"error": "未配置 JWT 密钥或 API Key", "city": cfg.get("city", "")}
lat = cfg.get("lat") or 39.9042
lon = cfg.get("lon") or 116.4074
host = _api_host(cfg)
url = f"{host}/v7/weather/now"
params = {
"location": f"{lon:.2f},{lat:.2f}",
"lang": "zh",
}
try:
resp = requests.get(url, params=params, headers=headers, timeout=10)
if resp.status_code == 401:
current_app.logger.error("QWeather: JWT auth failed (401)")
return {"error": "JWT 认证失败,请检查密钥配置", "city": cfg.get("city", "")}
if resp.status_code == 403:
current_app.logger.error(f"QWeather: forbidden (403) body={resp.text[:200]}")
# Detect Invalid Host (shared dev domain deprecated)
if "invalid host" in resp.text.lower() or "unauthorized api host" in resp.text.lower():
return {"error": "API Host 无效:共享开发域名已弃用,请在控制端配置专属 API Host(和风控制台 → 产品 → API Host)", "city": cfg.get("city", "")}
return {"error": "请求被拒绝(403),请检查额度、权限或 API Host 配置", "city": cfg.get("city", "")}
if resp.status_code == 429:
msg = "请求太频繁,请稍后再试"
current_app.logger.warning(f"QWeather: rate limited (429)")
return {"error": msg, "city": cfg.get("city", "")} if force else _fallback_cache(cfg, user_id)
resp.raise_for_status()
data = resp.json()
if data.get("code") != "200":
err_msg = data.get("code", "unknown")
current_app.logger.error(f"QWeather API error code: {err_msg}")
return {"error": f"API 错误: {err_msg}", "city": cfg.get("city", "")}
except requests.exceptions.Timeout:
msg = "API 请求超时"
current_app.logger.error(f"QWeather: timeout")
return {"error": msg, "city": cfg.get("city", "")} if force else _fallback_cache(cfg, user_id)
except requests.exceptions.ConnectionError:
msg = "无法连接 API"
current_app.logger.error(f"QWeather: connection failed")
return {"error": msg, "city": cfg.get("city", "")} if force else _fallback_cache(cfg, user_id)
except Exception as e:
msg = str(e)
current_app.logger.error(f"QWeather error: {e}")
return {"error": msg, "city": cfg.get("city", "")} if force else _fallback_cache(cfg, user_id)
now_data = data.get("now", {})
# Map QWeather icon codes to something displayable
icon_code = now_data.get("icon", "999")
result = {
"city": cfg.get("city", ""),
"temp": float(now_data.get("temp", 0)),
"feels_like": float(now_data.get("feelsLike", 0)),
"humidity": int(now_data.get("humidity", 0)),
"pressure": int(now_data.get("pressure", 0)),
"description": now_data.get("text", ""),
"icon": icon_code,
"wind_speed": float(now_data.get("windSpeed", 0)),
"wind_dir": now_data.get("windDir", ""),
"wind_scale": int(now_data.get("windScale", 0)),
"visibility": now_data.get("vis", ""),
"precip": now_data.get("precip", "0"),
"cloud": now_data.get("cloud", ""),
"dew": now_data.get("dew", ""),
"obs_time": now_data.get("obsTime", ""),
"updated_at": tz_now().isoformat(),
"provider": "qweather",
}
_save_cache(result, user_id)
current_app.event_bus.publish("weather_updated", result, user_id=user_id)
return result
# ─── Cache ─────────────────────────────────
def _save_cache(data: dict, user_id: int = None):
"""Write weather data to the cache table, replacing any existing row."""
if user_id is None:
user_id = _uid()
now_str = tz_strftime()
db = get_db(current_app.config["DB_PATH"])
db.execute("DELETE FROM weather_cache WHERE user_id=?", (user_id,))
db.execute(
"INSERT INTO weather_cache (user_id, data, updated_at) VALUES (?, ?, ?)",
(user_id, json.dumps(data), now_str)
)
db.commit()
db.close()
def _fallback_cache(cfg: dict, user_id: int = None) -> dict:
"""Return cached data if available, else error."""
if user_id is None:
user_id = _uid()
db = get_db(current_app.config["DB_PATH"])
cache = db.execute(
"SELECT data FROM weather_cache WHERE user_id=?", (user_id,)
).fetchone()
db.close()
if cache:
cached = json.loads(cache["data"])
cached["cached"] = True
return cached
return {"error": "无法获取天气数据", "city": cfg.get("city", "")}
def get_cached_weather(user_id: int = None) -> dict:
"""Return cached weather or empty error."""
if user_id is None:
user_id = _uid()
db = get_db(current_app.config["DB_PATH"])
row = db.execute("SELECT * FROM weather_cache WHERE user_id=?", (user_id,)).fetchone()
db.close()
if row:
return json.loads(row["data"])
return {"error": "暂无天气数据"}
# ─── Next Update Schedule ─────────────────
def get_next_update_times(user_id: int = None) -> dict:
"""Compute next scheduled update times based on cron rules.
- Weather (current): every :00 and :30
- Hourly forecast: every :01 past the hour
"""
from datetime import timedelta
if user_id is None:
user_id = _uid()
now = tz_now()
# Next weather update: next :00 or :30
if now.minute < 30:
next_w = now.replace(minute=30, second=0, microsecond=0)
else:
next_w = (now + timedelta(hours=1)).replace(minute=0, second=0, microsecond=0)
# Next hourly update: next :01
if now.minute < 1:
next_h = now.replace(minute=1, second=0, microsecond=0)
else:
next_h = (now + timedelta(hours=1)).replace(minute=1, second=0, microsecond=0)
# Read last update times from cached JSON (local time) not SQLite datetime('now') which is UTC
db = get_db(current_app.config["DB_PATH"])
w_updated = None
w_row = db.execute("SELECT data FROM weather_cache WHERE user_id=?", (user_id,)).fetchone()
if w_row and w_row["data"]:
w_data = json.loads(w_row["data"])
w_updated = w_data.get("updated_at", None)
h_updated = None
h_row = db.execute("SELECT data FROM weather_hourly_cache WHERE user_id=?", (user_id,)).fetchone()
if h_row and h_row["data"]:
h_data = json.loads(h_row["data"])
# Hourly data is a list; we store fetched_at separately, but for display
# use the SQLite fetched_at (converted) or just show the schedule
# Let's use the local-computed schedule as last-update signal
h_row2 = db.execute("SELECT fetched_at FROM weather_hourly_cache WHERE user_id=?", (user_id,)).fetchone()
if h_row2 and h_row2[0]:
# Keep the UTC string but note it's UTC — we'll just pass it
h_updated = h_row2[0]
db.close()
return {
"weather_updated_at": w_updated,
"weather_next_update": next_w.strftime("%Y-%m-%d %H:%M:%S"),
"hourly_updated_at": h_updated,
"hourly_next_update": next_h.strftime("%Y-%m-%d %H:%M:%S"),
}
# ─── Hourly Forecast ──────────────────────
def fetch_hourly(user_id: int = None) -> list:
"""Fetch 24-hour forecast from QWeather, filter past hours, cache.
Returns filtered list of {time, temp, icon, text, pop, windSpeed, windDir}.
Handles cross-hour/cross-day: filters out fxTime < now, so stale cache
never shows expired hours. If filtered result is short, caller should
re-fetch (handled by scheduler every 60 min).
"""
if user_id is None:
user_id = _uid()
cfg = get_config(user_id)
headers = _get_auth_header(cfg)
if not headers:
return []
lat = cfg.get("lat") or 39.9042
lon = cfg.get("lon") or 116.4074
host = _api_host(cfg)
url = f"{host}/v7/weather/24h"
params = {"location": f"{lon:.2f},{lat:.2f}", "lang": "zh"}
try:
resp = requests.get(url, params=params, headers=headers, timeout=10)
if resp.status_code != 200:
current_app.logger.error(f"Hourly fetch failed: {resp.status_code}")
return _get_cached_hourly(user_id) or []
data = resp.json()
if data.get("code") != "200":
return _get_cached_hourly(user_id) or []
raw_list = data.get("hourly", [])
except Exception as e:
current_app.logger.error(f"Hourly fetch error: {e}")
return _get_cached_hourly(user_id) or []
now = tz_now()
today_date = now.strftime("%Y-%m-%d")
filtered = []
for h in raw_list:
fx_str = h.get("fxTime", "")
fx_time = _parse_qw_time(fx_str)
if not fx_time:
continue
# Unify to a local timezone-aware datetime via the time middleware.
# fxTime from QWeather may carry an offset (+08:00) or be naive;
# never strip tzinfo — that breaks comparison with tz_now().
fx_local = tz_localize(fx_time)
if fx_local >= now:
# Determine if this hour is today or tomorrow
fx_date = fx_local.strftime("%Y-%m-%d")
day_label = "明日" if fx_date > today_date else "今日"
filtered.append({
"time": fx_local.strftime("%H:%M"),
"day": day_label,
"temp": h.get("temp", ""),
"icon": h.get("icon", "999"),
"text": h.get("text", ""),
"pop": h.get("pop", "0"),
"wind_speed": h.get("windSpeed", "0"),
"wind_dir": h.get("windDir", ""),
})
_save_hourly_cache(filtered, user_id)
current_app.logger.info(f"Hourly: fetched {len(raw_list)} → {len(filtered)} future hours")
return filtered
def _parse_qw_time(s: str):
"""Parse QWeather ISO time string to datetime, or None."""
if not s:
return None
try:
# Format: "2026-07-14T15:00+08:00" or "2026-07-14T15:00+08:00"
return datetime.fromisoformat(s)
except Exception:
pass
try:
# Fallback: "2026-07-14T15:00"
return datetime.strptime(s[:16], "%Y-%m-%dT%H:%M")
except Exception:
return None
def get_cached_hourly(user_id: int = None) -> list:
"""Return cached hourly forecast with cross-time filtering.
Filters out past hours using proper time comparison (handles
cross-midnight correctly). If fewer than 12 remain, logs a warning
so the scheduler knows to refresh early.
"""
if user_id is None:
user_id = _uid()
data = _get_cached_hourly(user_id)
if not data:
return []
# Detect old cache format (missing "day" field) — force re-fetch
if not data[0].get("day"):
current_app.logger.info("Hourly: old cache format detected, force re-fetch")
return []
now = tz_now()
now_minutes = now.hour * 60 + now.minute
valid = []
for h in data:
parts = h.get("time", "00:00").split(":")
h_minutes = int(parts[0]) * 60 + int(parts[1])
day = h.get("day", "今日")
# Keep if: it's "今日" and time hasn't passed, OR it's "明日" (always future)
if (day == "今日" and h_minutes >= now_minutes) or day == "明日":
valid.append(h)
if len(valid) < 12:
current_app.logger.info(
f"Hourly cache: only {len(valid)} future hours left, will refresh soon"
)
return valid
def _save_hourly_cache(data: list, user_id: int = None):
"""Persist hourly data, replacing old cache."""
if user_id is None:
user_id = _uid()
now_str = tz_strftime()
db = get_db(current_app.config["DB_PATH"])
db.execute(
"DELETE FROM weather_hourly_cache WHERE user_id=?", (user_id,)
)
db.execute(
"INSERT INTO weather_hourly_cache (user_id, data, fetched_at) VALUES (?, ?, ?)",
(user_id, json.dumps(data), now_str)
)
db.commit()
db.close()
def _get_cached_hourly(user_id: int = None) -> list | None:
"""Read cached hourly data from DB."""
if user_id is None:
user_id = _uid()
db = get_db(current_app.config["DB_PATH"])
row = db.execute(
"SELECT data FROM weather_hourly_cache WHERE user_id=?", (user_id,)
).fetchone()
db.close()
if row and row["data"]:
return json.loads(row["data"])
return None
# ─── Minutely Forecast (manual, rate-limited) ─
_MINUTELY_COOLDOWN = 180 # 3 minutes in seconds
def fetch_minutely(user_id: int = None) -> dict:
"""Fetch minute-level precipitation forecast from QWeather v7/minutely/5m.
Rate-limited: once per 3 minutes. Returns cooldown info on rejection.
"""
if user_id is None:
user_id = _uid()
# Check cooldown
db = get_db(current_app.config["DB_PATH"])
row = db.execute(
"SELECT fetched_at FROM weather_minutely_cache WHERE user_id=?", (user_id,)
).fetchone()
if row and row["fetched_at"]:
try:
last = tz_strptime(row["fetched_at"], "%Y-%m-%d %H:%M:%S") if 'T' not in row["fetched_at"] else tz_strptime(row["fetched_at"][:19].replace('T', ' '), "%Y-%m-%d %H:%M:%S")
elapsed = (tz_now() - last).total_seconds()
if elapsed < _MINUTELY_COOLDOWN:
remaining = int(_MINUTELY_COOLDOWN - elapsed)
db.close()
return {"error": f"冷却中,剩余 {remaining} 秒", "cooldown": remaining}
except Exception:
pass
db.close()
cfg = get_config(user_id)
headers = _get_auth_header(cfg)
if not headers:
return {"error": "未配置认证"}
lat = cfg.get("lat") or 39.9042
lon = cfg.get("lon") or 116.4074
host = _api_host(cfg)
url = f"{host}/v7/minutely/5m"
params = {"location": f"{lon:.2f},{lat:.2f}", "lang": "zh"}
try:
resp = requests.get(url, params=params, headers=headers, timeout=10)
if resp.status_code != 200:
return {"error": f"API 返回 {resp.status_code}"}
data = resp.json()
if data.get("code") != "200":
return {"error": f"API 错误: {data.get('code')}"}
except Exception as e:
return {"error": str(e)}
now_str = tz_strftime()
result = {
"summary": data.get("summary", ""),
"minutely": data.get("minutely", []),
"fetched_at": now_str,
}
db = get_db(current_app.config["DB_PATH"])
db.execute("DELETE FROM weather_minutely_cache WHERE user_id=?", (user_id,))
db.execute(
"INSERT INTO weather_minutely_cache (user_id, data, fetched_at) VALUES (?, ?, ?)",
(user_id, json.dumps(result), now_str)
)
db.commit()
db.close()
return result
def get_cached_minutely(user_id: int = None) -> dict | None:
"""Return cached minutely forecast if available."""
if user_id is None:
user_id = _uid()
db = get_db(current_app.config["DB_PATH"])
row = db.execute("SELECT data FROM weather_minutely_cache WHERE user_id=?", (user_id,)).fetchone()
db.close()
if row and row["data"]:
return json.loads(row["data"])
return None
# ─── Weather Warnings (manual, rate-limited) ─
def fetch_warnings(user_id: int = None) -> dict:
"""Fetch weather warnings from QWeather v7/warning/now.
Rate-limited: once per 3 minutes. Returns cooldown info on rejection.
"""
if user_id is None:
user_id = _uid()
# Check cooldown
db = get_db(current_app.config["DB_PATH"])
row = db.execute(
"SELECT fetched_at FROM weather_warning_cache WHERE user_id=?", (user_id,)
).fetchone()
if row and row["fetched_at"]:
try:
last = tz_strptime(row["fetched_at"], "%Y-%m-%d %H:%M:%S") if 'T' not in row["fetched_at"] else tz_strptime(row["fetched_at"][:19].replace('T', ' '), "%Y-%m-%d %H:%M:%S")
elapsed = (tz_now() - last).total_seconds()
if elapsed < _MINUTELY_COOLDOWN:
remaining = int(_MINUTELY_COOLDOWN - elapsed)
db.close()
return {"error": f"冷却中,剩余 {remaining} 秒", "cooldown": remaining}
except Exception:
pass
db.close()
cfg = get_config(user_id)
headers = _get_auth_header(cfg)
if not headers:
return {"error": "未配置认证"}
lat = round(cfg.get("lat") or 39.9042, 2)
lon = round(cfg.get("lon") or 116.4074, 2)
host = _api_host(cfg)
url = f"{host}/weatheralert/v1/current/{lat}/{lon}"
params = {"lang": "zh"}
try:
resp = requests.get(url, params=params, headers=headers, timeout=10)
if resp.status_code == 403:
return {"error": "天气预警为付费功能,当前订阅不可用", "unavailable": True}
if resp.status_code != 200:
return {"error": f"API 返回 {resp.status_code}"}
data = resp.json()
alert_list = data.get("alerts", [])
except Exception as e:
return {"error": str(e)}
now_str = tz_strftime()
result = {
"warnings": alert_list,
"fetched_at": now_str,
"zero_result": data.get("metadata", {}).get("zeroResult", False),
}
db = get_db(current_app.config["DB_PATH"])
db.execute("DELETE FROM weather_warning_cache WHERE user_id=?", (user_id,))
db.execute(
"INSERT INTO weather_warning_cache (user_id, data, fetched_at) VALUES (?, ?, ?)",
(user_id, json.dumps(result), now_str)
)
db.commit()
db.close()
return result
def get_cached_warnings(user_id: int = None) -> dict | None:
"""Return cached warnings if available."""
if user_id is None:
user_id = _uid()
db = get_db(current_app.config["DB_PATH"])
row = db.execute("SELECT data FROM weather_warning_cache WHERE user_id=?", (user_id,)).fetchone()
db.close()
if row and row["data"]:
return json.loads(row["data"])
return None
+255
View File
@@ -0,0 +1,255 @@
"""WebAuthn (Passkey) manager — registration and authentication ceremonies.
Multi-user support:
- Registration is bound to a concrete user id (challenge keyed by user id,
user handle = "user_<id>").
- Login ceremonies are credential-driven: after `complete_authentication`,
the caller maps the returned credential id to its owner via
`models.find_user_by_credential_id`.
- `begin_authentication` can be restricted to a single credential for
re-authentication ceremonies.
Uses the `webauthn` library (v2.x/v3.x).
Requires HTTPS (or localhost) to function.
"""
from dataclasses import asdict
from webauthn.helpers import bytes_to_base64url
from webauthn import (
generate_registration_options,
verify_registration_response,
generate_authentication_options,
verify_authentication_response,
)
from webauthn.helpers.structs import (
AuthenticatorSelectionCriteria,
ResidentKeyRequirement,
UserVerificationRequirement,
PublicKeyCredentialDescriptor,
)
from webauthn.helpers.exceptions import InvalidRegistrationResponse, InvalidAuthenticationResponse
# snake_case → camelCase mapping for browser WebAuthn API
_SNAKE_TO_CAMEL = {
"pub_key_cred_params": "pubKeyCredParams",
"exclude_credentials": "excludeCredentials",
"authenticator_selection": "authenticatorSelection",
"allow_credentials": "allowCredentials",
"rp_id": "rpId",
"user_verification": "userVerification",
"resident_key": "residentKey",
"authenticator_attachment": "authenticatorAttachment",
"require_resident_key": "requireResidentKey",
"user_handle": "userHandle",
"credential_id": "credentialId",
"credential_public_key": "credentialPublicKey",
"sign_count": "signCount",
"cred_props": "credProps",
"public_key": "publicKey",
"private_key": "privateKey",
"display_name": "displayName",
}
def _snake_to_camel(name: str) -> str:
"""Convert snake_case key to camelCase, with known overrides."""
if name in _SNAKE_TO_CAMEL:
return _SNAKE_TO_CAMEL[name]
parts = name.split("_")
return parts[0] + "".join(p.capitalize() for p in parts[1:])
# Relying Party info
RP_NAME = "Cal Display"
RP_ID = None # set dynamically from request.host
ORIGIN = None # set dynamically
# In-memory challenge stores
_CHALLENGES: dict = {} # "login" → challenge
_REGISTRATION_CHALLENGES: dict = {} # "reg_<user_id>" → challenge
def _get_rp_id(request) -> str:
"""Extract RP ID from the request host (e.g. 'example.com' or '192.168.1.100')."""
host = request.host.split(":")[0] # strip port
return host
def _get_origin(request) -> str:
"""Extract origin from the request (scheme + host)."""
scheme = request.scheme
return f"{scheme}://{request.host}"
def _serialize_options(options) -> dict:
"""Serialize a webauthn options dataclass to a browser-ready dict.
Converts bytes to base64url strings AND snake_case keys to camelCase.
"""
d = asdict(options)
return _bytes_to_json(d)
def _bytes_to_json(obj):
"""Recursively convert bytes→base64url and snake_case→camelCase."""
if isinstance(obj, bytes):
return bytes_to_base64url(obj)
elif isinstance(obj, dict):
return {_snake_to_camel(k): _bytes_to_json(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [_bytes_to_json(v) for v in obj]
elif isinstance(obj, tuple):
return tuple(_bytes_to_json(v) for v in obj)
return obj
# ─── Registration ────────────────────────────
def begin_registration(request, user_id: int, display_name: str = "",
existing_credential_id: str = None) -> dict:
"""Start WebAuthn registration for a specific user. Returns options dict."""
rp_id = _get_rp_id(request)
origin = _get_origin(request)
user_name = f"user_{user_id}"
user_handle = user_name.encode("utf-8")
# If the user already has a passkey, exclude it so they can't re-register same credential
exclude_credentials = []
if existing_credential_id:
exclude_credentials.append(
PublicKeyCredentialDescriptor(id=bytes.fromhex(existing_credential_id))
)
options = generate_registration_options(
rp_id=rp_id,
rp_name=RP_NAME,
user_id=user_handle,
user_name=user_name,
user_display_name=display_name or user_name,
attestation="none",
authenticator_selection=AuthenticatorSelectionCriteria(
resident_key=ResidentKeyRequirement.PREFERRED,
user_verification=UserVerificationRequirement.PREFERRED,
),
exclude_credentials=exclude_credentials,
)
# Store challenge for verification
_REGISTRATION_CHALLENGES[f"reg_{user_id}"] = options.challenge
return _serialize_options(options)
def complete_registration(request, credential: dict) -> dict:
"""Complete WebAuthn registration.
Returns {'ok': True, 'user_id': int, 'credential_id': str, 'public_key': str}
or raises an exception on failure.
"""
rp_id = _get_rp_id(request)
origin = _get_origin(request)
user_name = credential.get("user_name", "")
# Derive the target user id from the registration challenge key.
challenge_key = f"reg_{user_name}"
expected_challenge = _REGISTRATION_CHALLENGES.pop(challenge_key, None)
if not expected_challenge:
# Self-registration fallback: the browser may not echo the user handle,
# so resolve the target from the session user.
try:
from flask import session
uid = session.get("user_id")
if uid is not None:
challenge_key = f"reg_{uid}"
expected_challenge = _REGISTRATION_CHALLENGES.pop(challenge_key, None)
user_name = f"user_{uid}"
except Exception:
pass
if not expected_challenge:
raise ValueError("No registration challenge found. Please restart registration.")
user_id = int(user_name.split("_")[-1])
try:
# Library expects rawId field (browser-native camelCase, base64url string)
if "rawId" not in credential:
credential["rawId"] = credential.get("id", "")
verification = verify_registration_response(
credential=credential,
expected_challenge=expected_challenge,
expected_rp_id=rp_id,
expected_origin=origin,
)
except (InvalidRegistrationResponse, Exception) as e:
raise ValueError(f"Registration verification failed: {e}")
return {
"ok": True,
"user_id": user_id,
"credential_id": verification.credential_id.hex(),
"public_key": verification.credential_public_key.hex(),
}
# ─── Authentication (Login / Re-auth) ────────
def begin_authentication(request, allow_credential_ids: list = None) -> dict:
"""Start WebAuthn authentication. Returns options dict for the frontend.
allow_credential_ids: optional list of hex credential ids to restrict the
ceremony to (used for re-authentication of the current user).
"""
rp_id = _get_rp_id(request)
origin = _get_origin(request)
allow_credentials = []
if allow_credential_ids:
allow_credentials = [
PublicKeyCredentialDescriptor(id=bytes.fromhex(cid))
for cid in allow_credential_ids
]
options = generate_authentication_options(
rp_id=rp_id,
user_verification=UserVerificationRequirement.PREFERRED,
allow_credentials=allow_credentials,
)
# Store challenge for verification
_CHALLENGES["login"] = options.challenge
return _serialize_options(options)
def complete_authentication(request, credential: dict, stored_credential_id: str, stored_public_key: str) -> dict:
"""Complete WebAuthn authentication.
Returns {'ok': True, 'credential_id': str} or raises on failure.
"""
rp_id = _get_rp_id(request)
origin = _get_origin(request)
expected_challenge = _CHALLENGES.pop("login", None)
if not expected_challenge:
raise ValueError("No authentication challenge found. Please start again.")
try:
# Library expects rawId field (browser-native camelCase, base64url string)
if "rawId" not in credential:
credential["rawId"] = credential.get("id", "")
verification = verify_authentication_response(
credential=credential,
expected_challenge=expected_challenge,
expected_rp_id=rp_id,
expected_origin=origin,
credential_public_key=bytes.fromhex(stored_public_key),
credential_current_sign_count=0, # we don't track sign count
require_user_verification=False,
)
except (InvalidAuthenticationResponse, Exception) as e:
raise ValueError(f"Authentication verification failed: {e}")
return {
"ok": True,
"credential_id": verification.credential_id.hex(),
}
+20
View File
@@ -0,0 +1,20 @@
services:
app:
build: .
container_name: cal-display
restart: unless-stopped
ports:
- "5000:5000"
volumes:
# 持久化数据库
- ./data:/app/data
# 持久化项目代码(首次自动初始化为镜像内的克隆内容,之后 git pull 生效)
- app_code:/app
environment:
- FLASK_BIND=0.0.0.0
- FLASK_DB_PATH=/app/data/data.db
- FLASK_SECRET_KEY=${FLASK_SECRET_KEY:-}
- FLASK_HTTPS=${FLASK_HTTPS:-}
volumes:
app_code:
+350
View File
@@ -0,0 +1,350 @@
/* Control page styles - desktop layout */
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #0f1117;
--card-bg: #1a1d2e;
--text: #e0e0e0;
--text-dim: #888;
--accent: #4A90D9;
--border: rgba(255,255,255,0.10);
--radius: 8px;
--btn-secondary: rgba(255,255,255,0.08);
--warn-bg: rgba(255,200,87,0.12);
--badge-on-bg: rgba(76,175,80,0.18);
--badge-on-fg: #7bc47f;
--badge-off-bg: rgba(224,85,85,0.18);
--badge-off-fg: #e28a8a;
}
/* 浅色主题:白偏蓝背景、黑色字体与图标 */
.theme-light {
--bg: #e8edf4;
--card-bg: #ffffff;
--text: #1c2333;
--text-dim: #5b6a7e;
--accent: #2f6fb2;
--border: #d7dfe8;
--radius: 8px;
--btn-secondary: #e4e9f0;
--warn-bg: #fffbea;
--badge-on-bg: #e8f5e9;
--badge-on-fg: #2e7d32;
--badge-off-bg: #ffebee;
--badge-off-fg: #c62828;
}
/* 主题切换动画:切换瞬间由 theme.js 临时加 .theme-anim */
html.theme-anim,
html.theme-anim * {
transition: background-color 0.55s ease, color 0.55s ease,
border-color 0.55s ease, box-shadow 0.55s ease !important;
}
html, body {
font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
#app {
max-width: 960px;
margin: 0 auto;
padding: 20px;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 12px;
}
header h1 {
font-size: 22px;
font-weight: 600;
}
#tabs {
display: flex;
gap: 4px;
}
.tab {
padding: 8px 20px;
border: none;
background: var(--card-bg);
border-radius: var(--radius);
cursor: pointer;
font-size: 14px;
color: var(--text-dim);
border: 1px solid var(--border);
}
.tab.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.section {
background: var(--card-bg);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 16px;
border: 1px solid var(--border);
}
.section h3 {
font-size: 16px;
margin-bottom: 14px;
color: var(--text);
font-weight: 500;
}
/* Forms */
.form-row {
margin-bottom: 12px;
}
.form-row label {
display: block;
font-size: 13px;
color: var(--text-dim);
margin-bottom: 4px;
}
.inline-form {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
input, select, button {
font-family: inherit;
font-size: 14px;
}
input[type="text"],
input[type="url"],
input[type="number"],
input[type="time"],
select {
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--card-bg);
color: var(--text);
min-width: 120px;
}
input[type="color"] {
width: 36px;
height: 36px;
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
padding: 2px;
}
button {
padding: 8px 16px;
background: var(--accent);
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
}
button:hover {
opacity: 0.9;
}
button.btn-secondary {
background: var(--btn-secondary);
color: var(--text);
}
button.btn-danger {
background: #E05555;
}
/* Lists */
.group-item, .alarm-item, .clock-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 8px;
}
.group-item .info, .alarm-item .info, .clock-item .info {
display: flex;
align-items: center;
gap: 10px;
}
.group-color-dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
.group-name {
font-weight: 500;
}
.group-sources {
font-size: 12px;
color: var(--text-dim);
}
.group-actions {
display: flex;
gap: 6px;
}
.group-actions button {
font-size: 12px;
padding: 4px 10px;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
}
.badge-on { background: var(--badge-on-bg); color: var(--badge-on-fg); }
.badge-off { background: var(--badge-off-bg); color: var(--badge-off-fg); }
.small-btn {
font-size: 12px;
padding: 4px 10px;
background: var(--btn-secondary);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
}
/* Day picker */
.day-picker {
display: flex;
gap: 4px;
}
.day-picker label {
display: flex;
align-items: center;
gap: 2px;
font-size: 13px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
border: 1px solid var(--border);
}
.day-picker label:has(input:checked) {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.day-picker input[type="checkbox"] {
display: none;
}
/* Toast */
.toast {
position: fixed;
bottom: 20px;
right: 20px;
background: #333;
color: #fff;
padding: 10px 20px;
border-radius: 6px;
font-size: 14px;
animation: fadeIn 0.3s;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── Responsive breakpoints ────────────────── */
/* Tablet: 641–1024px */
@media (max-width: 1024px) {
#app { max-width: 100%; padding: 16px; }
.section { padding: 16px; }
}
/* Phone: ≤ 640px */
@media (max-width: 640px) {
#app { padding: 10px; }
header { flex-direction: column; align-items: flex-start; }
header h1 { font-size: 18px; }
#tabs { width: 100%; }
.tab { flex: 1; text-align: center; padding: 10px 8px; font-size: 13px; }
.section { padding: 14px; margin-bottom: 12px; }
.section h3 { font-size: 15px; margin-bottom: 10px; }
.inline-form { flex-direction: column; align-items: stretch; gap: 8px; }
input, select, button { width: 100%; min-width: 0; font-size: 15px; }
input[type="text"],
input[type="url"],
input[type="number"],
input[type="time"],
select { padding: 10px 12px; }
button { padding: 10px 16px; }
/* Day picker: wrap to two rows */
.day-picker { flex-wrap: wrap; gap: 4px; }
.day-picker label { padding: 6px 10px; font-size: 14px; }
/* List items: stack vertically */
.group-item, .alarm-item, .clock-item {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.group-actions { width: 100%; justify-content: flex-end; }
/* Toast: full-width at bottom */
.toast {
left: 10px; right: 10px; bottom: 10px;
text-align: center; font-size: 14px;
}
}
/* Large desktop: > 1400px */
@media (min-width: 1400px) {
#app { max-width: 1200px; }
.section { padding: 28px; }
input[type="text"],
input[type="url"],
input[type="number"],
input[type="time"],
select { padding: 10px 14px; font-size: 15px; }
button { padding: 10px 20px; font-size: 15px; }
}
+867
View File
@@ -0,0 +1,867 @@
/* Display page styles — responsive, baseline 800×480 */
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #0f1117;
--card-bg: #1a1d2e;
--card-active: #252840;
--text: #e0e0e0;
--text-dim: #888;
--accent: #4A90D9;
--accent-warm: #E8943A;
--accent-green: #4CAF50;
--accent-red: #E05555;
--radius: clamp(6px, 1.25vw, 16px);
/* 半透明白系列(背景/边框/分隔线,随主题切换) */
--w03: rgba(255,255,255,0.03);
--w04: rgba(255,255,255,0.04);
--w05: rgba(255,255,255,0.05);
--w06: rgba(255,255,255,0.06);
--w08: rgba(255,255,255,0.08);
--w10: rgba(255,255,255,0.10);
--w14: rgba(255,255,255,0.14);
--w15: rgba(255,255,255,0.15);
--w16: rgba(255,255,255,0.16);
--card-border: rgba(255,255,255,0.12);
}
/* 浅色主题:白偏蓝背景、黑色字体与图标(html.theme-light 时生效) */
.theme-light {
--bg: #e8edf4;
--card-bg: #ffffff;
--card-active: #dde4ee;
--text: #1c2333;
--text-dim: #5b6a7e;
--accent: #2f6fb2;
--accent-warm: #cf7416;
--accent-green: #3a9450;
--accent-red: #c74545;
--radius: clamp(6px, 1.25vw, 16px);
--w03: rgba(28,35,51,0.03);
--w04: rgba(28,35,51,0.04);
--w05: rgba(28,35,51,0.05);
--w06: rgba(28,35,51,0.06);
--w08: rgba(28,35,51,0.08);
--w10: rgba(28,35,51,0.10);
--w14: rgba(28,35,51,0.14);
--w15: rgba(28,35,51,0.15);
--w16: rgba(28,35,51,0.16);
--card-border: #c9d4e2;
}
/* 浅色主题下的天气图标:还原彩色(深色下由 filter 转白) */
.theme-light .card-weather-icon,
.theme-light .weather-icon,
.theme-light .hc-icon img { filter: none; }
/* 浅色主题下的 Sidebar 线性图标:黑色线条 */
.theme-light .card-icon svg { color: #1c2333; }
/* 主题切换动画:切换瞬间由 theme.js 临时加 .theme-anim,触发全局平滑过渡 */
html.theme-anim,
html.theme-anim * {
transition: background-color 0.55s ease, color 0.55s ease,
border-color 0.55s ease, box-shadow 0.55s ease,
fill 0.55s ease, stroke 0.55s ease !important;
}
html, body {
width: 100vw;
height: 100vh;
height: 100dvh; /* dynamic viewport for mobile browsers */
overflow: hidden;
font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
}
#app {
position: relative;
display: flex;
width: 100%;
height: 100%;
padding: clamp(4px, 1vw, 16px); /* 左右对称:大卡片左边缘与右边缘距离一致 */
gap: clamp(4px, 1vw, 16px);
/* 布局变量:Sidebar 浮层与大卡片左缘间距、Sidebar 宽度 */
--sidebar-gap: clamp(14px, 1.8vw, 28px);
--sidebar-w: clamp(140px, 25vw, 280px);
}
/* ── Sidebar(浮层:叠在大卡片之上,上/下/左与大卡片边缘等距)── */
#sidebar {
position: absolute;
left: var(--sidebar-gap);
top: var(--sidebar-gap);
bottom: var(--sidebar-gap);
width: var(--sidebar-w);
min-width: 0;
z-index: 2;
display: flex;
flex-direction: column;
gap: clamp(4px, 0.75vw, 12px);
}
.card {
flex: 1;
background: var(--card-bg);
border-radius: var(--radius);
padding: clamp(6px, 1.25vw, 18px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
border: 2px solid var(--card-border); /* Sidebar 小卡片框框(与大卡片区分层次) */
transition: border-color 0.2s, background 0.2s;
position: relative;
overflow: hidden;
}
.card:active { background: var(--card-active); }
.card.active { border-color: var(--accent); }
.card-icon { font-size: clamp(16px, 2.75vw, 36px); margin-bottom: clamp(2px, 0.25vw, 6px); }
/* Sidebar 线性图标:白色线条、无填充,尺寸与天气图标一致 */
.card-icon svg {
width: clamp(24px, 3.5vw, 44px);
height: clamp(24px, 3.5vw, 44px);
color: #fff; /* stroke 使用 currentColor */
}
/* 图标+标签 左列容器(默认纵向排列,视觉与原先一致) */
.card-left {
display: flex;
flex-direction: column;
align-items: center;
gap: clamp(2px, 0.25vw, 6px);
min-width: 0;
}
.card-left .card-icon { margin-bottom: 0; }
.card-left .card-label { margin-bottom: 0; }
.card-weather-icon { width: clamp(24px, 3.5vw, 44px); height: clamp(24px, 3.5vw, 44px); filter: brightness(0) invert(1); }
.card-label { font-size: clamp(10px, 1.4vw, 16px); color: var(--text-dim); margin-bottom: clamp(2px, 0.25vw, 6px); }
.card-value {
font-size: clamp(11px, 1.6vw, 20px); font-weight: 600; text-align: center;
max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* ── Panel ────────────────────────────────── */
#panel {
flex: 1;
min-width: 0;
background: var(--card-bg);
border-radius: var(--radius);
/* 左侧预留 Sidebar 区域:内容左缘 = sidebar左距 + sidebar宽 + 等距,
比 Sidebar 离大卡片左边缘的距离更大 */
padding: clamp(10px, 2vw, 28px) clamp(12px, 2.5vw, 36px)
clamp(10px, 2vw, 28px)
calc(var(--sidebar-gap) + var(--sidebar-w) + var(--sidebar-gap));
overflow-y: auto;
position: relative;
}
.lock-btn {
position: absolute;
top: clamp(6px, 1vh, 14px);
right: clamp(8px, 1.2vw, 18px);
background: var(--w06);
border: 1px solid var(--w10);
border-radius: 8px;
color: var(--text-dim);
font-size: clamp(14px, 2vw, 22px);
padding: clamp(4px, 0.6vh, 8px) clamp(8px, 1vw, 14px);
cursor: pointer;
line-height: 1;
z-index: 10;
transition: background 0.15s, color 0.15s;
}
.lock-btn:active {
background: var(--w14);
color: var(--text);
}
.panel-content { display: none; }
.panel-content.active { display: block; }
#panel-pomodoro.active { display: flex; flex-direction: column; height: 100%; }
.pomo-bg-wrap {
position: relative;
width: 100%;
flex: 1;
min-height: clamp(200px, 40vh, 400px);
border-radius: clamp(8px, 1.2vw, 16px);
overflow: hidden;
}
.panel-content h2 {
font-size: clamp(14px, 2vw, 26px); color: var(--text-dim); margin-bottom: clamp(8px, 1.5vh, 16px);
font-weight: 400; border-bottom: 1px solid var(--w08);
padding-bottom: clamp(6px, 1vh, 12px);
}
/* ── Time ─────────────────────────────────── */
.big-clock { font-size: clamp(32px, 7vw, 96px); font-weight: 700; font-variant-numeric: tabular-nums; letter-spacing: 2px; margin-bottom: clamp(2px, 0.5vh, 8px); }
.date-str { font-size: clamp(12px, 2vw, 24px); color: var(--text-dim); margin-bottom: clamp(10px, 2vh, 24px); }
.clock-list { display: flex; flex-wrap: wrap; gap: clamp(6px, 1.25vw, 16px); }
.clock-item { background: var(--w05); border-radius: 8px; padding: clamp(6px, 1vw, 14px) clamp(10px, 1.75vw, 22px); }
.clock-item .city { font-size: clamp(11px, 1.5vw, 18px); color: var(--text-dim); }
.clock-item .time { font-size: clamp(16px, 2.5vw, 30px); font-weight: 600; }
/* ── Weather ──────────────────────────────── */
.weather-main { display: flex; align-items: center; gap: clamp(10px, 1.75vw, 24px); margin-bottom: clamp(8px, 1.5vh, 18px); }
.weather-temp-big { font-size: clamp(28px, 6.5vw, 88px); font-weight: 700; }
.weather-desc { font-size: clamp(14px, 2.25vw, 28px); color: var(--text-dim); }
.weather-icon { width: clamp(48px, 7vw, 88px); height: clamp(48px, 7vw, 88px); flex-shrink: 0; filter: brightness(0) invert(1); }
.weather-details { display: grid; grid-template-columns: 1fr 1fr; gap: clamp(6px, 1vw, 14px); }
.weather-detail { background: var(--w04); border-radius: 6px; padding: clamp(6px, 1vw, 14px) clamp(10px, 1.5vw, 20px); font-size: clamp(11px, 1.75vw, 20px); }
.weather-detail .label { font-size: clamp(10px, 1.4vw, 16px); color: var(--text-dim); }
/* ── Hourly Forecast ──────────────────────── */
.hourly-section { margin-top: clamp(12px, 2vh, 24px); }
.hourly-title { font-size: clamp(12px, 1.6vw, 18px); color: var(--text-dim); margin-bottom: clamp(8px, 1vh, 14px); }
.hourly-list {
display: flex; gap: clamp(4px, 0.6vw, 10px);
overflow-x: auto; padding-bottom: clamp(4px, 0.5vh, 8px);
scrollbar-width: thin;
}
.hc-item {
flex: 0 0 auto;
min-width: clamp(44px, 6vw, 64px);
display: flex; flex-direction: column; align-items: center;
gap: clamp(2px, 0.3vh, 6px);
padding: clamp(6px, 0.8vw, 12px) clamp(4px, 0.6vw, 10px);
background: var(--w04);
border-radius: clamp(6px, 0.8vw, 12px);
border: 1px solid transparent;
}
.hc-item.hc-now {
background: rgba(74, 144, 217, 0.15);
border-color: var(--accent);
}
.hc-time { font-size: clamp(10px, 1.3vw, 15px); color: var(--text-dim); }
.hc-time .tomorrow { font-size: clamp(8px, 1vw, 12px); color: var(--accent-warm); margin-right: 2px; }
.hc-icon { font-size: clamp(16px, 2.2vw, 28px); line-height: 1; }
.hc-icon img { display: block; width: 100%; height: 100%; filter: brightness(0) invert(1); }
.hc-temp { font-size: clamp(14px, 1.8vw, 22px); font-weight: 600; }
.hc-pop { font-size: clamp(10px, 1.2vw, 14px); color: var(--accent-warm); }
/* ── Weather Update Info ──────────────────── */
.update-info {
margin-top: clamp(10px, 1.5vh, 18px);
padding: clamp(6px, 0.8vw, 12px) clamp(8px, 1vw, 14px);
background: var(--w03);
border-radius: clamp(4px, 0.6vw, 8px);
font-size: clamp(10px, 1.3vw, 14px);
color: var(--text-dim);
line-height: 1.8;
}
.update-info .label {
color: var(--text-dim);
opacity: 0.7;
}
/* ── Minutely + Warnings ──────────────────── */
.section-title-row {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: clamp(6px, 0.8vh, 10px);
}
.mini-refresh-btn {
padding: clamp(2px, 0.3vw, 6px) clamp(8px, 1vw, 14px);
font-size: clamp(10px, 1.2vw, 14px);
border: 1px solid var(--w15);
border-radius: 6px;
background: var(--w08);
color: var(--text-dim);
cursor: pointer;
font-family: inherit;
}
.mini-refresh-btn:active { background: var(--w16); }
/* Minutely bars */
.minutely-bars {
display: flex; gap: 1px; align-items: flex-end;
height: clamp(60px, 8vh, 100px);
overflow-x: auto; padding-bottom: 4px;
}
.mb-col {
flex: 0 0 clamp(16px, 2.2vw, 28px);
display: flex; flex-direction: column; align-items: center;
justify-content: flex-end; height: 100%;
}
.mb-bar {
width: 100%;
background: var(--accent);
border-radius: 2px 2px 0 0;
min-height: 2px;
opacity: 0.7;
transition: height 0.3s;
}
.mb-time {
font-size: clamp(8px, 0.9vw, 10px);
color: var(--text-dim);
margin-top: 2px;
}
/* Warning cards */
.warning-card {
background: var(--w04);
border-radius: clamp(4px, 0.6vw, 8px);
padding: clamp(8px, 1vw, 14px) clamp(10px, 1.5vw, 18px);
margin-bottom: clamp(4px, 0.6vh, 8px);
}
.warning-title {
font-size: clamp(12px, 1.6vw, 18px);
font-weight: 600;
margin-bottom: clamp(4px, 0.4vh, 6px);
}
.warning-text {
font-size: clamp(11px, 1.4vw, 16px);
color: var(--text-dim);
line-height: 1.5;
}
/* ── Display Toast ────────────────────────── */
.display-toast {
position: fixed;
bottom: clamp(20px, 4vh, 40px);
left: 50%;
transform: translateX(-50%);
background: rgba(30, 30, 40, 0.95);
color: #fff;
padding: clamp(10px, 1.5vw, 18px) clamp(20px, 3vw, 36px);
border-radius: clamp(8px, 1vw, 14px);
font-size: clamp(13px, 1.8vw, 22px);
z-index: 300;
border: 1px solid var(--w10);
animation: toast-in 0.25s ease;
pointer-events: none;
}
@keyframes toast-in {
from { opacity: 0; transform: translateX(-50%) translateY(10px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
/* ── Calendar ─────────────────────────────── */
.event-list { max-height: calc(100vh - clamp(100px, 29vw, 220px)); overflow-y: auto; }
.event-item { display: flex; align-items: center; gap: clamp(8px, 1.5vw, 20px); padding: clamp(6px, 1vh, 12px) 0; border-bottom: 1px solid var(--w05); }
.event-dot { width: clamp(6px, 1vw, 12px); height: clamp(6px, 1vw, 12px); border-radius: 50%; flex-shrink: 0; }
.event-time { font-size: clamp(16px, 2.5vw, 30px); font-weight: 600; font-variant-numeric: tabular-nums; min-width: clamp(40px, 6.5vw, 70px); }
.event-summary { font-size: clamp(13px, 1.9vw, 24px); }
.event-sub { font-size: clamp(11px, 1.5vw, 18px); color: var(--text-dim); }
/* ── Pomodoro ─────────────────────────────── */
.pomo-bg-img {
position: absolute;
inset: 0;
background-image: url('/static/img/bg_pomodoro.jpg');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
/* If bg_pomodoro.jpg doesn't exist, the 404 is silently ignored — no extra CSS needed */
.pomo-content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: clamp(24px, 4vh, 48px) clamp(16px, 3vh, 32px);
}
.pomo-glass {
display: inline-block;
text-align: center;
padding: clamp(8px, 1.2vh, 16px) clamp(16px, 2.5vw, 36px);
background: rgba(0,0,0,0.30);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: clamp(10px, 1.5vw, 20px);
}
#pomodoro-phase { font-size: clamp(16px, 2.5vw, 28px); color: var(--accent-green); margin-bottom: clamp(4px, 0.6vh, 10px); font-weight: 600; }
#pomodoro-timer { font-size: clamp(36px, 8vw, 100px); font-weight: 700; font-variant-numeric: tabular-nums; margin-bottom: clamp(4px, 0.6vh, 10px); }
#pomodoro-cycles { font-size: clamp(12px, 2vw, 20px); color: var(--text-dim); }
#pomodoro-next-break { font-size: clamp(13px, 2vw, 22px); color: var(--accent-warm); margin-top: clamp(6px, 0.8vh, 12px); font-weight: 500; }
/* Pomodoro sidebar card layout */
.pomo-sidebar-row {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.pomo-sidebar-btns {
position: absolute;
right: clamp(4px, 0.5vw, 8px);
top: clamp(4px, 0.5vw, 8px);
bottom: clamp(4px, 0.5vw, 8px);
display: flex;
flex-direction: column;
gap: clamp(2px, 0.3vw, 5px);
}
.pomo-sidebar-info {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 0;
}
.pomo-mini-btn {
flex: 1;
min-width: 0;
padding: 0 clamp(6px, 0.8vw, 12px);
font-size: clamp(9px, 1.2vw, 14px);
font-weight: 600;
border: none;
border-radius: clamp(3px, 0.4vw, 6px);
cursor: pointer;
font-family: inherit;
color: #fff;
line-height: 1;
}
.pomo-mini-btn:active { opacity: 0.7; }
.pomo-mini-primary { background: var(--accent-green); }
.pomo-mini-secondary { background: var(--accent-warm); }
.pomo-mini-danger { background: var(--accent-red); }
/* ── Alarm Overlay ────────────────────────── */
.alarm-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
width: 100vw; height: 100vh;
height: 100dvh;
background: rgba(15, 17, 23, 0.92);
display: flex; align-items: center; justify-content: center;
z-index: 100;
}
.alarm-overlay.hidden { display: none; }
.alarm-overlay-box {
text-align: center;
padding: clamp(24px, 5vw, 56px) clamp(32px, 6.5vw, 72px);
background: var(--card-bg); border-radius: clamp(12px, 2vw, 24px);
border: 2px solid var(--accent-red);
animation: overlay-in 0.3s ease;
}
@keyframes overlay-in {
from { transform: scale(0.85); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.alarm-overlay-icon { font-size: clamp(40px, 7vw, 80px); margin-bottom: clamp(8px, 1.5vh, 16px); }
.alarm-overlay-title { font-size: clamp(18px, 2.75vw, 36px); font-weight: 700; margin-bottom: clamp(6px, 1vh, 12px); color: var(--accent-red); }
.alarm-overlay-info { font-size: clamp(14px, 2vw, 26px); color: var(--text-dim); margin-bottom: clamp(16px, 3vh, 32px); line-height: 1.6; }
.alarm-dismiss-btn {
padding: clamp(10px, 1.75vw, 20px) clamp(32px, 6vw, 64px);
font-size: clamp(14px, 2.25vw, 26px); font-weight: 600;
background: var(--accent-red); color: #fff;
border: none; border-radius: clamp(8px, 1.25vw, 14px); cursor: pointer;
font-family: inherit;
}
.alarm-dismiss-btn:active { opacity: 0.8; }
/* ── Animations ───────────────────────────── */
.card.alert {
border-color: var(--accent-red);
animation: alert-flash 0.5s ease 6;
}
@keyframes alert-flash {
0%, 100% { background: var(--card-bg); }
50% { background: rgba(224, 85, 85, 0.3); }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* ── Scrollbar ────────────────────────────── */
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--0.1); border-radius: 2px; }
/* ── Small-screen fine-tuning (< 600px) ──────
Preserves the existing 800×480 design intent while keeping
things usable on even narrower viewports (phones). */
@media (max-width: 600px) {
#app {
flex-direction: column;
padding: clamp(3px, 1.5vw, 8px);
gap: clamp(3px, 1.5vw, 8px);
}
#sidebar {
position: static; /* 小屏恢复流式布局(顶部横条) */
flex-direction: row;
width: 100%;
height: clamp(60px, 15vh, 100px);
gap: clamp(3px, 1.5vw, 8px);
}
#panel { flex: 1; padding: clamp(10px, 2vw, 28px) clamp(12px, 2.5vw, 36px); }
.card { min-width: 0; }
.card-icon { font-size: clamp(14px, 4vw, 24px); }
.card-label { font-size: clamp(9px, 2.5vw, 14px); }
.card-value { font-size: clamp(10px, 2.8vw, 16px); }
}
/* ── 超宽屏(宽高比严格大于 16:9):Sidebar 卡片横向布局 ──
图标+标签在左(card-left 列),具体信息在右侧自动换行,
避免窄高卡片内文字放大覆盖。番茄钟按钮保持右侧竖排,
info 预留按钮区域避免覆盖。 */
@media (min-aspect-ratio: 1601/900) {
#sidebar .card:not(.card-pomodoro) {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: clamp(8px, 1vw, 16px);
padding: clamp(4px, 0.7vw, 10px) clamp(10px, 1.2vw, 18px);
}
/* 左右 1:1:图标+标签占左半(内容居中),信息占右半(文字居中) */
#sidebar .card-left { flex: 1 1 0; min-width: 0; }
#sidebar .card:not(.card-pomodoro) .card-value,
#sidebar .pomo-sidebar-info .card-value {
flex: 1 1 0;
min-width: 0;
text-align: center;
white-space: normal; /* 覆盖全局 nowrap:允许换行,line-clamp 才生效 */
line-height: 1.35;
word-break: break-word;
/* 长文本上限 2 行:超出在底部截断并以省略号代替 */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 番茄钟:与其他卡片一致——整卡左右 1:1(图标+标签左半、信息右半)。
按钮与 value 互斥:value 显示时按钮隐藏,按钮显示时 value 隐藏,
按钮 absolute 落在右侧空区,互不遮挡 */
#sidebar .card-pomodoro { flex-direction: row; }
#sidebar .pomo-sidebar-info {
width: 100%;
height: 100%;
flex-direction: row;
align-items: center;
justify-content: center;
gap: clamp(8px, 1vw, 16px);
}
}
/* ── Large-screen caps (> 1920px) ───────────
Once the viewport is wide enough that all clamp() values
hit their maximums, constrain the app so it doesn't keep
stretching. */
@media (min-width: 1920px) {
html, body {
display: flex;
align-items: center;
justify-content: center;
}
#app {
max-width: 1920px;
max-height: 1080px;
}
}
/* ── Panel header buttons (refresh + lock) ── */
.panel-header-btns {
position: absolute;
top: clamp(6px, 1vh, 14px);
right: clamp(8px, 1.2vw, 18px);
display: flex;
gap: clamp(4px, 0.6vw, 10px);
z-index: 10;
}
.panel-header-btns .lock-btn {
position: static;
}
/* ── Panel Add Button ─────────────────────── */
.panel-add-btn {
display: block;
width: 100%;
margin-top: clamp(8px, 1.5vh, 16px);
padding: clamp(8px, 1.25vw, 16px);
font-size: clamp(13px, 2vw, 22px);
font-weight: 600;
background: var(--w06);
border: 1px dashed var(--0.18);
border-radius: clamp(6px, 1vw, 12px);
color: var(--text-dim);
cursor: pointer;
font-family: inherit;
transition: background 0.15s, color 0.15s;
}
.panel-add-btn:active {
background: var(--0.12);
color: var(--text);
}
/* ── Alarm item action buttons (edit/delete) ─ */
.alarm-actions {
display: flex;
gap: clamp(4px, 0.6vw, 8px);
flex-shrink: 0;
}
.alarm-action-btn {
padding: clamp(3px, 0.5vw, 6px) clamp(8px, 1.2vw, 14px);
font-size: clamp(10px, 1.4vw, 16px);
border: 1px solid var(--w15);
border-radius: 6px;
background: var(--w06);
color: var(--text-dim);
cursor: pointer;
font-family: inherit;
transition: background 0.15s, color 0.15s;
}
.alarm-action-btn:active {
background: var(--w14);
}
.alarm-action-btn.edit-btn:hover { color: var(--accent); border-color: var(--accent); }
.alarm-action-btn.del-btn:hover { color: var(--accent-red); border-color: var(--accent-red); }
/* ── Alarm item layout (with actions) ─────── */
.alarm-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: clamp(8px, 1.25vw, 18px) clamp(10px, 1.75vw, 22px);
background: var(--w04);
border-radius: 8px;
margin-bottom: clamp(6px, 1vh, 12px);
}
.alarm-item .alarm-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.alarm-item .alarm-time {
font-size: clamp(20px, 3.5vw, 42px);
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.alarm-item .alarm-label {
font-size: clamp(12px, 1.75vw, 22px);
color: var(--text-dim);
}
.alarm-item .alarm-days {
font-size: clamp(10px, 1.4vw, 16px);
color: var(--text-dim);
}
.alarm-item .alarm-status {
font-size: clamp(10px, 1.2vw, 14px);
color: var(--text-dim);
text-align: right;
}
.alarm-item.triggered {
background: rgba(224, 85, 85, 0.25);
border: 2px solid var(--accent-red);
animation: pulse 1s infinite;
}
/* ── Modal Overlay ────────────────────────── */
.modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
width: 100vw; height: 100vh;
height: 100dvh;
background: rgba(15, 17, 23, 0.90);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal-overlay.hidden { display: none; }
.modal-box {
background: var(--card-bg);
border-radius: clamp(12px, 2vw, 24px);
padding: clamp(20px, 3vw, 40px) clamp(24px, 4vw, 48px);
width: min(90vw, clamp(320px, 50vw, 500px));
max-height: 90vh;
overflow-y: auto;
border: 1px solid var(--w10);
animation: overlay-in 0.25s ease;
}
.modal-title {
font-size: clamp(16px, 2.5vw, 28px);
font-weight: 700;
margin-bottom: clamp(16px, 2.5vh, 28px);
text-align: center;
}
.modal-field {
margin-bottom: clamp(14px, 2.5vh, 24px);
}
.modal-label {
font-size: clamp(12px, 1.6vw, 18px);
color: var(--text-dim);
margin-bottom: clamp(6px, 0.8vh, 10px);
}
.alarm-modal-name {
font-size: clamp(14px, 2vw, 22px);
color: var(--text);
padding: clamp(6px, 1vw, 12px);
background: var(--w04);
border-radius: 6px;
}
.modal-actions {
display: flex;
gap: clamp(10px, 1.5vw, 20px);
justify-content: center;
margin-top: clamp(16px, 3vh, 32px);
}
/* Modal buttons */
.pomo-btn {
padding: clamp(10px, 1.5vw, 18px) clamp(24px, 4vw, 48px);
font-size: clamp(14px, 2vw, 22px);
font-weight: 600;
border: none;
border-radius: clamp(8px, 1vw, 14px);
cursor: pointer;
font-family: inherit;
transition: opacity 0.15s;
}
.pomo-btn:active { opacity: 0.7; }
.pomo-btn-primary { background: var(--accent); color: #fff; }
.pomo-btn-secondary { background: var(--w08); color: var(--text-dim); border: 1px solid var(--0.12); }
/* ── Time Picker (mouse-only) ─────────────── */
.time-picker {
display: flex;
align-items: center;
justify-content: center;
gap: clamp(4px, 0.8vw, 12px);
}
.tp-col {
display: flex;
flex-direction: column;
align-items: center;
gap: clamp(2px, 0.4vh, 6px);
}
.tp-btn {
width: clamp(40px, 6vw, 64px);
height: clamp(32px, 4vh, 48px);
border: 1px solid var(--w15);
border-radius: 8px;
background: var(--w06);
color: var(--text);
font-size: clamp(14px, 2vw, 24px);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
transition: background 0.1s;
font-family: inherit;
}
.tp-btn:active {
background: var(--w16);
}
.tp-value {
font-size: clamp(32px, 6vw, 64px);
font-weight: 700;
font-variant-numeric: tabular-nums;
width: clamp(50px, 7vw, 80px);
text-align: center;
line-height: 1.3;
}
.tp-sep {
font-size: clamp(28px, 5vw, 56px);
font-weight: 700;
color: var(--text-dim);
margin-top: calc(clamp(32px, 4vh, 48px) + clamp(8px, 1.2vh, 18px));
}
/* ── Day Picker (modal style) ─────────────── */
#alarm-modal-days {
display: flex;
gap: clamp(4px, 0.6vw, 8px);
}
.day-btn {
width: clamp(32px, 4.5vw, 48px);
height: clamp(32px, 4.5vw, 48px);
border-radius: 50%;
border: 1px solid var(--0.18);
background: var(--w06);
color: var(--text-dim);
font-size: clamp(12px, 1.6vw, 18px);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-family: inherit;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.day-btn:active {
background: var(--w14);
}
.day-btn.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
/* ── Toggle Switch ────────────────────────── */
.toggle-label {
display: inline-flex;
align-items: center;
gap: clamp(8px, 1vw, 14px);
cursor: pointer;
font-size: clamp(13px, 1.8vw, 20px);
color: var(--text);
}
.toggle-label input { display: none; }
.toggle-slider {
width: clamp(36px, 4vw, 48px);
height: clamp(20px, 2.5vh, 28px);
background: var(--w15);
border-radius: 14px;
position: relative;
transition: background 0.2s;
}
.toggle-slider::after {
content: '';
position: absolute;
top: 2px; left: 2px;
width: calc(clamp(20px, 2.5vh, 28px) - 4px);
height: calc(clamp(20px, 2.5vh, 28px) - 4px);
background: #fff;
border-radius: 50%;
transition: transform 0.2s;
}
.toggle-label input:checked + .toggle-slider {
background: var(--accent-green);
}
.toggle-label input:checked + .toggle-slider::after {
transform: translateX(calc(clamp(36px, 4vw, 48px) - clamp(20px, 2.5vh, 28px)));
}
/* ── Countdown List ───────────────────────── */
.countdown-list .cd-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: clamp(8px, 1.25vw, 18px) clamp(10px, 1.75vw, 22px);
background: var(--w04);
border-radius: 8px;
margin-bottom: clamp(6px, 1vh, 12px);
}
.cd-item .cd-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.cd-item .cd-time {
font-size: clamp(24px, 4vw, 52px);
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.cd-item .cd-label {
font-size: clamp(11px, 1.5vw, 18px);
color: var(--text-dim);
}
.cd-item .cd-remain {
font-size: clamp(14px, 2vw, 24px);
color: var(--accent-warm);
font-weight: 600;
text-align: right;
white-space: nowrap;
}
.cd-item .cd-remain.overdue {
color: var(--accent-red);
}
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-100-fill" viewBox="0 0 16 16">
<path d="M8.005 3.5a4.5 4.5 0 1 0 0 9 4.5 4.5 0 0 0 0-9Zm.004-.997a.5.5 0 0 1-.5-.5v-1.5a.5.5 0 0 1 1 0v1.5a.5.5 0 0 1-.5.5ZM3.766 4.255a.498.498 0 0 1-.353-.147l-1.062-1.06a.5.5 0 0 1 .707-.707L4.122 3.4a.5.5 0 0 1-.355.854v.001ZM2.004 8.493h-1.5a.5.5 0 1 1 0-1h1.5a.5.5 0 1 1 0 1Zm.691 5.303a.5.5 0 0 1-.354-.854l1.062-1.06a.5.5 0 0 1 .708.707l-1.063 1.06a.497.497 0 0 1-.353.147Zm5.301 2.201a.5.5 0 0 1-.5-.5v-1.5a.5.5 0 0 1 1 0v1.5a.5.5 0 0 1-.5.5Zm5.304-2.191a.496.496 0 0 1-.353-.147l-1.06-1.06a.5.5 0 1 1 .706-.707l1.06 1.06a.5.5 0 0 1-.353.854Zm2.203-5.299h-1.5a.5.5 0 0 1 0-1h1.5a.5.5 0 1 1 0 1ZM12.25 4.265a.5.5 0 0 1-.354-.854l1.06-1.06a.5.5 0 1 1 .708.707l-1.06 1.06a.498.498 0 0 1-.354.147Z"/>
</svg>

After

Width:  |  Height:  |  Size: 839 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-100" viewBox="0 0 16 16">
<path d="M7.655 2.357a.5.5 0 0 0 .854-.353v-1.5a.5.5 0 0 0-1 0v1.5a.5.5 0 0 0 .146.353Zm-4.08 1.861c.06.026.126.039.191.039l.001-.001a.5.5 0 0 0 .355-.855l-1.064-1.06a.5.5 0 0 0-.707.708l1.062 1.06a.498.498 0 0 0 .162.11ZM.503 8.496h1.5a.5.5 0 1 0 0-1h-1.5a.5.5 0 0 0 0 1Zm1.914 5.221a.501.501 0 0 0 .631-.063l1.063-1.06a.5.5 0 0 0-.708-.707l-1.062 1.06a.5.5 0 0 0 .076.77Zm5.225 2.14a.5.5 0 0 0 .854-.354v-1.5a.5.5 0 0 0-1 0v1.5a.5.5 0 0 0 .146.354Zm5.467-2.084a.5.5 0 0 0 .544-.816l-1.06-1.06a.498.498 0 0 0-.832.152.5.5 0 0 0 .126.555l1.06 1.06a.496.496 0 0 0 .162.109Zm.893-5.263h1.5a.5.5 0 1 0 0-1h-1.5a.5.5 0 0 0 0 1Zm-2.031-4.327a.5.5 0 0 0 .633-.063l1.06-1.06a.5.5 0 1 0-.708-.708l-1.06 1.06a.5.5 0 0 0 .075.77Zm-6.466.075a4.5 4.5 0 1 1 5 7.484 4.5 4.5 0 0 1-5-7.484Zm4.445.832a3.5 3.5 0 1 0-3.89 5.82 3.5 3.5 0 0 0 3.89-5.82Z"/>
</svg>

After

Width:  |  Height:  |  Size: 965 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1001" viewBox="0 0 16 16">
<path d="M9.21 1.491c-3.757-.066-6.613 2.537-6.88 5.705-.292 3.494 2.107 4.947 2.428 5.232C2.853 12.4.585 10.28.204 8.296a.104.104 0 0 0-.1-.085.103.103 0 0 0-.103.114c.403 3.526 3.405 6.2 6.79 6.186 3.604-.016 6.518-2.147 6.89-5.646.35-3.295-2.108-5.008-2.424-5.292 2.023.02 4.162 2.15 4.54 4.133.008.048.05.084.098.085a.102.102 0 0 0 .1-.071.103.103 0 0 0 .004-.043c-.406-3.521-3.405-6.126-6.788-6.185ZM8 9.503A1.502 1.502 0 1 1 8 6.5a1.502 1.502 0 0 1 0 3.004Z"/>
</svg>

After

Width:  |  Height:  |  Size: 595 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1002" viewBox="0 0 16 16">
<path d="M2.653 4.48c-.105-.315-.105-.573.014-.791.53-.967 2.19-2.004 4.637-2.443 2.743-.492 4.582.057 5.1.746a.71.71 0 0 1 .033.837c.897-.528 1.158-1.081 1.014-1.58C13.166.246 10.41-.369 7.09.244c-3.417.632-5.456 2.22-5.362 3.229.031.337.38.804.925 1.006Z"/>
<path d="M11.568 3.056c-.178-.621-1.676-1.49-3.99-1.047-2.362.452-3.348 1.804-3.29 2.43.02.208.237.498.575.624-.066-.196-.065-.356.01-.492.327-.6 1.358-1.244 2.877-1.516 1.704-.306 2.845.035 3.166.463a.44.44 0 0 1 .022.52c.556-.329.718-.672.63-.982Z"/>
<path d="M15.583 1.905c.062.674-.27 1.59-1.036 2.482.365.215.778.548.828.978.11.964-.43 2.058-1.606 2.953.008.007.016.012.024.017a.15.15 0 0 1 .03.023 1.278 1.278 0 0 1 .34 1.041c-.058.378-.301.568-.598.8-.3.232-.654.508-.926 1.062-.097.198-.38.559-.564.729-.378.35-.722.4-1.007.44-.18.026-.336.049-.462.141-.294.216-.43.424-.553.612-.101.154-.193.294-.356.415a.915.915 0 0 1-.432.167c-.165.03-.326.06-.519.252-.073.073-.128.19-.188.317-.078.167-.165.351-.31.476-.103.089-.21.146-.306.197-.117.062-.216.115-.27.207-.093.16-.123.34-.147.487-.028.167-.048.292-.149.299-.106.007-.964-1.02-1.167-1.975-.045-.21-.102-.478-.018-.791.022-.085.049-.15.085-.238.031-.078.07-.175.12-.319l.016-.044a2.85 2.85 0 0 0 .134-.446c.034-.234-.267-.455-.457-.595a1.666 1.666 0 0 1-.12-.092c-.149-.138-1.019-.973-.964-1.71 0-.189.048-.376.139-.542-.958-.5-1.593-1.174-1.774-1.808a1.946 1.946 0 0 1-.05-.459C1.513 6.41.583 5.356.424 4.445c-.073-.427.173-.94.554-1.38 0 .001-.035.686.285 1.026.956 1.018 3.98 1.787 7.223 1.262 3.613-.585 5.97-2.628 5.763-3.967-.06-.39-.198-.651-.538-.972 0 0 1.784.537 1.873 1.491Zm-5.95 9.347c1.13-.1 2.536-.664 3.386-1.793.17-.241.273-.524.298-.818-.83.52-1.902.951-3.247 1.199-1.852.341-3.422.098-4.581-.408.03.104.077.201.142.287.54.625 1.978 1.714 4.002 1.533ZM4.122 7.37c1.124.775 3.08 1.268 5.706.85 2.927-.463 4.836-2.11 4.671-3.22a1.227 1.227 0 0 0-.15-.395c-1.082 1.143-2.899 2.206-5.571 2.61-2.004.302-3.616.223-4.876-.072.06.088.134.165.22.227Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1003" viewBox="0 0 16 16">
<path d="M7.9 10a4.99 4.99 0 0 0 3.827-1.783 3 3 0 1 0 .553-5.63A4.999 4.999 0 0 0 7.9 0a4.998 4.998 0 0 0-4.359 2.549 3 3 0 1 0 .586 5.732A4.988 4.988 0 0 0 7.9 10Zm-.791-6.398c-.057-.362.17-.8.496-.997.256-.153.551-.133.806.023l.07.042a.846.846 0 0 1 .409.853L8.532 5.8H7.454L7.11 3.602ZM8.599 6.9a.6.6 0 1 1-1.2 0 .6.6 0 0 1 1.2 0Zm.344 4.804a.494.494 0 0 0-.157-.641c-.205-.13-.467-.053-.586.172l-2.143 4.061a.494.494 0 0 0 .157.641c.205.13.467.053.586-.172l2.143-4.061ZM2.8 11.556c.191.115.257.37.146.57l-1.2 2.165a.391.391 0 0 1-.546.153.427.427 0 0 1-.146-.57l1.2-2.165a.39.39 0 0 1 .546-.153Zm3 0c.191.115.257.37.146.57l-1.2 2.165a.391.391 0 0 1-.546.153.427.427 0 0 1-.146-.57l1.2-2.165a.39.39 0 0 1 .546-.153Zm9 0c.191.115.257.37.146.57l-1.2 2.165a.391.391 0 0 1-.546.153.427.427 0 0 1-.146-.57l1.2-2.165a.39.39 0 0 1 .546-.153Zm-2.854.569a.427.427 0 0 0-.146-.57.391.391 0 0 0-.546.154l-1.2 2.166a.428.428 0 0 0 .146.57.391.391 0 0 0 .546-.154l1.2-2.166Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1004" viewBox="0 0 16 16">
<path d="M7.9 10a4.99 4.99 0 0 0 3.827-1.783 3 3 0 1 0 .553-5.63A4.999 4.999 0 0 0 7.9 0a4.998 4.998 0 0 0-4.359 2.549 3 3 0 1 0 .586 5.732A4.988 4.988 0 0 0 7.9 10Zm-.791-6.398c-.057-.362.17-.8.496-.997.256-.153.551-.133.806.023l.07.042a.846.846 0 0 1 .409.853L8.532 5.8H7.454L7.11 3.602ZM8.599 6.9a.6.6 0 1 1-1.2 0 .6.6 0 0 1 1.2 0ZM1.501 10a.35.35 0 0 0-.35.35v.544l-.47-.272a.35.35 0 1 0-.35.606l.47.272-.47.272a.35.35 0 1 0 .35.606l.47-.272v.544a.35.35 0 1 0 .7 0v-.544l.47.272a.35.35 0 1 0 .35-.606l-.47-.272.47-.272a.35.35 0 1 0-.35-.606l-.47.272v-.544a.35.35 0 0 0-.35-.35Zm12.65.35a.35.35 0 1 1 .7 0v.544l.47-.272a.35.35 0 1 1 .35.606l-.47.272.47.272a.35.35 0 1 1-.35.606l-.47-.272v.544a.35.35 0 1 1-.7 0v-.544l-.47.272a.35.35 0 1 1-.35-.606l.47-.272-.47-.272a.35.35 0 1 1 .35-.606l.47.272v-.544Zm-6.5 3a.35.35 0 1 1 .7 0v.544l.47-.272a.35.35 0 1 1 .35.606l-.47.272.47.272a.35.35 0 1 1-.35.606l-.47-.272v.544a.35.35 0 1 1-.7 0v-.544l-.47.272a.35.35 0 1 1-.35-.606l.47-.272-.47-.272a.35.35 0 1 1 .35-.606l.47.272v-.544Zm-3.5-1a.35.35 0 1 1 .7 0v.544l.47-.272a.35.35 0 1 1 .35.606l-.47.272.47.272a.35.35 0 1 1-.35.606l-.47-.272v.544a.35.35 0 1 1-.7 0v-.544l-.47.272a.35.35 0 1 1-.35-.606l.47-.272-.47-.272a.35.35 0 1 1 .35-.606l.47.272v-.544Zm7.35-.35a.35.35 0 0 0-.35.35v.544l-.47-.272a.35.35 0 1 0-.35.606l.47.272-.47.272a.35.35 0 1 0 .35.606l.47-.272v.544a.35.35 0 1 0 .7 0v-.544l.47.272a.35.35 0 1 0 .35-.606l-.47-.272.47-.272a.35.35 0 1 0-.35-.606l-.47.272v-.544a.35.35 0 0 0-.35-.35Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1005" viewBox="0 0 16 16">
<path d="M8.5.5a.5.5 0 1 0-1 0v1.293l-.646-.647a.5.5 0 1 0-.708.708L7.5 3.207V5c0 .014 0 .028.002.041A2.997 2.997 0 0 0 5.687 6.09a.512.512 0 0 0-.035-.022l-1.553-.896-.495-1.85a.5.5 0 1 0-.966.26l.237.882-1.12-.646a.5.5 0 0 0-.5.866l1.12.646-.884.237a.5.5 0 0 0 .26.966l1.848-.495 1.553.896a.503.503 0 0 0 .036.019 2.994 2.994 0 0 0 0 2.096.508.508 0 0 0-.036.019l-1.553.896-1.849-.495a.5.5 0 0 0-.259.966l.884.237-1.12.646a.5.5 0 1 0 .5.866l1.12-.646-.237.883a.5.5 0 1 0 .966.258l.495-1.849 1.553-.896a.507.507 0 0 0 .035-.022 3 3 0 0 0 1.815 1.048.51.51 0 0 0-.002.04v1.793l-1.354 1.353a.5.5 0 0 0 .708.708l.646-.647V15.5a.5.5 0 0 0 1 0v-1.293l.646.647a.5.5 0 0 0 .708-.708L8.5 12.793V10.99c0-.01 0-.02-.002-.031H8.5V5.04h-.002l.002-.03V3.207l1.354-1.353a.5.5 0 1 0-.707-.708l-.647.647V.5Z"/>
<path d="M16 5.064 13.897 4l-1.946.985-1.722-.435-.229.928L12.061 6l1.836-.93 1.68.85.423-.856Zm0 3L13.897 7l-1.946.985-1.722-.435-.229.928L12.061 9l1.836-.93 1.68.85.423-.856ZM13.897 10 16 11.065l-.423.856-1.68-.85-1.836.929L10 11.478l.23-.928 1.721.435L13.897 10Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1006" viewBox="0 0 16 16">
<path d="M15.497 3.077S7.525.722 4.845.023a.794.794 0 0 0-.956.568L.024 15a.81.81 0 0 0 .544.968.811.811 0 0 0 1-.554l1.671-6.23 12.373-4.817a.696.696 0 0 0-.115-1.291Z"/>
<path d="M11.756 7.71a.296.296 0 0 0-.512 0L7.04 15a.292.292 0 0 0 .256.438h8.41a.292.292 0 0 0 .256-.437L11.756 7.71Zm-.956 2.41c-.036-.317.287-.59.7-.59.412 0 .736.273.7.59l-.316 2.785h-.768L10.8 10.12Zm1.267 3.91a.563.563 0 1 1-1.125 0 .563.563 0 0 1 1.125 0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 568 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1007" viewBox="0 0 16 16">
<path d="M15.859 8.141 14.34 9.66a.2.2 0 0 1-.34-.143V9H1a1 1 0 1 1 0-2h13v-.517a.2.2 0 0 1 .341-.142L15.86 7.86a.2.2 0 0 1 0 .282Z"/>
<path d="M10.462 2.99c.388.39 1.052.476 1.516.156.44-.303.536-.862.177-1.238a7.232 7.232 0 0 0-1.52-1.241C9.91.222 9.06 0 8.082 0 6.82 0 5.788.354 4.984 1.063c-.788.694-1.182 1.548-1.182 2.562 0 .472.102.944.307 1.417.205.472.528.916.97 1.333.191.174.447.383.768.625h3.366a24.36 24.36 0 0 0-.162-.104c-1.356-.89-2.223-1.528-2.6-1.917-.38-.389-.568-.84-.568-1.354 0-.528.197-.958.59-1.292.41-.333.907-.5 1.49-.5.6 0 1.12.132 1.561.396.276.156.588.41.938.761ZM11.725 9c.12.126.229.251.33.375.63.778.945 1.597.945 2.458 0 1.125-.497 2.104-1.49 2.938C10.533 15.59 9.39 16 8.082 16c-2.038 0-3.686-.887-4.946-2.662-.284-.4-.105-.918.36-1.154a1.175 1.175 0 0 1 1.43.328c.886 1.159 1.859 1.738 2.92 1.738.819 0 1.52-.23 2.104-.688.583-.458.875-.993.875-1.604 0-.61-.245-1.187-.733-1.729-.317-.36-.783-.77-1.398-1.229h3.031Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1008" viewBox="0 0 16 16">
<path d="M11.5 7a.5.5 0 0 0-.5.5v2.063a2 2 0 1 0 1 0V7.5a.5.5 0 0 0-.5-.5Z"/>
<path d="m10.2 8.399-.532.356a3.3 3.3 0 1 0 3.665 0l-.533-.356V2.5a1.3 1.3 0 1 0-2.6 0v5.899ZM9 2.5a2.5 2.5 0 0 1 5 0v5.258a4.5 4.5 0 1 1-5 0V2.5Zm-5.465-.033a.467.467 0 0 1 .933 0v.725l.628-.363a.467.467 0 1 1 .467.808L4.935 4l.628.363a.467.467 0 0 1-.467.808l-.628-.363v.725a.467.467 0 1 1-.933 0v-.725l-.628.363a.467.467 0 0 1-.467-.808L3.068 4l-.628-.363a.467.467 0 1 1 .467-.808l.628.363v-.725Z"/>
<path d="M7.324 1.872 4.175.048a.347.347 0 0 0-.35 0L.675 1.872a.352.352 0 0 0-.175.304v3.648c0 .126.067.242.175.305l3.15 1.824a.344.344 0 0 0 .35 0l3.149-1.824a.351.351 0 0 0 .176-.305V2.176a.353.353 0 0 0-.176-.304Zm-.526 3.75L4 7.241l-2.798-1.62V2.38L4 .758 6.798 2.38v3.242Z"/>
</svg>

After

Width:  |  Height:  |  Size: 895 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1009" viewBox="0 0 16 16">
<path d="M3 2a.6.6 0 0 0-.6.6v1.8H.6a.6.6 0 1 0 0 1.2h1.8v1.8a.6.6 0 0 0 1.2 0V5.6h1.8a.6.6 0 1 0 0-1.2H3.6V2.6A.6.6 0 0 0 3 2Zm8.5 1a.5.5 0 0 0-.5.5v6.063a2 2 0 1 0 1 0V3.5a.5.5 0 0 0-.5-.5Z"/>
<path d="m10.2 8.399-.532.356a3.3 3.3 0 1 0 3.665 0l-.533-.356V2.5a1.3 1.3 0 1 0-2.6 0v5.899ZM9 2.5a2.5 2.5 0 0 1 5 0v5.258a4.5 4.5 0 1 1-5 0V2.5Z"/>
</svg>

After

Width:  |  Height:  |  Size: 475 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-101-fill" viewBox="0 0 16 16">
<path d="M4.745 1.777a.516.516 0 1 0 1.007-.224L5.496.403A.516.516 0 0 0 4.49.627l.255 1.15ZM1.023 3.535l.994.633a.516.516 0 0 0 .554-.87l-.994-.633a.516.516 0 0 0-.554.87ZM.628 8.043l1.15-.256a.516.516 0 0 0-.223-1.008l-1.15.256a.516.516 0 1 0 .223 1.008Zm10.238-2.28a.535.535 0 0 0 .112-.012l1.15-.256a.516.516 0 1 0-.224-1.008l-1.15.256a.516.516 0 0 0 .112 1.02ZM8.522 2.728a.516.516 0 0 0 .712-.158l.633-.994a.516.516 0 0 0-.87-.554l-.633.994a.516.516 0 0 0 .158.712ZM2.819 7.032c.071.303.182.596.331.87a3.13 3.13 0 0 0 .908-.486 2.453 2.453 0 0 1-.232-.608A2.504 2.504 0 0 1 8.714 5.72l.004.038a5.42 5.42 0 0 1 1.064.25 3.51 3.51 0 0 0-.061-.512 3.535 3.535 0 0 0-6.902 1.536Zm9.175 7.364A4.758 4.758 0 0 1 8.406 16a4.76 4.76 0 0 1-3.537-1.547 2.908 2.908 0 0 1-1.056.197C2.258 14.65 1 13.441 1 11.95s1.26-2.7 2.813-2.7c.173 0 .342.015.507.044C5.124 7.924 6.652 7 8.406 7c1.769 0 3.308.94 4.107 2.328a2.93 2.93 0 0 1 .675-.078c1.553 0 2.812 1.209 2.812 2.7s-1.26 2.7-2.813 2.7a2.9 2.9 0 0 1-1.193-.254Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-101" viewBox="0 0 16 16">
<path d="M8.406 15.985a4.766 4.766 0 0 0 3.446-1.449.323.323 0 0 1 .341-.074c.31.112.645.173.995.173 1.553 0 2.812-1.209 2.812-2.7s-1.26-2.7-2.813-2.7c-.157 0-.312.013-.463.037a.32.32 0 0 1-.32-.137c-.824-1.29-2.306-2.15-3.998-2.15-1.682 0-3.157.85-3.984 2.128a.318.318 0 0 1-.3.138 2.962 2.962 0 0 0-.31-.016C2.26 9.235 1 10.444 1 11.935s1.26 2.7 2.813 2.7c.302 0 .594-.046.867-.13a.322.322 0 0 1 .324.076 4.768 4.768 0 0 0 3.402 1.404Zm3.661-2.606c-.161-.116-.41-.088-.52.074a3.788 3.788 0 0 1-3.14 1.632 3.792 3.792 0 0 1-3.091-1.56c-.106-.147-.326-.178-.482-.08-.294.183-.645.29-1.021.29-1.036 0-1.876-.806-1.876-1.8s.84-1.8 1.875-1.8c.241 0 .471.044.683.123.173.065.383-.008.455-.172.569-1.293 1.902-2.2 3.456-2.2 1.575 0 2.924.931 3.48 2.253.075.18.314.254.496.17.244-.111.517-.174.806-.174 1.035 0 1.874.806 1.874 1.8s-.839 1.8-1.874 1.8c-.42 0-.808-.132-1.12-.356ZM4.995 1.762a.516.516 0 1 0 1.007-.224L5.746.388A.516.516 0 0 0 4.74.612l.255 1.15ZM1.273 3.52l.994.633a.516.516 0 0 0 .555-.87l-.995-.633a.516.516 0 0 0-.554.87ZM.878 8.028l1.15-.256a.516.516 0 0 0-.223-1.008l-1.15.256a.516.516 0 1 0 .223 1.008Zm10.238-2.28a.535.535 0 0 0 .112-.012l1.15-.256a.516.516 0 1 0-.224-1.008l-1.15.256a.516.516 0 0 0 .112 1.02ZM8.772 2.713a.516.516 0 0 0 .712-.158l.633-.994a.516.516 0 0 0-.87-.554l-.633.994a.516.516 0 0 0 .158.712ZM3.07 7.017c.07.303.182.596.33.87a3.13 3.13 0 0 0 .909-.486 2.453 2.453 0 0 1-.233-.608 2.504 2.504 0 0 1 4.888-1.088c.003.013.002.026.005.038a5.42 5.42 0 0 1 1.063.25 3.497 3.497 0 0 0-.061-.512A3.535 3.535 0 1 0 3.07 7.017Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1010" viewBox="0 0 16 16">
<path d="M8.01 2.503a.5.5 0 0 1-.501-.5v-1.5a.5.5 0 0 1 1 0v1.5a.5.5 0 0 1-.5.5ZM3.766 4.255a.498.498 0 0 1-.353-.147l-1.062-1.06a.5.5 0 0 1 .707-.707L4.122 3.4a.5.5 0 0 1-.355.854v.001ZM2.004 8.493h-1.5a.5.5 0 1 1 0-1h1.5a.5.5 0 1 1 0 1Zm.691 5.303a.5.5 0 0 1-.354-.854l1.062-1.06a.5.5 0 0 1 .708.707l-1.063 1.06a.498.498 0 0 1-.353.147Zm5.301 2.201a.5.5 0 0 1-.5-.5v-1.5a.5.5 0 0 1 1 0v1.5a.5.5 0 0 1-.5.5Zm-.869-3.583a4.5 4.5 0 0 0 1.373.059V3.527a4.503 4.503 0 0 0-4.652 2.75 4.5 4.5 0 0 0 3.28 6.137Zm8.876-4.407A.506.506 0 0 0 16 7.952v.04a.5.5 0 0 0-.2-.392 2.952 2.952 0 0 0-.846-.463A2.96 2.96 0 0 0 14 7c-.629 0-1.037.364-1.304.601l-.028.025C12.37 7.891 12.222 8 12 8c-.273 0-.468-.029-.638-.085A1.967 1.967 0 0 1 10.8 7.6a.5.5 0 0 0-.6.8c.276.207.544.363.846.463.301.1.61.137.954.137.629 0 1.038-.364 1.304-.601l.028-.025C13.63 8.109 13.778 8 14 8c.273 0 .468.029.638.085.17.057.344.151.562.315a.5.5 0 0 0 .8-.392v.054a.506.506 0 0 0 .003-.055ZM12 6c.629 0 1.038-.364 1.304-.601l.028-.025C13.63 5.109 13.778 5 14 5c.273 0 .468.029.638.085.17.057.344.151.562.315a.5.5 0 0 0 .6-.8 2.952 2.952 0 0 0-.846-.463A2.96 2.96 0 0 0 14 4c-.629 0-1.037.364-1.304.601l-.028.025C12.37 4.891 12.222 5 12 5c-.273 0-.468-.029-.638-.085A1.967 1.967 0 0 1 10.8 4.6a.5.5 0 0 0-.6.8c.276.207.544.363.846.463.301.1.61.137.954.137Zm0 6c.629 0 1.038-.364 1.304-.601l.028-.025c.298-.264.446-.374.668-.374.273 0 .468.029.638.085.17.057.344.151.562.315a.5.5 0 0 0 .6-.8 2.951 2.951 0 0 0-.846-.463c-.301-.1-.61-.137-.954-.137-.629 0-1.037.364-1.304.601l-.028.025c-.298.264-.446.374-.668.374-.273 0-.468-.029-.638-.085a1.968 1.968 0 0 1-.562-.315.5.5 0 0 0-.6.8c.276.207.544.363.846.463.301.1.61.137.954.137Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1011" viewBox="0 0 16 16">
<path d="M4.9 1.5c-.9.3-1.2 1.1-1.1 1.6-.7-.8-.7-1.7-.6-3.1-2.1.8-1.6 3.2-1.7 4C1 3.5.9 2.5.9 2.5.3 2.8 0 3.6 0 4.3 0 5.9 1.3 7 2.8 7c1.5 0 2.7-1.2 2.7-2.7 0-1.1-.6-1.4-.6-2.8ZM2 9h6a1 1 0 1 0-.943-1.333.5.5 0 1 1-.943-.334A2 2 0 1 1 8 10H2a.5.5 0 0 1 0-1Z"/>
<path d="M11.079 8.375A2.5 2.5 0 0 1 16 9c0 1.397-1.24 2.5-2.5 2.5H.5a.5.5 0 0 1 0-1h13c.74 0 1.5-.688 1.5-1.5a1.5 1.5 0 0 0-2.953-.375.5.5 0 1 1-.968-.25ZM2.5 12.5A.5.5 0 0 1 3 12h8a2 2 0 1 1-1.886 2.667.5.5 0 1 1 .943-.334A1 1 0 1 0 11 13H3a.5.5 0 0 1-.5-.5Z"/>
</svg>

After

Width:  |  Height:  |  Size: 654 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1012" viewBox="0 0 16 16">
<path d="M5 10a.5.5 0 0 1 .5.5V14a2 2 0 1 1-2.665-1.887.5.5 0 0 1 .332.944A1 1 0 1 0 4.5 14v-3.5A.5.5 0 0 1 5 10Zm3 1c.276 0 .5.18.5.4v3.2c0 .22-.224.4-.5.4s-.5-.18-.5-.4v-3.2c0-.22.224-.4.5-.4Zm2.5-.5a.5.5 0 0 1 1 0V14a1 1 0 1 0 1.332-.944.5.5 0 1 1 .332-.943A2 2 0 1 1 10.5 14v-3.5ZM7.9 10a4.99 4.99 0 0 0 3.827-1.783 3 3 0 1 0 .553-5.63A4.999 4.999 0 0 0 7.9 0a4.998 4.998 0 0 0-4.359 2.549 3 3 0 1 0 .586 5.732A4.988 4.988 0 0 0 7.9 10Zm-.791-6.398c-.057-.362.17-.8.496-.997.256-.153.551-.133.806.023l.07.042a.846.846 0 0 1 .409.853L8.532 5.8H7.454L7.11 3.602ZM8.599 6.9a.6.6 0 1 1-1.2 0 .6.6 0 0 1 1.2 0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 741 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1013" viewBox="0 0 16 16">
<path d="m14.589 4-1.472.835-1.235 2.139-2.44 1.017L8.336 9.77l-3.588.905L1 15.998h15L14.589 4Zm-4.088-4a.35.35 0 0 0-.35.35v.544l-.47-.272a.35.35 0 0 0-.35.606l.47.272-.47.272a.35.35 0 0 0 .35.606l.47-.272v.544a.35.35 0 1 0 .7 0v-.544l.47.272a.35.35 0 0 0 .35-.606l-.47-.272.47-.272a.35.35 0 1 0-.35-.606l-.47.272V.35a.35.35 0 0 0-.35-.35Zm-4.35 3.35a.35.35 0 1 1 .7 0v.544l.471-.272a.35.35 0 0 1 .35.606L7.2 4.5l.471.272a.35.35 0 0 1-.35.606l-.471-.272v.544a.35.35 0 1 1-.7 0v-.544l-.47.272a.35.35 0 0 1-.35-.606L5.8 4.5l-.47-.272a.35.35 0 1 1 .35-.606l.47.272V3.35Z"/>
<path d="M10.151 4.35a.35.35 0 1 1 .7 0v.544l.47-.272a.35.35 0 0 1 .35.606l-.47.272.47.272a.35.35 0 1 1-.35.606l-.47-.272v.544a.35.35 0 1 1-.7 0v-.544l-.47.272a.35.35 0 0 1-.35-.606L9.8 5.5l-.47-.272a.35.35 0 0 1 .35-.606l.47.272V4.35Zm-5 3a.35.35 0 1 1 .7 0v.544l.471-.272a.35.35 0 0 1 .35.606L6.2 8.5l.471.272a.35.35 0 0 1-.35.606l-.471-.272v.544a.35.35 0 1 1-.7 0v-.544l-.47.272a.35.35 0 0 1-.35-.606L4.8 8.5l-.47-.272a.35.35 0 0 1 .35-.606l.47.272V7.35ZM2.501 9a.35.35 0 0 0-.35.35v.544l-.47-.272a.35.35 0 0 0-.35.606l.47.272-.47.272a.35.35 0 1 0 .35.606l.47-.272v.544a.35.35 0 1 0 .7 0v-.544l.471.272a.35.35 0 1 0 .35-.606L3.2 10.5l.47-.272a.35.35 0 1 0-.349-.606l-.471.272V9.35A.35.35 0 0 0 2.5 9Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1014" viewBox="0 0 16 16">
<path d="M7.058 0h5.403c.264 0 .422.311.279.548l-2.975 3.85a.173.173 0 0 0 .05.232.147.147 0 0 0 .08.024h3.13c.416 0 .63.53.345.855L4.12 16l2.203-8.082a.177.177 0 0 0-.025-.146.159.159 0 0 0-.055-.048.148.148 0 0 0-.07-.018H2.976a.451.451 0 0 1-.236-.067.49.49 0 0 1-.173-.183.532.532 0 0 1-.006-.503L6.56.311C6.66.119 6.85 0 7.057 0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 466 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1015" viewBox="0 0 16 16">
<path d="m1 9 1 1.5L1 13l-1-2.5L1 9Zm15 1.5L15 9l-1 1.5 1 2.5 1-2.5ZM4.5 11l1 1.5-1 2.5-1-2.5 1-1.5Zm8 1.5-1-1.5-1 1.5 1 2.5 1-2.5Zm-3.5 1L8 12l-1 1.5L8 16l1-2.5ZM7.9 10a4.99 4.99 0 0 0 3.827-1.783 3 3 0 1 0 .553-5.63A4.999 4.999 0 0 0 7.9 0a4.998 4.998 0 0 0-4.359 2.549 3 3 0 1 0 .586 5.732A4.988 4.988 0 0 0 7.9 10Zm-.791-6.398c-.057-.362.17-.8.496-.997.256-.153.551-.133.806.023l.07.042a.846.846 0 0 1 .409.853L8.532 5.8H7.454L7.11 3.602ZM8.599 6.9a.6.6 0 1 1-1.2 0 .6.6 0 0 1 1.2 0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 619 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1016" viewBox="0 0 16 16">
<path d="M0 10.75a.75.75 0 0 1 1.5 0v3.75h13v-3.75a.75.75 0 0 1 1.5 0v4.5a.75.75 0 0 1-.75.75H.75a.75.75 0 0 1-.75-.75v-4.5Z"/>
<path d="M8.406.406a.406.406 0 1 0-.812 0v1.05L7.069.932a.406.406 0 1 0-.575.575l1.1 1.1v1.457l.001.033a2.44 2.44 0 0 0-1.474.851.415.415 0 0 0-.029-.018l-1.261-.728L4.428 2.7a.406.406 0 1 0-.785.21l.193.718-.91-.526a.406.406 0 0 0-.406.704l.91.525-.718.192a.406.406 0 0 0 .21.785l1.502-.402 1.262.728.03.015a2.432 2.432 0 0 0 0 1.704.415.415 0 0 0-.03.015l-1.262.728-1.502-.402a.406.406 0 1 0-.21.785l.717.192-.91.525a.406.406 0 0 0 .407.704l.91-.526-.193.718a.406.406 0 1 0 .785.21L4.831 8.8l1.261-.728a.416.416 0 0 0 .029-.018 2.44 2.44 0 0 0 1.474.851.411.411 0 0 0-.001.034v1.456l-1.1 1.1a.406.406 0 1 0 .575.575l.525-.526v1.05a.406.406 0 0 0 .812 0v-1.05l.526.526a.406.406 0 1 0 .574-.575l-1.1-1.1V8.937l-.001-.033a2.435 2.435 0 0 0 1.474-.852l.029.019 1.261.728.403 1.502a.406.406 0 1 0 .785-.21l-.192-.718.91.526a.406.406 0 1 0 .405-.704l-.91-.525.718-.192a.406.406 0 0 0-.21-.785l-1.502.402-1.262-.728a.422.422 0 0 0-.03-.016 2.433 2.433 0 0 0 0-1.702l.03-.016 1.262-.728 1.502.402a.406.406 0 0 0 .21-.785l-.717-.192.91-.525a.406.406 0 0 0-.407-.704l-.91.526.193-.718a.406.406 0 1 0-.785-.21L11.17 4.2l-1.262.728a.414.414 0 0 0-.029.019 2.435 2.435 0 0 0-1.474-.852l.001-.034V2.607l1.1-1.1a.406.406 0 0 0-.574-.575l-.526.526V.407Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1017" viewBox="0 0 16 16">
<path d="M12.227 8.13a.263.263 0 0 0-.454 0l-3.738 6.482a.26.26 0 0 0 .227.388h7.476a.26.26 0 0 0 .227-.388L12.227 8.13Zm-.85 2.144c-.032-.282.256-.524.623-.524s.655.242.623.524l-.282 2.476h-.682l-.282-2.476Zm1.127 3.476a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0ZM.75 2a.75.75 0 0 0 0 1.5h14.5a.75.75 0 0 0 0-1.5H.75Zm7 3.5a.75.75 0 0 0 0 1.5h7.5a.75.75 0 0 0 0-1.5h-7.5ZM0 9.75A.75.75 0 0 1 .75 9h7.5a.75.75 0 0 1 0 1.5H.75A.75.75 0 0 1 0 9.75ZM.75 5.5a.75.75 0 0 0 0 1.5h4.5a.75.75 0 1 0 0-1.5H.75Z"/>
</svg>

After

Width:  |  Height:  |  Size: 620 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1018" viewBox="0 0 16 16">
<path d="M7.86 12.447a2.807 2.807 0 1 0 0-5.614 2.807 2.807 0 0 0 0 5.614Zm0-6.771c.973 0 1.636.475 1.833.628.013-.169.026-.382.027-.628.015-2.353-.956-5.15-1.856-5.15-.678 0-1.849 2.306-1.849 5.15 0 .211.016.412.027.617a3.018 3.018 0 0 1 1.818-.617Zm-3.332 6.1a3.022 3.022 0 0 1-.372-1.901 8.77 8.77 0 0 0-.558.29C1.553 11.328-.384 13.567.066 14.347c.339.587 2.921.449 5.384-.973.183-.106.349-.22.52-.332a3.016 3.016 0 0 1-1.442-1.266Zm7.871-1.762a9.015 9.015 0 0 0-.547-.285 3.021 3.021 0 0 1-.375 1.883 3.013 3.013 0 0 1-1.46 1.273c.175.114.344.23.53.338 2.463 1.422 5.046 1.559 5.388.967.45-.779-1.492-3.015-3.536-4.176Z"/>
</svg>

After

Width:  |  Height:  |  Size: 756 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1019" viewBox="0 0 16 16">
<path d="M12.543 4.487c-1.581 0-3.876 1.712-4.57 2.888C7.282 6.2 4.987 4.487 3.406 4.487a3.486 3.486 0 0 0 0 6.97c1.58 0 3.876-1.75 4.569-2.906.693 1.156 2.988 2.906 4.569 2.906a3.486 3.486 0 0 0 0-6.97h-.001Z"/>
</svg>

After

Width:  |  Height:  |  Size: 341 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-102-fill" viewBox="0 0 16 16">
<path d="M13.268 15.162a.224.224 0 0 0-.233.042A2.99 2.99 0 0 1 11 16a2.99 2.99 0 0 1-2.035-.796.224.224 0 0 0-.233-.042 2 2 0 1 1-.383-3.832.224.224 0 0 0 .22-.087A2.996 2.996 0 0 1 11 10c1 0 1.887.49 2.432 1.243.05.069.136.102.22.087a2 2 0 1 1-.383 3.832ZM11 15.25c.752 0 1.418-.37 1.827-.936.086-.12.273-.134.388-.041a1.25 1.25 0 1 0 .209-2.082c-.132.068-.312.017-.373-.118a2.25 2.25 0 0 0-4.102 0c-.06.135-.241.186-.372.118a1.25 1.25 0 1 0 .209 2.082c.114-.093.301-.079.387.04A2.247 2.247 0 0 0 11 15.25Zm-7.6-3.373.002-.002a.5.5 0 1 1 .707.707l-1.065 1.064a.5.5 0 1 1-.707-.707l.003-.003h.001l1.057-1.059H3.4Zm4.957-9.523A.5.5 0 0 1 7.503 2V.5a.5.5 0 0 1 1 0V2a.5.5 0 0 1-.146.354ZM3.76 4.245a.5.5 0 0 1-.352-.14L2.347 3.044a.5.5 0 0 1 .708-.706l1.06 1.06a.5.5 0 0 1-.355.847ZM2.354 7.636A.5.5 0 0 1 2 8.49H.5a.5.5 0 0 1 0-1H2a.5.5 0 0 1 .354.146Zm11.286.721a.5.5 0 0 1 .353-.854h1.5a.5.5 0 0 1 0 1h-1.5a.5.5 0 0 1-.354-.146Zm-1.207-4.134a.498.498 0 0 1-.47-.046.5.5 0 0 1-.075-.77l1.061-1.06a.5.5 0 0 1 .706.707l-1.06 1.061a.498.498 0 0 1-.162.108Z"/>
<path d="M9.975 9.166a3.846 3.846 0 0 0-1.961 1.244v.001a2.923 2.923 0 0 0-2.381 1.397 4.5 4.5 0 1 1 6.868-3.812 4.453 4.453 0 0 1-.207 1.282 3.846 3.846 0 0 0-2.32-.112Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-102" viewBox="0 0 16 16">
<path d="M13.268 15.162a.224.224 0 0 0-.233.042A2.99 2.99 0 0 1 11 16a2.99 2.99 0 0 1-2.035-.796.224.224 0 0 0-.233-.042 2 2 0 1 1-.383-3.832.224.224 0 0 0 .22-.087A2.996 2.996 0 0 1 11 10c1 0 1.887.49 2.432 1.243.05.069.136.102.22.087a2 2 0 1 1-.383 3.832ZM11 15.25c.752 0 1.418-.37 1.827-.936.086-.12.273-.134.388-.041a1.25 1.25 0 1 0 .209-2.082c-.132.068-.312.017-.373-.118a2.25 2.25 0 0 0-4.102 0c-.06.135-.241.186-.372.118a1.25 1.25 0 1 0 .209 2.082c.114-.093.301-.079.387.04A2.247 2.247 0 0 0 11 15.25ZM7.655 2.357a.5.5 0 0 0 .854-.353v-1.5a.5.5 0 1 0-1 0v1.5a.5.5 0 0 0 .146.353Zm-4.08 1.861c.06.026.126.039.191.039l.001-.001a.5.5 0 0 0 .355-.855l-1.064-1.06a.5.5 0 0 0-.707.708l1.062 1.06a.498.498 0 0 0 .162.11ZM.503 8.496h1.5a.5.5 0 1 0 0-1h-1.5a.5.5 0 0 0 0 1Zm1.914 5.221a.501.501 0 0 0 .631-.063l1.063-1.06a.5.5 0 0 0-.708-.707l-1.062 1.06a.5.5 0 0 0 .076.77ZM12.393 9a4.5 4.5 0 1 0-7.033 2.64l.718-.718A3.501 3.501 0 0 1 4.505 8a3.504 3.504 0 0 1 3.5-3.5A3.5 3.5 0 0 1 11.359 9h1.034Zm1.609-.49h1.5a.5.5 0 1 0 0-1h-1.5a.5.5 0 1 0 0 1Zm-2.031-4.327a.5.5 0 0 0 .633-.063l1.06-1.06a.5.5 0 1 0-.708-.708l-1.06 1.06a.5.5 0 0 0 .075.77Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1020" viewBox="0 0 16 16">
<path d="M2 3h6a1 1 0 1 0-.943-1.333.5.5 0 1 1-.943-.334A2 2 0 1 1 8 4H2a.5.5 0 0 1 0-1Z"/>
<path d="M11.079 2.375A2.5 2.5 0 0 1 16 3c0 1.397-1.24 2.5-2.5 2.5H.5a.5.5 0 0 1 0-1h13c.74 0 1.5-.688 1.5-1.5a1.5 1.5 0 0 0-2.953-.375.5.5 0 1 1-.968-.25ZM2.5 6.5A.5.5 0 0 1 3 6h8a2 2 0 1 1-1.886 2.667.5.5 0 1 1 .943-.334A1 1 0 1 0 11 7H3a.5.5 0 0 1-.5-.5Zm2.96 6.126c-.054 0-.089-.05-.065-.093l.792-1.438C6.21 11.05 6.176 11 6.122 11H4.544a.147.147 0 0 0-.076.02.158.158 0 0 0-.058.057l-1.397 2.637c-.042.079.022.17.118.17h1.42c.05 0 .084.043.069.086l-.739 1.943c-.027.07.072.118.124.063l2.978-3.243c.04-.042.006-.107-.055-.107H5.46Zm4.698 2.886a1.666 1.666 0 0 1-.488-1.179c0-.833.926-2.325 1.667-3.333.74 1.008 1.666 2.5 1.666 3.333a1.666 1.666 0 0 1-2.845 1.179Z"/>
</svg>

After

Width:  |  Height:  |  Size: 893 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1021" viewBox="0 0 16 16">
<path d="M16 4 6.5 9l3.5 3-6 4h9l2.5-5L11 9l5-3V4Zm-1.45 1.878-.1-.173.5-.291.1.172-.5.292Zm-2 1.167-.1-.173 1-.583.1.172-1 .584Zm-2 1.166-.1-.172 1-.584.1.173-1 .583Zm-1 .584-.376.22.265.22-.128.154-.485-.403.624-.364.1.173Zm1.389 1.69-.128.154-.75-.625.128-.153.75.625Zm1.196.998-.366.513-.163-.117.259-.362-.304-.253.128-.153.446.372Zm-1.616 2.263-.163-.117.625-.875.163.117-.625.875Zm-.938 1.312-.162-.116.312-.438.163.117-.313.437ZM3.535 2.467a.467.467 0 0 1 .933 0v.725l.628-.363a.467.467 0 1 1 .467.808L4.935 4l.628.363a.467.467 0 0 1-.467.808l-.628-.363v.725a.467.467 0 1 1-.933 0v-.725l-.628.363a.467.467 0 0 1-.467-.808L3.068 4l-.628-.363a.467.467 0 1 1 .467-.808l.628.363v-.725Z"/>
<path d="M7.324 1.872 4.175.048a.347.347 0 0 0-.35 0L.675 1.872a.352.352 0 0 0-.175.304v3.648c0 .126.067.242.175.305l3.15 1.824a.344.344 0 0 0 .35 0l3.149-1.824a.351.351 0 0 0 .176-.305V2.176a.353.353 0 0 0-.176-.304Zm-.526 3.75L4 7.241l-2.798-1.62V2.38L4 .758 6.798 2.38v3.242Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1022" viewBox="0 0 16 16">
<path d="M2.121.352C1.873.01 1.337-.103.924.104.51.31.377.756.624 1.099l3.09 4.281a.21.21 0 0 1-.017.27l-2.63 2.915C.641 9.038.541 9.66.805 10.21L2.628 14H1a1 1 0 1 0 0 2h14a1 1 0 1 0 0-2h-.492l-2.082-4.33a.209.209 0 0 1 .037-.234l2.63-2.915c.496-.551.543-1.297.117-1.888L12.12.353c-.248-.344-.784-.456-1.197-.25-.413.207-.547.653-.3.996l3.089 4.281a.21.21 0 0 1-.017.27l-2.629 2.915c-.426.473-.526 1.096-.262 1.645L12.63 14H9.51L7.425 9.67a.209.209 0 0 1 .037-.234l2.63-2.915c.496-.551.543-1.297.117-1.888L7.12.353c-.248-.344-.784-.456-1.197-.25-.413.207-.547.653-.3.996L8.714 5.38a.21.21 0 0 1-.017.27l-2.63 2.915c-.426.473-.526 1.096-.262 1.645L7.628 14h-3.12L2.426 9.67a.209.209 0 0 1 .037-.234l2.63-2.915c.497-.551.543-1.297.117-1.888L2.12.353Z"/>
</svg>

After

Width:  |  Height:  |  Size: 881 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1023" viewBox="0 0 16 16">
<path d="M12.685 1.923S7.703.45 6.028.014A.496.496 0 0 0 5.43.37L3.015 9.376a.506.506 0 0 0 .34.605.507.507 0 0 0 .625-.347l1.044-3.893 7.734-3.01a.434.434 0 0 0-.073-.808ZM.777 11.644a.5.5 0 1 0-.554.832l1.017.678a1.5 1.5 0 0 0 1.503.094l1.033-.517a.5.5 0 0 1 .448 0l1.105.553a1.5 1.5 0 0 0 1.342 0l1.105-.553a.5.5 0 0 1 .448 0l1.105.553a1.5 1.5 0 0 0 1.342 0l1.105-.553a.5.5 0 0 1 .448 0l1.033.517a1.5 1.5 0 0 0 1.503-.094l1.017-.678a.5.5 0 1 0-.554-.832l-1.017.678a.5.5 0 0 1-.501.031l-1.034-.517a1.5 1.5 0 0 0-1.342 0l-1.105.553a.5.5 0 0 1-.448 0l-1.105-.553a1.5 1.5 0 0 0-1.342 0l-1.105.553a.5.5 0 0 1-.448 0l-1.105-.553a1.5 1.5 0 0 0-1.342 0l-1.034.517a.5.5 0 0 1-.5-.03l-1.018-.679Zm0 2.5a.5.5 0 1 0-.554.832l1.017.678a1.5 1.5 0 0 0 1.503.094l1.033-.517a.5.5 0 0 1 .448 0l1.105.553a1.5 1.5 0 0 0 1.342 0l1.105-.553a.5.5 0 0 1 .448 0l1.105.553a1.5 1.5 0 0 0 1.342 0l1.105-.553a.5.5 0 0 1 .448 0l1.033.517a1.5 1.5 0 0 0 1.503-.094l1.017-.678a.5.5 0 1 0-.554-.832l-1.017.678a.5.5 0 0 1-.501.031l-1.034-.517a1.5 1.5 0 0 0-1.342 0l-1.105.553a.5.5 0 0 1-.448 0l-1.105-.553a1.5 1.5 0 0 0-1.342 0l-1.105.553a.5.5 0 0 1-.448 0l-1.105-.553a1.5 1.5 0 0 0-1.342 0l-1.034.517a.5.5 0 0 1-.5-.03l-1.018-.679Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1024" viewBox="0 0 16 16">
<path d="M11.306 1.326a.281.281 0 0 0 .48-.199V.283a.281.281 0 1 0-.562 0v.844c0 .075.03.146.082.2ZM9.011 2.373a.281.281 0 0 0 .307-.46l-.598-.596a.281.281 0 0 0-.398.398l.598.597a.28.28 0 0 0 .091.06ZM7.283 4.78h.844a.281.281 0 0 0 0-.564h-.844a.281.281 0 0 0 0 .563ZM8.36 7.716a.281.281 0 0 0 .355-.036l.597-.596a.281.281 0 0 0-.398-.398l-.597.597a.281.281 0 0 0 .042.433Zm2.939 1.204a.281.281 0 0 0 .48-.2v-.844a.281.281 0 1 0-.563 0v.844c0 .075.03.147.083.2Zm3.075-1.173a.28.28 0 0 0 .367-.152.281.281 0 0 0-.061-.307l-.597-.596a.281.281 0 1 0-.397.398l.597.596a.28.28 0 0 0 .09.061Zm.502-2.96h.844a.282.282 0 0 0 0-.563h-.844a.281.281 0 1 0 0 .563Zm-1.142-2.434a.282.282 0 0 0 .356-.036l.596-.596a.281.281 0 0 0-.398-.398l-.597.596a.281.281 0 0 0 .043.434Zm-3.637.042a2.532 2.532 0 1 1 2.812 4.21 2.532 2.532 0 0 1-2.813-4.21Zm2.5.468a1.969 1.969 0 1 0-2.188 3.274 1.969 1.969 0 0 0 2.188-3.274ZM5 6.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm-.939 2.251L2.904 8.52a1 1 0 0 0-1.187.848l-.708 5.31A1 1 0 0 0 2 15.81h1.055a1 1 0 0 0 .555-.168l2.651-1.767a.2.2 0 0 1 .2-.013l3.791 1.896a.5.5 0 0 0 .224.052h.23a.5.5 0 0 0 .278-.916l-4.205-2.803a.8.8 0 0 0-.855-.02l-1.695 1.017a.2.2 0 0 1-.3-.205l.365-2.19a.2.2 0 0 1 .339-.108l.932.932a1 1 0 0 0 .707.293h2.086a.5.5 0 0 0 0-1H6.441a.2.2 0 0 1-.142-.058L4.572 9.024a1 1 0 0 0-.51-.273Zm.633-7.132a.218.218 0 0 0-.29-.096.213.213 0 0 0-.098.287l.21.415c.092.182.056.4-.09.544l-.16.159a.892.892 0 0 0-.17 1.038l.21.415a.218.218 0 0 0 .29.096.213.213 0 0 0 .098-.287l-.21-.415a.467.467 0 0 1 .09-.544l.16-.159a.892.892 0 0 0 .17-1.038l-.21-.415Zm-2.291-.096a.218.218 0 0 1 .29.096l.21.415a.892.892 0 0 1-.17 1.038l-.16.159a.467.467 0 0 0-.09.544l.21.415a.213.213 0 0 1-.096.287.218.218 0 0 1-.29-.096l-.21-.415a.892.892 0 0 1 .17-1.038l.16-.159a.467.467 0 0 0 .09-.544l-.21-.415a.213.213 0 0 1 .096-.287Zm1 0a.218.218 0 0 1 .29.096l.21.415a.892.892 0 0 1-.17 1.038l-.16.159a.467.467 0 0 0-.09.544l.21.415a.213.213 0 0 1-.096.287.218.218 0 0 1-.29-.096l-.21-.415a.892.892 0 0 1 .17-1.038l.16-.159a.467.467 0 0 0 .09-.544l-.21-.415a.213.213 0 0 1 .096-.287Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1025" viewBox="0 0 16 16">
<path d="M15.4 3c-.9.3-1.2 1.1-1.1 1.6-.7-.8-.7-1.7-.6-3.1-2.1.8-1.6 3.2-1.7 4-.5-.5-.6-1.5-.6-1.5-.6.3-.9 1.1-.9 1.8 0 1.6 1.3 2.7 2.8 2.7 1.5 0 2.7-1.2 2.7-2.7 0-1.1-.6-1.4-.6-2.8Z"/>
<path d="M12.3 9.3c-1.6-.4-2.8-1.8-2.8-3.6 0-.6.2-1.3.6-1.9L7.26.203a.2.2 0 0 0-.316.003l-3.7 4.873a.2.2 0 0 0 .159.321H4.8L2.056 8.876a.2.2 0 0 0 .157.324H4.2L.298 13.567a.2.2 0 0 0 .149.333H6v1.9c0 .11.09.2.2.2H8a.2.2 0 0 0 .2-.2v-1.9h5.547a.2.2 0 0 0 .147-.335L10 9.3h2.3Z"/>
</svg>

After

Width:  |  Height:  |  Size: 595 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1026" viewBox="0 0 16 16">
<path d="M15.454 5.05c-.818.27-1.09.99-1 1.44-.636-.72-.636-1.53-.545-2.79-1.909.72-1.455 2.88-1.545 3.6-.455-.45-.546-1.35-.546-1.35-.545.27-.818.99-.818 1.62C11 9.01 12.182 10 13.546 10A2.432 2.432 0 0 0 16 7.57c0-.99-.546-1.26-.546-2.52Z"/>
<path d="M14.7 10.8v-.2c-.4.2-.8.2-1.3.2-.9 0-1.7-.3-2.3-.8-.3.4-.6.9-.8 1.4-.4 1.1-.7 2-.8 2.8-.1-1.6-.3-4.2-1.1-6.6C7 3.1 2.2 0 2.2 0s2.6 6.5 2.9 9.8c.3 2.2-.1 4.3-.3 5.4-.1-.8-.3-2-.9-3.3C2.8 9.3 0 7.6 0 7.6s1.4 3.2 1.7 5.1c.2 1.8-.4 3.3-.4 3.3h13.5c.1 0-1-1.5-.1-5.2Z"/>
</svg>

After

Width:  |  Height:  |  Size: 649 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1027" viewBox="0 0 16 16">
<path d="M7.07 4.933a.933.933 0 0 1 1.866 0v1.45l1.256-.725a.933.933 0 1 1 .933 1.617L9.87 8l1.256.725a.933.933 0 1 1-.933 1.617l-1.256-.725v1.45a.933.933 0 0 1-1.867 0v-1.45l-1.256.725a.933.933 0 1 1-.933-1.617L6.136 8 4.88 7.275a.933.933 0 0 1 .933-1.617l1.256.725v-1.45Z"/>
<path d="M14.649 3.743 8.35.095a.693.693 0 0 0-.701 0L1.351 3.743a.705.705 0 0 0-.351.61v7.295c0 .251.134.483.351.61l6.298 3.647a.688.688 0 0 0 .701 0l6.298-3.648a.702.702 0 0 0 .352-.609V4.352a.705.705 0 0 0-.351-.609Zm-1.052 7.5L8 14.483l-5.597-3.242V4.76L8 1.516l5.597 3.242v6.484Z"/>
</svg>

After

Width:  |  Height:  |  Size: 695 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1028" viewBox="0 0 16 16">
<path d="M15.25 4.13a2.08 2.08 0 0 0 .486-1.328 2.102 2.102 0 0 0-3.284-1.734A7.305 7.305 0 0 0 8.652 0 7.35 7.35 0 0 0 1.3 7.35c0 .879.162 1.717.445 2.498A3.14 3.14 0 0 0 0 12.654a3.15 3.15 0 0 0 3.15 3.15 3.135 3.135 0 0 0 2.748-1.64 7.327 7.327 0 0 0 5.564-.021A7.35 7.35 0 0 0 16 7.35a7.3 7.3 0 0 0-.75-3.222ZM3.15 14.333a1.682 1.682 0 0 1-1.68-1.68c0-.927.754-1.68 1.68-1.68a1.681 1.681 0 0 1 0 3.36Zm5.5-1.101c-.812 0-1.615-.17-2.357-.497 0-.027.008-.054.008-.081a3.15 3.15 0 0 0-3.117-3.147 5.838 5.838 0 0 1-.415-2.155 5.888 5.888 0 0 1 5.881-5.88 5.84 5.84 0 0 1 2.958.806 2.08 2.08 0 0 0-.074.524c0 1.16.94 2.1 2.1 2.1.116 0 .228-.015.34-.033a5.83 5.83 0 0 1 .557 2.483 5.889 5.889 0 0 1-5.88 5.88Z"/>
<path d="M8.584 4.829a2.688 2.688 0 1 0 0 5.376 2.688 2.688 0 0 0 0-5.376Z"/>
</svg>

After

Width:  |  Height:  |  Size: 920 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1029" viewBox="0 0 16 16">
<path d="M10.649.631a2 2 0 0 0-3.275.533 1.5 1.5 0 0 0-2.098.84 1 1 0 0 0-1.028.665 1 1 0 1 0 .388 1.162.995.995 0 0 0 1.22-.084 1.493 1.493 0 0 0 1.388.148 1.5 1.5 0 0 0 2.362.793 2.5 2.5 0 0 0 4.149-.719A1.002 1.002 0 0 0 15 3a1 1 0 0 0-.674-.946 1 1 0 0 0-1.36-1.042 2.497 2.497 0 0 0-2.318-.381ZM2.234 6.096a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5ZM3 2a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1Z"/>
<path d="M5 1.5a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1Zm-.5 4a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1Zm-3 1h3v1h-3v-1ZM16 16H0v-1h1.034l.5-7h2.932l.5 7H16v1ZM3.963 15l-.143-2H2.18l-.143 2h1.926Zm-.214-3-.067-.933H2.318L2.251 12H3.75Zm-.138-1.933L3.534 9H2.466l-.077 1.067h1.222Z"/>
<path d="M10.773 7.63a.263.263 0 0 1 .454 0l3.738 6.482a.26.26 0 0 1-.227.388H7.262a.26.26 0 0 1-.227-.388l3.738-6.481ZM11 9.25c-.367 0-.655.242-.623.524l.282 2.476h.682l.282-2.476c.032-.282-.256-.524-.623-.524Zm.004 4.5a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-103-fill" viewBox="0 0 16 16">
<path d="M13.583 11.078c-.199.005-.396.03-.59.075A4.249 4.249 0 0 0 10.128 10a4.279 4.279 0 0 0-1.642.34 4.05 4.05 0 0 0-1.354.935 2.953 2.953 0 0 0-.579-.056 3.096 3.096 0 0 0-1.364.297c-.423.2-.787.494-1.063.856a1.707 1.707 0 0 0-.44-.094 1.769 1.769 0 0 0-.75.163 1.66 1.66 0 0 0-.595.462 1.53 1.53 0 0 0-.312.662 1.49 1.49 0 0 0 .037.723c.072.235.201.451.378.631.176.18.394.32.638.406a1.778 1.778 0 0 0 1.493-.14c.558.443 1.268.68 1.998.665.527 0 1.046-.126 1.508-.365a4.267 4.267 0 0 0 2.222.511 4.23 4.23 0 0 0 2.162-.699c.345.184.733.28 1.13.282a2.49 2.49 0 0 0 1.69-.652c.451-.418.708-.986.716-1.58a2.297 2.297 0 0 0-.735-1.579 2.597 2.597 0 0 0-1.682-.69Zm0 3.591a1.437 1.437 0 0 1-.66-.169 1.051 1.051 0 0 0-1.058.066 3.036 3.036 0 0 1-1.738.525 3.148 3.148 0 0 1-1.548-.384 1.049 1.049 0 0 0-.509-.132.986.986 0 0 0-.48.122c-.306.156-.65.24-.998.244a2.142 2.142 0 0 1-1.338-.44 1.038 1.038 0 0 0-.65-.226c-.19 0-.599.235-.888.235a.694.694 0 0 1-.427-.21.616.616 0 0 1-.17-.423c0-.156.06-.306.17-.423a.694.694 0 0 1 .427-.21c.05 0 .4.056.489.056a.884.884 0 0 0 .451-.096.816.816 0 0 0 .328-.307c.188-.232.431-.42.71-.549.28-.129.587-.194.898-.192.13 0 .49.066.61.066a1.05 1.05 0 0 0 .39-.095.993.993 0 0 0 .318-.233 3.13 3.13 0 0 1 1.017-.683c.384-.161.8-.248 1.22-.255.403.005.801.085 1.171.235.37.15.706.366.987.637a1 1 0 0 0 .329.21c.123.048.256.072.39.072.1 0 .46-.075.56-.075.362.027.704.173.963.413.26.24.42.559.454.9a1.293 1.293 0 0 1-.419.934 1.467 1.467 0 0 1-.999.387ZM4.79 10.68a3.56 3.56 0 0 1 1.91-.55h.22a4.56 4.56 0 0 1 3.13-1.22 4.91 4.91 0 0 1 2 .46 4.29 4.29 0 0 0 .33-1.64 4.38 4.38 0 0 0-8.76 0 4.33 4.33 0 0 0 1.17 2.95ZM8 2.52a.5.5 0 0 0 .5-.5V.52a.5.5 0 1 0-1-.02V2a.5.5 0 0 0 .5.52Zm-4.58 1.6a.48.48 0 0 0 .7 0 .48.48 0 0 0 0-.7L3.06 2.36a.495.495 0 1 0-.7.7l1.06 1.06ZM2.51 8a.5.5 0 0 0-.5-.5H.51a.5.5 0 0 0 0 1H2a.5.5 0 0 0 .51-.5Zm10.95 0a.5.5 0 0 0 .5.5h1.5a.5.5 0 1 0 .04-1H14a.5.5 0 0 0-.54.5Zm-1.21-3.72a.47.47 0 0 0 .35-.15l1.06-1.06a.495.495 0 1 0-.7-.7L11.9 3.43a.48.48 0 0 0 0 .7.47.47 0 0 0 .35.15Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-103" viewBox="0 0 16 16">
<path d="M13.583 11.078c-.199.005-.396.03-.59.075A4.249 4.249 0 0 0 10.128 10a4.279 4.279 0 0 0-1.642.34 4.05 4.05 0 0 0-1.354.935 2.953 2.953 0 0 0-.579-.056 3.096 3.096 0 0 0-1.364.297c-.423.2-.787.494-1.063.856a1.707 1.707 0 0 0-.44-.094 1.769 1.769 0 0 0-.75.163 1.66 1.66 0 0 0-.595.462 1.53 1.53 0 0 0-.312.662 1.49 1.49 0 0 0 .037.723c.072.235.201.451.378.631.176.18.394.32.638.406a1.778 1.778 0 0 0 1.493-.14c.558.443 1.268.68 1.998.665.527 0 1.046-.126 1.508-.365a4.267 4.267 0 0 0 2.222.511 4.23 4.23 0 0 0 2.162-.699c.345.184.733.28 1.13.282a2.49 2.49 0 0 0 1.69-.652c.451-.418.708-.986.716-1.58a2.297 2.297 0 0 0-.735-1.579 2.597 2.597 0 0 0-1.682-.69Zm0 3.591a1.437 1.437 0 0 1-.66-.169 1.051 1.051 0 0 0-1.058.066 3.036 3.036 0 0 1-1.738.525 3.148 3.148 0 0 1-1.548-.384 1.049 1.049 0 0 0-.509-.132.986.986 0 0 0-.48.122c-.306.156-.65.24-.998.244a2.142 2.142 0 0 1-1.338-.44 1.038 1.038 0 0 0-.65-.226c-.19 0-.599.235-.888.235a.694.694 0 0 1-.427-.21.616.616 0 0 1-.17-.423c0-.156.06-.306.17-.423a.694.694 0 0 1 .427-.21c.05 0 .4.056.489.056a.884.884 0 0 0 .451-.096.816.816 0 0 0 .328-.307c.188-.232.431-.42.71-.549.28-.129.587-.194.898-.192.13 0 .49.066.61.066a1.05 1.05 0 0 0 .39-.095.993.993 0 0 0 .318-.233 3.13 3.13 0 0 1 1.017-.683c.384-.161.8-.248 1.22-.255.403.005.801.085 1.171.235.37.15.706.366.987.637a1 1 0 0 0 .329.21c.123.048.256.072.39.072.1 0 .46-.075.56-.075.362.027.704.173.963.413.26.24.42.559.454.9a1.293 1.293 0 0 1-.419.934 1.467 1.467 0 0 1-.999.387ZM8.354 2.374A.5.5 0 0 1 7.5 2V.5a.5.5 0 1 1 1 .02v1.5a.5.5 0 0 1-.146.354ZM3.58 4.232a.48.48 0 0 1-.16-.112L2.36 3.06a.495.495 0 0 1 .7-.7l1.06 1.06a.48.48 0 0 1 0 .7.48.48 0 0 1-.54.112ZM2.364 7.646A.5.5 0 0 1 2 8.5H.51a.5.5 0 0 1 0-1h1.5a.5.5 0 0 1 .354.146Zm11.242.708A.5.5 0 0 1 14 7.5h1.5a.5.5 0 1 1-.04 1h-1.5a.5.5 0 0 1-.354-.146ZM12.44 4.242a.47.47 0 0 1-.19.038.47.47 0 0 1-.35-.15.48.48 0 0 1 0-.7l1.06-1.06a.495.495 0 0 1 .7.7L12.6 4.13a.471.471 0 0 1-.16.112ZM5.86 10.42a3.57 3.57 0 0 0-1 .41 4.33 4.33 0 0 1-1.12-2.95 4.38 4.38 0 1 1 8.44 1.64 4.179 4.179 0 0 0-.92-.32 3.41 3.41 0 1 0-5.4 1.22Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1030" viewBox="0 0 16 16">
<path d="M11.5 7a.5.5 0 0 0-.5.5v2.063a2 2 0 1 0 1 0V7.5a.5.5 0 0 0-.5-.5Z"/>
<path d="m10.2 8.399-.532.356a3.3 3.3 0 1 0 3.665 0l-.533-.356V2.5a1.3 1.3 0 1 0-2.6 0v5.899ZM9 2.5a2.5 2.5 0 0 1 5 0v5.258a4.5 4.5 0 1 1-5 0V2.5Zm-6.349-.65a.35.35 0 1 1 .7 0v.544l.471-.272a.35.35 0 0 1 .35.606L3.7 3l.47.272a.35.35 0 0 1-.35.606l-.47-.272v.544a.35.35 0 1 1-.7 0v-.544l-.47.272a.35.35 0 0 1-.35-.606L2.3 3l-.47-.272a.35.35 0 0 1 .35-.606l.47.272V1.85Z"/>
<path d="M5.493 1.404 3.131.036a.26.26 0 0 0-.263 0L.507 1.404a.264.264 0 0 0-.132.228v2.736c0 .094.05.181.132.228l2.361 1.368a.258.258 0 0 0 .263 0l2.362-1.368a.264.264 0 0 0 .132-.228V1.632a.264.264 0 0 0-.132-.228ZM5.1 4.216 3 5.432.901 4.216V1.785L3 .569l2.099 1.215v2.432Zm.393 4.188L3.131 7.036a.26.26 0 0 0-.263 0L.507 8.404a.264.264 0 0 0-.132.228v2.736c0 .094.05.181.132.228l2.361 1.368a.258.258 0 0 0 .263 0l2.362-1.368a.264.264 0 0 0 .132-.228V8.632a.264.264 0 0 0-.132-.228ZM5.1 11.216 3 12.432.901 11.216V8.785L3 7.569l2.099 1.215v2.432Z"/>
<path d="M2.293 11.207A1 1 0 0 1 2 10.5c0-.5.555-1.395 1-2 .445.605 1 1.5 1 2a1 1 0 0 1-1.707.707Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1031" viewBox="0 0 16 16">
<path d="m1.959 8.648 3.43-2.169a.288.288 0 0 0-.067-.517l-1.853-.579a5.17 5.17 0 0 1 .808-1.08 5.254 5.254 0 0 1 1.668-1.126 5.298 5.298 0 0 1 4.084 0 5.22 5.22 0 0 1 1.668 1.125c.48.478.861 1.045 1.125 1.668a.701.701 0 0 0 1.292-.546 6.612 6.612 0 0 0-1.425-2.113 6.635 6.635 0 0 0-2.114-1.425 6.7 6.7 0 0 0-5.177 0 6.618 6.618 0 0 0-2.114 1.425c-.485.484-.86 1.047-1.172 1.647l-1.74-.543a.287.287 0 0 0-.35.382l1.517 3.717a.289.289 0 0 0 .42.134Zm14.02 2.402L14.46 7.334a.286.286 0 0 0-.419-.134l-3.43 2.168a.287.287 0 0 0 .067.517l1.91.598a5.238 5.238 0 0 1-.892 1.24 5.22 5.22 0 0 1-3.71 1.535 5.285 5.285 0 0 1-2.042-.41 5.242 5.242 0 0 1-1.668-1.124 5.24 5.24 0 0 1-1.125-1.67.701.701 0 0 0-1.292.547 6.608 6.608 0 0 0 1.425 2.113 6.629 6.629 0 0 0 4.701 1.948 6.615 6.615 0 0 0 4.702-1.948 6.63 6.63 0 0 0 1.258-1.807l1.681.526a.287.287 0 0 0 .352-.383Z"/>
</svg>

After

Width:  |  Height:  |  Size: 993 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1032" viewBox="0 0 16 16">
<path d="M7.81 8c0 .473-.077.872-.232 1.198a1.686 1.686 0 0 1-.638.74c-.272.166-.585.25-.94.25s-.668-.084-.94-.25a1.708 1.708 0 0 1-.641-.74c-.153-.326-.23-.726-.23-1.198s.077-.871.23-1.195c.155-.327.368-.573.64-.74A1.75 1.75 0 0 1 6 5.813c.355 0 .668.084.94.252.273.167.485.413.638.74.155.324.233.723.233 1.195Z"/>
<path d="M13.99 13.245a3 3 0 0 1-4.49 2.354A2.987 2.987 0 0 1 8 16a2.987 2.987 0 0 1-1.5-.401 3 3 0 0 1-4.49-2.354A2.993 2.993 0 0 1 1 11c0-.535.14-1.037.385-1.471a2.998 2.998 0 0 1-.304-4.835A3 3 0 0 1 3.755 1.01 2.993 2.993 0 0 1 6 0c.768 0 1.47.289 2 .764A2.989 2.989 0 0 1 10 0c.893 0 1.695.39 2.245 1.01a3 3 0 0 1 2.674 3.684 2.998 2.998 0 0 1-.304 4.834c.245.435.385.937.385 1.472 0 .893-.39 1.695-1.01 2.245ZM9 8c0-.671-.132-1.245-.395-1.722A2.722 2.722 0 0 0 7.533 5.18 3.073 3.073 0 0 0 6 4.8a3.09 3.09 0 0 0-1.537.38c-.45.254-.807.62-1.07 1.098C3.13 6.755 3 7.328 3 8c0 .67.13 1.243.392 1.722.264.476.62.842 1.071 1.098.453.253.965.38 1.537.38s1.083-.127 1.533-.38c.453-.254.81-.619 1.072-1.095C8.868 9.246 9 8.67 9 8Zm1.71 2.654c.226.097.482.146.77.146.296 0 .558-.05.787-.148.229-.1.408-.235.538-.407A.941.941 0 0 0 13 9.654a.836.836 0 0 0-.228-.605c-.149-.16-.364-.258-.647-.296a.018.018 0 0 1-.016-.018c0-.009.006-.016.015-.018a.939.939 0 0 0 .523-.274.753.753 0 0 0 .206-.546.94.94 0 0 0-.17-.555 1.17 1.17 0 0 0-.476-.396 1.644 1.644 0 0 0-.715-.146c-.27 0-.512.049-.727.146-.213.097-.383.23-.51.402a.98.98 0 0 0-.173.39c-.022.11.071.203.184.203h.402c.108 0 .191-.09.239-.186a.541.541 0 0 1 .271-.242.774.774 0 0 1 .603.002.47.47 0 0 1 .268.431.46.46 0 0 1-.08.27.537.537 0 0 1-.228.18.81.81 0 0 1-.334.064h-.167a.2.2 0 0 0-.201.2v.204c0 .11.09.2.2.2h.168a.98.98 0 0 1 .386.069.557.557 0 0 1 .25.19c.06.08.09.172.09.275a.461.461 0 0 1-.084.272.553.553 0 0 1-.23.185.81.81 0 0 1-.337.066.877.877 0 0 1-.33-.059.582.582 0 0 1-.235-.165.436.436 0 0 1-.045-.07c-.05-.093-.133-.181-.24-.181h-.445c-.113 0-.206.092-.184.202a.99.99 0 0 0 .178.399c.13.172.306.308.53.407Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1033" viewBox="0 0 16 16">
<path d="M7.9 10a4.99 4.99 0 0 0 3.827-1.783 3 3 0 1 0 .553-5.63A4.999 4.999 0 0 0 7.9 0a4.998 4.998 0 0 0-4.359 2.549 3 3 0 1 0 .586 5.732A4.988 4.988 0 0 0 7.9 10Zm-.791-6.398c-.057-.362.17-.8.496-.997.256-.153.551-.133.806.023l.07.042a.846.846 0 0 1 .409.853L8.532 5.8H7.454L7.11 3.602ZM8.599 6.9a.6.6 0 1 1-1.2 0 .6.6 0 0 1 1.2 0ZM1.501 10a.35.35 0 0 0-.35.35v.544l-.47-.272a.35.35 0 1 0-.35.606l.47.272-.47.272a.35.35 0 1 0 .35.606l.47-.272v.544a.35.35 0 1 0 .7 0v-.544l.471.272a.35.35 0 1 0 .35-.606L2.2 11.5l.47-.272a.35.35 0 1 0-.349-.606l-.471.272v-.544A.35.35 0 0 0 1.5 10Zm4 2a.35.35 0 0 0-.35.35v.544l-.47-.272a.35.35 0 1 0-.35.606l.47.272-.47.272a.35.35 0 1 0 .35.606l.47-.272v.544a.35.35 0 1 0 .7 0v-.544l.471.272a.35.35 0 1 0 .35-.606L6.2 13.5l.471-.272a.35.35 0 1 0-.35-.606l-.471.272v-.544A.35.35 0 0 0 5.5 12Zm4.65.35a.35.35 0 1 1 .7 0v.544l.47-.272a.35.35 0 1 1 .35.606l-.47.272.47.272a.35.35 0 1 1-.35.606l-.47-.272v.544a.35.35 0 1 1-.7 0v-.544l-.47.272a.35.35 0 1 1-.35-.606l.47-.272-.47-.272a.35.35 0 1 1 .35-.606l.47.272v-.544Zm4.35-2.35a.35.35 0 0 0-.35.35v.544l-.47-.272a.35.35 0 1 0-.35.606l.47.272-.47.272a.35.35 0 1 0 .35.606l.47-.272v.544a.35.35 0 1 0 .7 0v-.544l.47.272a.35.35 0 1 0 .35-.606l-.47-.272.47-.272a.35.35 0 1 0-.35-.606l-.47.272v-.544a.35.35 0 0 0-.35-.35Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1034" viewBox="0 0 16 16">
<path d="M2.302 1.7a.7.7 0 0 1 1.4 0v1.088l.942-.544a.7.7 0 1 1 .7 1.212L4.402 4l.942.544a.7.7 0 1 1-.7 1.212l-.942-.544V6.3a.7.7 0 1 1-1.4 0V5.212l-.942.544a.7.7 0 0 1-.7-1.212L1.602 4 .66 3.456a.7.7 0 0 1 .7-1.212l.942.544V1.7ZM11.5 7a.5.5 0 0 0-.5.5v2.063a2 2 0 1 0 1 0V7.5a.5.5 0 0 0-.5-.5Z"/>
<path d="m10.2 8.399-.532.356a3.3 3.3 0 1 0 3.665 0l-.533-.356V2.5a1.3 1.3 0 1 0-2.6 0v5.899ZM9 2.5a2.5 2.5 0 0 1 5 0v5.258a4.5 4.5 0 1 1-5 0V2.5Z"/>
</svg>

After

Width:  |  Height:  |  Size: 578 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1035" viewBox="0 0 16 16">
<path d="M8.293 15.707A1 1 0 0 1 8 15c0-.5.555-1.395 1-2 .445.605 1 1.5 1 2a1 1 0 0 1-1.707.707Zm-6-6A1 1 0 0 1 2 9c0-.5.555-1.395 1-2 .445.605 1 1.5 1 2a1 1 0 0 1-1.707.707Zm10 6A1 1 0 0 1 12 15c0-.5.555-1.395 1-2 .445.605 1 1.5 1 2a1 1 0 0 1-1.707.707ZM7.268 5.162a.224.224 0 0 0-.233.042A2.99 2.99 0 0 1 5 6a2.99 2.99 0 0 1-2.035-.796.224.224 0 0 0-.233-.042 2 2 0 1 1-.383-3.832.224.224 0 0 0 .22-.087A2.996 2.996 0 0 1 5 0c1 0 1.887.49 2.432 1.243.05.069.136.102.22.087a2 2 0 1 1-.383 3.832ZM5 5.25c.752 0 1.418-.37 1.827-.936.086-.12.273-.134.388-.041a1.25 1.25 0 1 0 .209-2.082c-.132.068-.312.017-.373-.118a2.25 2.25 0 0 0-4.102 0c-.06.135-.241.186-.372.118a1.25 1.25 0 1 0 .209 2.082c.114-.093.301-.079.387.04A2.247 2.247 0 0 0 5 5.25Zm8.137 5.855A2.99 2.99 0 0 1 11 12a2.99 2.99 0 0 1-2.138-.895 2 2 0 1 1-.375-3.745A2.997 2.997 0 0 1 11 6c1.052 0 1.977.541 2.512 1.36A2.004 2.004 0 0 1 16 9.3a2 2 0 0 1-2.863 1.805Zm-2.85-3.224.276 1.759h.863l.286-1.822a.677.677 0 0 0-.327-.682l-.056-.034a.607.607 0 0 0-.645-.018c-.26.157-.442.508-.397.797Zm1.193 2.639a.48.48 0 1 0-.96 0 .48.48 0 0 0 .96 0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1036" viewBox="0 0 16 16">
<path d="M3 10.76c-.683-.13-1.336-.353-2.139-.705-.298-.132-.657-.018-.801.253-.144.27-.02.597.279.728 1.034.454 1.872.72 2.794.854a5.438 5.438 0 0 0 1.331 2.548C5.402 15.438 6.674 16 8 16s2.598-.562 3.536-1.562c.937-1 1.464-2.357 1.464-3.771C13 8.24 10.703 4.073 8.615.91A78.36 78.36 0 0 0 8 0a61.87 61.87 0 0 0-.615.911 49.363 49.363 0 0 0-2.39 3.999c-1.648.01-2.627-.194-4.134-.856-.298-.13-.657-.017-.801.254-.144.27-.02.597.279.728 1.496.657 2.581.92 4.105.959a22.42 22.42 0 0 0-.805 1.858c-.939-.1-1.73-.339-2.778-.799-.298-.13-.657-.017-.801.254-.144.27-.02.597.279.728 1.092.48 1.964.749 2.948.875C3.104 9.557 3 10.15 3 10.667v.092Zm1.005.124A4.554 4.554 0 0 1 4 10.667c0-.43.101-.992.312-1.676.252.009.516.01.796.008 1.249-.015 1.993-.128 3.18-.482.314-.094.486-.402.382-.688-.103-.286-.442-.441-.757-.347-1.07.319-1.694.413-2.82.427h-.394a24.089 24.089 0 0 1 .88-1.922c.982-.037 1.678-.163 2.708-.47.315-.094.487-.402.383-.688-.103-.286-.442-.441-.757-.347-.677.201-1.175.313-1.734.372A49.378 49.378 0 0 1 8 1.835c.86 1.32 1.736 2.791 2.453 4.213.471.936.864 1.834 1.138 2.643.277.82.409 1.486.409 1.976a4.415 4.415 0 0 1-1.172 3.017c-.75.8-1.767 1.25-2.828 1.25-1.06 0-2.078-.45-2.828-1.25a4.324 4.324 0 0 1-.976-1.698c.287.013.59.016.912.013 1.249-.015 1.993-.128 3.18-.482.314-.094.486-.402.382-.688-.103-.286-.442-.441-.757-.348-1.07.32-1.694.414-2.82.428-.396.004-.754-.003-1.088-.025Z"/>
<path d="M11.052 4.996c1.675-.025 2.681.09 4.375.488a.47.47 0 0 0 .562-.376.497.497 0 0 0-.357-.592c-1.84-.433-2.934-.549-4.818-.509a8.9 8.9 0 0 0-.295.012c.183.325.361.651.533.977Zm1.369 3.019c.975.051 1.823.191 3.006.47a.47.47 0 0 0 .562-.377.497.497 0 0 0-.357-.592c-1.412-.332-2.385-.478-3.612-.51.148.344.282.682.402 1.01Zm3.006 3.47c-.95-.224-1.684-.358-2.44-.429a4.745 4.745 0 0 0-.029-.999c.838.072 1.637.215 2.674.458.253.06.413.325.357.593a.47.47 0 0 1-.562.377Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1037" viewBox="0 0 16 16">
<path d="m9 4.866-3.027 5.462.696 1.729-1.761 3.927L.212 16a.184.184 0 0 1-.103-.031.232.232 0 0 1-.077-.088.29.29 0 0 1-.01-.246L6.69.149a.247.247 0 0 1 .077-.107A.188.188 0 0 1 6.88 0c.04 0 .08.012.113.037a.243.243 0 0 1 .08.105L9 4.866ZM13.5 11l2.482 4.676c.057.15-.03.324-.163.324L10 15.969 13.5 11Zm-1.595-3.44.46-4.56-4.16 7.05 1.605-.212L8 15.068l5.653-7.362-1.748-.145Z"/>
</svg>

After

Width:  |  Height:  |  Size: 509 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1038" viewBox="0 0 16 16">
<path d="M7.9 10a4.99 4.99 0 0 0 3.827-1.783 3 3 0 1 0 .553-5.63A4.999 4.999 0 0 0 7.9 0a4.998 4.998 0 0 0-4.359 2.549 3 3 0 1 0 .586 5.732A4.988 4.988 0 0 0 7.9 10Zm-.791-6.398c-.057-.362.17-.8.496-.997.256-.153.551-.133.806.023l.07.042a.846.846 0 0 1 .409.853L8.532 5.8H7.454L7.11 3.602ZM8.599 6.9a.6.6 0 1 1-1.2 0 .6.6 0 0 1 1.2 0ZM1 12.5a1 1 0 1 0 2 0c0-.5-.555-1.395-1-2-.445.605-1 1.5-1 2ZM5 15a1 1 0 1 0 2 0c0-.5-.555-1.395-1-2-.445.605-1 1.5-1 2Zm4.293.707A1 1 0 0 1 9 15c0-.5.555-1.395 1-2 .445.605 1 1.5 1 2a1 1 0 0 1-1.707.707ZM13 12.5a1 1 0 0 0 2 0c0-.5-.555-1.395-1-2-.445.605-1 1.5-1 2Z"/>
</svg>

After

Width:  |  Height:  |  Size: 732 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1039" viewBox="0 0 16 16">
<path d="M6 2.7a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0Z"/>
<path d="M7.833 11.939a.258.258 0 0 0 .335 0l6.721-5.737a.317.317 0 0 0 .09-.35.32.32 0 0 0-.12-.15.332.332 0 0 0-.185-.057h-2.442V.498a.492.492 0 0 0-.15-.352.517.517 0 0 0-.36-.146h-7.58a.517.517 0 0 0-.36.146.491.491 0 0 0-.15.352v5.147H1.327a.332.332 0 0 0-.186.056.31.31 0 0 0-.029.501l6.72 5.737ZM7.832 2.99a2.417 2.417 0 0 1 1.426-.157c.156.03.615.131 1.068.487.263.207.496.486.597.64l-.52.546c-.119-.192-.524-.662-1.12-.81-.838-.21-2.077.27-2.089 1.621-.01 1.33 1.186 1.911 2.185 1.654.544-.139.885-.536 1.03-.74l.51.53c-.246.329-.565.6-.932.794a2.28 2.28 0 0 1-1.167.254h-.01c-.213-.006-1.107-.028-1.77-.696-.673-.678-.703-1.525-.703-1.827 0-.68.258-1.263.719-1.75.104-.111.383-.37.776-.546Zm-3.02-.936a.935.935 0 0 1 .674-.266.968.968 0 0 1 .688.255.857.857 0 0 1 .268.645.856.856 0 0 1-.268.65.938.938 0 0 1-.676.255.982.982 0 0 1-.688-.261.85.85 0 0 1-.28-.637c0-.25.094-.463.281-.641Z"/>
<path d="M14.4 8.94h1.478a.123.123 0 0 1 .08.215L8.247 15.91a.374.374 0 0 1-.492 0L.04 9.155a.123.123 0 0 1 .081-.215H1.6a.59.59 0 0 1 .393.148l6.009 5.26 6.007-5.26a.594.594 0 0 1 .393-.148Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-104-fill" viewBox="0 0 16 16">
<path d="M11.727 14.217A4.99 4.99 0 0 1 7.9 16a4.988 4.988 0 0 1-3.773-1.719 3 3 0 1 1-.586-5.732A4.998 4.998 0 0 1 7.9 6a4.999 4.999 0 0 1 4.38 2.587 3 3 0 1 1-.553 5.63Z"/>
<path d="M4.008 6.637a1.545 1.545 0 0 1 1.54-1.467.913.913 0 0 1 .108.012l.084.012a1 1 0 0 0 .961-.445 2.74 2.74 0 0 1 4.598 0 1 1 0 0 0 .961.445l.084-.012a.916.916 0 0 1 .108-.012 1.524 1.524 0 0 1 1.455 2.048c.312.135.602.316.86.538A2.484 2.484 0 0 0 12.136 4.2a3.74 3.74 0 0 0-6.27 0 2.506 2.506 0 0 0-.317-.032A2.548 2.548 0 0 0 3 6.717c.005.174.028.347.069.517.238-.3.569-.51.94-.597h-.001Z"/>
</svg>

After

Width:  |  Height:  |  Size: 708 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-104" viewBox="0 0 16 16">
<path d="M7.9 16c1.453 0 2.761-.62 3.675-1.61a.335.335 0 0 1 .365-.083 3 3 0 1 0 .566-5.767.335.335 0 0 1-.341-.152A4.997 4.997 0 0 0 7.9 6a4.997 4.997 0 0 0-4.25 2.365.334.334 0 0 1-.32.153 3 3 0 1 0 .596 5.836.334.334 0 0 1 .345.086A4.99 4.99 0 0 0 7.9 16Zm3.905-2.896c-.172-.128-.438-.097-.555.083A3.996 3.996 0 0 1 7.9 15a3.996 3.996 0 0 1-3.297-1.734c-.112-.163-.347-.197-.513-.089a2 2 0 1 1-.362-3.54c.184.072.408-.01.485-.192a4.001 4.001 0 0 1 7.398.059c.08.2.335.282.53.19a2 2 0 1 1-.335 3.41Z"/>
<path d="M4.008 6.637a1.545 1.545 0 0 1 1.54-1.467.913.913 0 0 1 .108.012l.084.012a1 1 0 0 0 .961-.445 2.74 2.74 0 0 1 4.598 0 1 1 0 0 0 .961.445l.084-.012a.916.916 0 0 1 .108-.012 1.524 1.524 0 0 1 1.455 2.048c.312.135.602.316.86.538A2.484 2.484 0 0 0 12.136 4.2a3.74 3.74 0 0 0-6.27 0 2.506 2.506 0 0 0-.317-.032A2.548 2.548 0 0 0 3 6.717c.005.174.028.347.069.517.238-.3.569-.51.94-.597h-.001Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1040" viewBox="0 0 16 16">
<path d="M7.07.933a.933.933 0 0 1 1.866 0v1.45l1.256-.725a.933.933 0 0 1 .933 1.617L9.87 4l1.256.725a.933.933 0 0 1-.933 1.617l-1.256-.725v1.45a.933.933 0 0 1-1.867 0v-1.45l-1.255.725a.933.933 0 1 1-.934-1.617L6.136 4 4.88 3.275a.933.933 0 0 1 .934-1.617l1.255.725V.933ZM2.686 9.817a.817.817 0 0 1 1.633 0v1.268l1.099-.634a.817.817 0 1 1 .816 1.415l-1.098.634 1.098.634a.817.817 0 0 1-.816 1.415l-1.099-.634v1.268a.817.817 0 1 1-1.633 0v-1.268l-1.1.634a.817.817 0 0 1-.816-1.415l1.1-.634-1.1-.634a.817.817 0 0 1 .817-1.415l1.099.634V9.817ZM12.502 9a.817.817 0 0 0-.816.817v1.268l-1.1-.634a.817.817 0 1 0-.816 1.415l1.099.634-1.099.634a.817.817 0 0 0 .817 1.415l1.099-.634v1.268a.817.817 0 1 0 1.633 0v-1.268l1.099.634a.817.817 0 0 0 .817-1.415l-1.1-.634 1.1-.634a.817.817 0 0 0-.817-1.415l-1.099.634V9.817A.817.817 0 0 0 12.502 9Z"/>
</svg>

After

Width:  |  Height:  |  Size: 962 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1041" viewBox="0 0 16 16">
<path d="M13.475 2.625c-.787.263-1.05.962-.963 1.4-.612-.7-.612-1.488-.524-2.713-1.838.7-1.4 2.8-1.488 3.5-.438-.437-.525-1.312-.525-1.312-.525.263-.787.963-.787 1.575 0 1.4 1.137 2.362 2.45 2.362A2.353 2.353 0 0 0 14 5.075c0-.963-.525-1.225-.525-2.45Z"/>
<path d="M8.313 4.988c0 1.575 1.05 2.8 2.45 3.15H8.75l3.37 3.69a.2.2 0 0 1-.148.335H7.175v1.717a46.583 46.583 0 0 0-1.925-.265v-1.453H.447a.2.2 0 0 1-.15-.333L3.676 8.05H1.988a.2.2 0 0 1-.157-.324L4.2 4.725H3.028a.2.2 0 0 1-.16-.32l3.188-4.2a.2.2 0 0 1 .317-.002l2.465 3.122a3.092 3.092 0 0 0-.525 1.663Z"/>
<path d="M0 14s1-.5 3.5-.5 6.5 1 8.5 1 4-.5 4-.5v2H0v-2Z"/>
</svg>

After

Width:  |  Height:  |  Size: 756 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1042" viewBox="0 0 16 16">
<path d="M6.5 4h3v2.5H12v3H9.5V12h-3V9.5H4v-3h2.5V4Z"/>
<path d="m.095 7.65 3.648-6.298A.705.705 0 0 1 4.352 1h7.296a.703.703 0 0 1 .609.352l3.648 6.298a.688.688 0 0 1 0 .7l-3.648 6.298a.705.705 0 0 1-.61.352H4.353a.705.705 0 0 1-.61-.351L.096 8.351a.693.693 0 0 1 0-.701ZM14.484 8l-3.242-5.596H4.758L1.516 8l3.243 5.597h6.483L14.484 8Z"/>
</svg>

After

Width:  |  Height:  |  Size: 470 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1043" viewBox="0 0 16 16">
<path d="M7.9 10a4.99 4.99 0 0 0 3.827-1.783 3 3 0 1 0 .553-5.63A4.999 4.999 0 0 0 7.9 0a4.998 4.998 0 0 0-4.359 2.549 3 3 0 1 0 .586 5.732A4.988 4.988 0 0 0 7.9 10Zm-.791-6.398c-.057-.362.17-.8.496-.997.256-.153.551-.133.806.023l.07.042a.846.846 0 0 1 .409.853L8.532 5.8H7.454L7.11 3.602ZM8.599 6.9a.6.6 0 1 1-1.2 0 .6.6 0 0 1 1.2 0ZM0 11.5a1 1 0 1 0 2 0c0-.5-.555-1.395-1-2-.445.605-1 1.5-1 2ZM3 13a1 1 0 1 0 2 0c0-.5-.555-1.395-1-2-.445.605-1 1.5-1 2Zm8.293.707A1 1 0 0 1 11 13c0-.5.555-1.395 1-2 .445.605 1 1.5 1 2a1 1 0 0 1-1.707.707ZM14 11.5a1 1 0 0 0 2 0c0-.5-.555-1.395-1-2-.445.605-1 1.5-1 2Zm-5.54 1.126c-.054 0-.089-.05-.065-.093l.792-1.438C9.21 11.05 9.176 11 9.122 11H7.544a.147.147 0 0 0-.076.02.158.158 0 0 0-.058.057l-1.397 2.637c-.042.079.022.17.118.17h1.42c.05 0 .084.043.069.086l-.739 1.943c-.027.07.072.118.124.063l2.978-3.243c.04-.042.006-.107-.055-.107H8.46Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1012 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1044" viewBox="0 0 16 16">
<path d="M3.981 3.721a.194.194 0 0 0-.26.26l1.45 3.312 2.122-2.121-3.312-1.45Zm1.544 3.926 2.121-2.122 4.597 4.596-2.122 2.122-4.596-4.596Zm4.95 4.949 2.121-2.121.354.354a.5.5 0 0 1 0 .707l-1.414 1.414a.5.5 0 0 1-.708 0l-.353-.354Z"/>
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0Zm-1.3 0a6.67 6.67 0 0 0-1.352-4.037l-9.385 9.385A6.7 6.7 0 0 0 14.7 8Zm-2.385-5.126a6.7 6.7 0 0 0-9.44 9.44l9.44-9.44Z"/>
</svg>

After

Width:  |  Height:  |  Size: 531 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1045" viewBox="0 0 16 16">
<path d="M3.25 5.121c.055-.203.306-.25.455-.102l1.411 1.412a.8.8 0 0 0 1.132 0l.282-.283a.8.8 0 0 0 0-1.132L5.12 3.605c-.149-.149-.101-.4.102-.454a2.799 2.799 0 0 1 3.531 2.79.284.284 0 0 1-.085.191l-.51.51 3.974 3.975a.8.8 0 0 1 0 1.131l-.283.283a.8.8 0 0 1-1.132 0L6.744 8.057l-.51.51a.284.284 0 0 1-.192.085 2.799 2.799 0 0 1-2.79-3.531Z"/>
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0Zm-1.3 0a6.67 6.67 0 0 0-1.352-4.037l-9.385 9.385A6.7 6.7 0 0 0 14.7 8Zm-2.385-5.126a6.7 6.7 0 0 0-9.44 9.44l9.44-9.44Z"/>
</svg>

After

Width:  |  Height:  |  Size: 640 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1046" viewBox="0 0 16 16">
<path d="M.777 11.644a.5.5 0 1 0-.554.832l1.017.678a1.5 1.5 0 0 0 1.503.094l1.033-.517a.5.5 0 0 1 .448 0l1.105.553a1.5 1.5 0 0 0 1.342 0l1.105-.553a.5.5 0 0 1 .448 0l1.105.553a1.5 1.5 0 0 0 1.342 0l1.105-.553a.5.5 0 0 1 .448 0l1.033.517a1.5 1.5 0 0 0 1.503-.094l1.017-.678a.5.5 0 1 0-.554-.832l-1.017.678a.5.5 0 0 1-.501.031l-1.034-.517a1.5 1.5 0 0 0-1.342 0l-1.105.553a.5.5 0 0 1-.448 0l-1.105-.553a1.5 1.5 0 0 0-1.342 0l-1.105.553a.5.5 0 0 1-.448 0l-1.105-.553a1.5 1.5 0 0 0-1.342 0l-1.034.517a.5.5 0 0 1-.5-.03l-1.018-.679Zm0 2.5a.5.5 0 1 0-.554.832l1.017.678a1.5 1.5 0 0 0 1.503.094l1.033-.517a.5.5 0 0 1 .448 0l1.105.553a1.5 1.5 0 0 0 1.342 0l1.105-.553a.5.5 0 0 1 .448 0l1.105.553a1.5 1.5 0 0 0 1.342 0l1.105-.553a.5.5 0 0 1 .448 0l1.033.517a1.5 1.5 0 0 0 1.503-.094l1.017-.678a.5.5 0 1 0-.554-.832l-1.017.678a.5.5 0 0 1-.501.031l-1.034-.517a1.5 1.5 0 0 0-1.342 0l-1.105.553a.5.5 0 0 1-.448 0l-1.105-.553a1.5 1.5 0 0 0-1.342 0l-1.105.553a.5.5 0 0 1-.448 0l-1.105-.553a1.5 1.5 0 0 0-1.342 0l-1.034.517a.5.5 0 0 1-.5-.03l-1.018-.679ZM8.284.788a.329.329 0 0 0-.568 0L3.044 8.89a.324.324 0 0 0 .284.485h9.344a.324.324 0 0 0 .284-.485L8.284.788Zm-1.062 2.68c-.04-.352.32-.655.778-.655s.818.303.778.655l-.352 3.095h-.852l-.352-3.095ZM8.63 7.813a.625.625 0 1 1-1.25 0 .625.625 0 0 1 1.25 0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1047" viewBox="0 0 16 16">
<path d="M5.05 2.677a.87.87 0 0 1-.867.867.869.869 0 0 1-.868-.867.87.87 0 0 1 .868-.868.87.87 0 0 1 .867.868Zm5.974 6.243a.41.41 0 0 1 .565.38.41.41 0 0 1-.408.408.41.41 0 0 1-.157-.787Zm2.454-2.018a.87.87 0 0 1-.613.255.871.871 0 0 1-.868-.868.87.87 0 0 1 1.736 0 .87.87 0 0 1-.255.613Zm-1.342 5.293a.87.87 0 0 1 .868-.867.87.87 0 0 1 0 1.735.87.87 0 0 1-.868-.868Zm1.749-3.897a.878.878 0 0 1 .618-.257.877.877 0 0 1 .875.875.878.878 0 0 1-.875.875.878.878 0 0 1-.875-.875c0-.232.093-.454.257-.618Zm-1.633-5.934a.87.87 0 0 1-.867.868.87.87 0 0 1-.868-.868.87.87 0 0 1 .868-.868.87.87 0 0 1 .867.868ZM8.596 5.389a1.274 1.274 0 0 1 2.177.901c0 .706-.57 1.276-1.276 1.276a1.274 1.274 0 0 1-.901-2.177Zm-4.064 8.396c0-.649.53-1.18 1.18-1.18.649 0 1.18.531 1.18 1.18 0 .649-.531 1.18-1.18 1.18-.65 0-1.18-.531-1.18-1.18ZM2.863 4.488a.613.613 0 0 1-.417.188.613.613 0 0 1 0-1.225.613.613 0 0 1 .417 1.037ZM.685 8.956a.415.415 0 0 1 .29-.125.415.415 0 0 1 .408.418.42.42 0 0 1-.408.418.415.415 0 0 1-.408-.418c0-.11.042-.215.118-.293ZM2.25 5.422a.87.87 0 0 1 .868.867.87.87 0 0 1-.868.868.87.87 0 0 1-.868-.868.87.87 0 0 1 .868-.867Zm.172 6.549a.41.41 0 0 1 .288-.12.41.41 0 0 1 .408.408c0 .22-.188.408-.408.408a.41.41 0 0 1-.288-.696ZM6.656 6.58a.58.58 0 0 1-.408.169.58.58 0 0 1-.222-1.114.58.58 0 0 1 .8.536.58.58 0 0 1-.17.408ZM8.41 4.024a.59.59 0 0 1-1.005-.416.59.59 0 0 1 .589-.589.59.59 0 0 1 .416 1.005Zm5.741-.033a.496.496 0 1 1-.992 0 .496.496 0 0 1 .992 0Zm-9.757 6.534a1.276 1.276 0 1 0 0-2.552 1.276 1.276 0 0 0 0 2.552Zm3.368-.408a.868.868 0 1 0 0-1.736.868.868 0 0 0 0 1.736Zm2.439 2.078a.958.958 0 1 1-1.916 0 .958.958 0 0 1 1.916 0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1048" viewBox="0 0 16 16">
<path d="M5.5 4.2a.7.7 0 0 0 .702-.702.7.7 0 1 0-1.403 0 .7.7 0 0 0 .345.61.698.698 0 0 0 .356.093Z"/>
<path d="M3.5 0a.5.5 0 0 0-.5.5V10H.593c-.595 0-.818.804-.314 1.129l7.407 4.778a.576.576 0 0 0 .628 0l7.407-4.778c.504-.325.28-1.129-.314-1.129H13V.5a.5.5 0 0 0-.5-.5h-9Zm2 5c-.277 0-.528-.067-.755-.2a1.542 1.542 0 0 1-.542-.543A1.465 1.465 0 0 1 4 3.498a1.51 1.51 0 0 1 .745-1.296C4.972 2.069 5.223 2 5.5 2s.528.068.755.203A1.509 1.509 0 0 1 7 3.498c0 .277-.068.53-.203.759a1.542 1.542 0 0 1-.542.543c-.227.133-.478.2-.755.2Zm5.464.05a1.354 1.354 0 0 0-.163-.464 1.223 1.223 0 0 0-.7-.562 1.536 1.536 0 0 0-.486-.074c-.308 0-.582.08-.82.24a1.559 1.559 0 0 0-.56.695c-.134.304-.202.676-.202 1.115 0 .447.068.823.202 1.13.137.303.324.534.56.69.238.153.51.23.817.23.17 0 .329-.022.477-.068a1.29 1.29 0 0 0 .401-.208 1.293 1.293 0 0 0 .474-.793L12 6.987a2.343 2.343 0 0 1-.754 1.426 2.297 2.297 0 0 1-.725.433A2.637 2.637 0 0 1 9.598 9a2.53 2.53 0 0 1-1.335-.356 2.464 2.464 0 0 1-.925-1.03C7.113 7.165 7 6.627 7 6c0-.63.114-1.167.34-1.614a2.48 2.48 0 0 1 .929-1.03A2.511 2.511 0 0 1 9.599 3c.315 0 .609.046.88.137.272.091.514.225.726.402.212.175.387.39.524.644.138.253.229.542.271.867h-1.036Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1049" viewBox="0 0 16 16">
<path d="M1.293 14.707A1 1 0 0 1 1 14c0-.5.555-1.395 1-2 .445.605 1 1.5 1 2a1 1 0 0 1-1.707.707Zm3 1A1 1 0 0 1 4 15c0-.5.555-1.395 1-2 .445.605 1 1.5 1 2a1 1 0 0 1-1.707.707ZM7 15a1 1 0 1 0 2 0c0-.5-.555-1.395-1-2-.445.605-1 1.5-1 2Zm3.293.707A1 1 0 0 1 10 15c0-.5.555-1.395 1-2 .445.605 1 1.5 1 2a1 1 0 0 1-1.707.707ZM13 14a1 1 0 0 0 2 0c0-.5-.555-1.395-1-2-.445.605-1 1.5-1 2ZM9.114.001C5.651-.059 3.018 2.34 2.774 5.26c-.27 3.22 1.94 4.56 2.236 4.823-1.756-.027-3.846-1.98-4.197-3.81a.096.096 0 0 0-.093-.078.095.095 0 0 0-.094.105c.371 3.25 3.138 5.715 6.258 5.7 3.322-.014 6.008-1.978 6.35-5.203C13.558 3.76 11.292 2.181 11 1.92c1.865.018 3.837 1.982 4.184 3.81a.095.095 0 0 0 .091.077.095.095 0 0 0 .096-.105c-.374-3.245-3.138-5.645-6.257-5.7ZM8 7.386a1.385 1.385 0 1 1 0-2.77 1.385 1.385 0 0 1 0 2.77Z"/>
</svg>

After

Width:  |  Height:  |  Size: 940 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1050" viewBox="0 0 16 16">
<path d="M11.5 7a.5.5 0 0 0-.5.5v2.063a2 2 0 1 0 1 0V7.5a.5.5 0 0 0-.5-.5Z"/>
<path d="m10.2 8.399-.532.356a3.3 3.3 0 1 0 3.665 0l-.533-.356V2.5a1.3 1.3 0 1 0-2.6 0v5.899ZM9 2.5a2.5 2.5 0 0 1 5 0v5.258a4.5 4.5 0 1 1-5 0V2.5ZM4.002 1a.7.7 0 0 0-.7.7v1.088l-.942-.544a.7.7 0 0 0-.7 1.212L2.602 4l-.942.544a.7.7 0 0 0 .7 1.212l.942-.544V6.3a.7.7 0 1 0 1.4 0V5.212l.942.544a.7.7 0 1 0 .7-1.212L5.402 4l.942-.544a.7.7 0 1 0-.7-1.212l-.942.544V1.7a.7.7 0 0 0-.7-.7ZM3.001 8a.467.467 0 0 0-.466.467v.725l-.628-.363a.467.467 0 0 0-.467.808l.628.363-.628.363a.467.467 0 0 0 .467.808l.628-.363v.725a.467.467 0 1 0 .933 0v-.725l.628.363a.467.467 0 1 0 .467-.808L3.935 10l.628-.363a.467.467 0 1 0-.467-.808l-.628.363v-.725A.467.467 0 0 0 3.001 8Z"/>
</svg>

After

Width:  |  Height:  |  Size: 868 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1051" viewBox="0 0 16 16">
<path d="M15.859 8.141 14.34 9.66a.2.2 0 0 1-.34-.143V9H1a1 1 0 1 1 0-2h13v-.517a.2.2 0 0 1 .341-.142L15.86 7.86a.2.2 0 0 1 0 .282Z"/>
<path d="M10.462 2.99c.388.39 1.052.476 1.516.156.44-.303.536-.862.177-1.238a7.232 7.232 0 0 0-1.52-1.241C9.91.222 9.06 0 8.082 0 6.82 0 5.788.354 4.984 1.063c-.788.694-1.182 1.548-1.182 2.562 0 .472.102.944.307 1.417.205.472.528.916.97 1.333.191.174.447.383.768.625h3.366a24.36 24.36 0 0 0-.162-.104c-1.356-.89-2.223-1.528-2.6-1.917-.38-.389-.568-.84-.568-1.354 0-.528.197-.958.59-1.292.41-.333.907-.5 1.49-.5.6 0 1.12.132 1.561.396.276.156.588.41.938.761ZM11.725 9c.12.126.229.251.33.375.63.778.945 1.597.945 2.458 0 1.125-.497 2.104-1.49 2.938C10.533 15.59 9.39 16 8.082 16c-2.038 0-3.686-.887-4.946-2.662-.284-.4-.105-.918.36-1.154a1.175 1.175 0 0 1 1.43.328c.886 1.159 1.859 1.738 2.92 1.738.819 0 1.52-.23 2.104-.688.583-.458.875-.993.875-1.604 0-.61-.245-1.187-.733-1.729-.317-.36-.783-.77-1.398-1.229h3.031ZM1 1.5a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1ZM2.5 3a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1Z"/>
<path d="M10.5 4.23a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm1.524 2.116a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1Zm1.454-2.199a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0ZM6.5 12a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1Zm8.5-.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0ZM4.5 11a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1Zm-2 3a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0Zm-.75-2.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm13 2.293a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM2 6a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1052" viewBox="0 0 16 16">
<path d="M.777 11.644a.5.5 0 1 0-.554.832l1.017.678a1.5 1.5 0 0 0 1.503.094l1.033-.517a.5.5 0 0 1 .448 0l1.105.553a1.5 1.5 0 0 0 1.342 0l1.105-.553a.5.5 0 0 1 .448 0l1.105.553a1.5 1.5 0 0 0 1.342 0l1.105-.553a.5.5 0 0 1 .448 0l1.033.517a1.5 1.5 0 0 0 1.503-.094l1.017-.678a.5.5 0 1 0-.554-.832l-1.017.678a.5.5 0 0 1-.501.031l-1.034-.517a1.5 1.5 0 0 0-1.342 0l-1.105.553a.5.5 0 0 1-.448 0l-1.105-.553a1.5 1.5 0 0 0-1.342 0l-1.105.553a.5.5 0 0 1-.448 0l-1.105-.553a1.5 1.5 0 0 0-1.342 0l-1.034.517a.5.5 0 0 1-.5-.03l-1.018-.679Zm0 2.5a.5.5 0 1 0-.554.832l1.017.678a1.5 1.5 0 0 0 1.503.094l1.033-.517a.5.5 0 0 1 .448 0l1.105.553a1.5 1.5 0 0 0 1.342 0l1.105-.553a.5.5 0 0 1 .448 0l1.105.553a1.5 1.5 0 0 0 1.342 0l1.105-.553a.5.5 0 0 1 .448 0l1.033.517a1.5 1.5 0 0 0 1.503-.094l1.017-.678a.5.5 0 1 0-.554-.832l-1.017.678a.5.5 0 0 1-.501.031l-1.034-.517a1.5 1.5 0 0 0-1.342 0l-1.105.553a.5.5 0 0 1-.448 0l-1.105-.553a1.5 1.5 0 0 0-1.342 0l-1.105.553a.5.5 0 0 1-.448 0l-1.105-.553a1.5 1.5 0 0 0-1.342 0l-1.034.517a.5.5 0 0 1-.5-.03l-1.018-.679Zm7.618-6.111c-.024.043.011.093.065.093h1.468c.06 0 .094.065.055.107l-2.978 3.243c-.052.055-.151.006-.124-.063L7.62 9.47c.015-.043-.019-.087-.069-.087h-1.42c-.096 0-.16-.09-.118-.17L7.41 6.578a.159.159 0 0 1 .058-.057.147.147 0 0 1 .076-.02h1.578c.054 0 .089.051.065.094l-.792 1.439ZM3.293 9.707A1 1 0 0 1 3 9c0-.5.555-1.395 1-2 .445.605 1 1.5 1 2a1 1 0 0 1-1.707.707Zm8 0A1 1 0 0 1 11 9c0-.5.555-1.395 1-2 .445.605 1 1.5 1 2a1 1 0 0 1-1.707.707Zm-1.156-4.602A2.99 2.99 0 0 1 8 6a2.99 2.99 0 0 1-2.138-.895 2 2 0 1 1-.375-3.745A2.997 2.997 0 0 1 8 0c1.052 0 1.977.541 2.512 1.36A2.004 2.004 0 0 1 13 3.3a2 2 0 0 1-2.863 1.805Zm-2.85-3.224.277 1.759h.862l.286-1.822a.677.677 0 0 0-.327-.682l-.056-.034a.607.607 0 0 0-.645-.018c-.26.157-.443.508-.397.797ZM8.48 4.52a.48.48 0 1 0-.96 0 .48.48 0 0 0 .96 0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1053" viewBox="0 0 16 16">
<path d="M.777 11.644a.5.5 0 1 0-.554.832l1.017.678a1.5 1.5 0 0 0 1.503.094l1.033-.517a.5.5 0 0 1 .448 0l1.105.553a1.5 1.5 0 0 0 1.342 0l1.105-.553a.5.5 0 0 1 .448 0l1.105.553a1.5 1.5 0 0 0 1.342 0l1.105-.553a.5.5 0 0 1 .448 0l1.033.517a1.5 1.5 0 0 0 1.503-.094l1.017-.678a.5.5 0 1 0-.554-.832l-1.017.678a.5.5 0 0 1-.501.031l-1.034-.517a1.5 1.5 0 0 0-1.342 0l-1.105.553a.5.5 0 0 1-.448 0l-1.105-.553a1.5 1.5 0 0 0-1.342 0l-1.105.553a.5.5 0 0 1-.448 0l-1.105-.553a1.5 1.5 0 0 0-1.342 0l-1.034.517a.5.5 0 0 1-.5-.03l-1.018-.679Zm0 2.5a.5.5 0 1 0-.554.832l1.017.678a1.5 1.5 0 0 0 1.503.094l1.033-.517a.5.5 0 0 1 .448 0l1.105.553a1.5 1.5 0 0 0 1.342 0l1.105-.553a.5.5 0 0 1 .448 0l1.105.553a1.5 1.5 0 0 0 1.342 0l1.105-.553a.5.5 0 0 1 .448 0l1.033.517a1.5 1.5 0 0 0 1.503-.094l1.017-.678a.5.5 0 1 0-.554-.832l-1.017.678a.5.5 0 0 1-.501.031l-1.034-.517a1.5 1.5 0 0 0-1.342 0l-1.105.553a.5.5 0 0 1-.448 0l-1.105-.553a1.5 1.5 0 0 0-1.342 0l-1.105.553a.5.5 0 0 1-.448 0l-1.105-.553a1.5 1.5 0 0 0-1.342 0l-1.034.517a.5.5 0 0 1-.5-.03l-1.018-.679ZM9 .604a4.86 4.86 0 0 0-3.229.439 4.608 4.608 0 0 0-1.69 1.497 3.262 3.262 0 0 0-.396-.037c-.472 0-.938.1-1.364.294a3.169 3.169 0 0 0-1.102.828 2.962 2.962 0 0 0-.656 2.544 3 3 0 0 0 .574 1.225c.283.362.647.66 1.064.871a3.301 3.301 0 0 0 2.727.109A4.72 4.72 0 0 0 8 9.5a4.72 4.72 0 0 0 3.072-1.126 3.301 3.301 0 0 0 2.727-.109 3.153 3.153 0 0 0 1.064-.871c.212-.271.375-.573.483-.894h-.963a2.162 2.162 0 0 1-.45.6 2.257 2.257 0 0 1-.745.465 2.33 2.33 0 0 1-.873.151c-.301 0-.6-.058-.877-.17a.973.973 0 0 0-.984.15A3.76 3.76 0 0 1 8 8.6a3.76 3.76 0 0 1-2.454-.903.973.973 0 0 0-.984-.151 2.33 2.33 0 0 1-1.925-.076 2.225 2.225 0 0 1-.752-.615 2.117 2.117 0 0 1-.404-.865 2.073 2.073 0 0 1 .02-.948c.08-.31.232-.6.443-.847.21-.248.477-.447.778-.584a2.31 2.31 0 0 1 .963-.207c.06.002.12.008.178.018l.102.012a.972.972 0 0 0 .51-.076.928.928 0 0 0 .39-.323c.34-.502.804-.914 1.352-1.2A3.86 3.86 0 0 1 9 1.53V.604Z"/>
<path d="M10 1.9c0-.22.18-.4.4-.4h5.2a.4.4 0 0 1 0 .8h-5.2a.4.4 0 0 1-.4-.4ZM8 3.3c0-.22.18-.4.4-.4h5.2a.4.4 0 0 1 0 .8H8.4a.4.4 0 0 1-.4-.4Zm2 1.4c0-.22.18-.4.4-.4h5.2a.4.4 0 0 1 0 .8h-5.2a.4.4 0 0 1-.4-.4ZM7 6.1c0-.22.18-.4.4-.4h5.2a.4.4 0 0 1 0 .8H7.4a.4.4 0 0 1-.4-.4Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1054" viewBox="0 0 16 16">
<path d="M.777 11.644a.5.5 0 1 0-.554.832l1.017.678a1.5 1.5 0 0 0 1.503.094l1.033-.517a.5.5 0 0 1 .448 0l1.105.553a1.5 1.5 0 0 0 1.342 0l1.105-.553a.5.5 0 0 1 .448 0l1.105.553a1.5 1.5 0 0 0 1.342 0l1.105-.553a.5.5 0 0 1 .448 0l1.033.517a1.5 1.5 0 0 0 1.503-.094l1.017-.678a.5.5 0 1 0-.554-.832l-1.017.678a.5.5 0 0 1-.501.031l-1.034-.517a1.5 1.5 0 0 0-1.342 0l-1.105.553a.5.5 0 0 1-.448 0l-1.105-.553a1.5 1.5 0 0 0-1.342 0l-1.105.553a.5.5 0 0 1-.448 0l-1.105-.553a1.5 1.5 0 0 0-1.342 0l-1.034.517a.5.5 0 0 1-.5-.03l-1.018-.679Zm0 2.5a.5.5 0 1 0-.554.832l1.017.678a1.5 1.5 0 0 0 1.503.094l1.033-.517a.5.5 0 0 1 .448 0l1.105.553a1.5 1.5 0 0 0 1.342 0l1.105-.553a.5.5 0 0 1 .448 0l1.105.553a1.5 1.5 0 0 0 1.342 0l1.105-.553a.5.5 0 0 1 .448 0l1.033.517a1.5 1.5 0 0 0 1.503-.094l1.017-.678a.5.5 0 1 0-.554-.832l-1.017.678a.5.5 0 0 1-.501.031l-1.034-.517a1.5 1.5 0 0 0-1.342 0l-1.105.553a.5.5 0 0 1-.448 0l-1.105-.553a1.5 1.5 0 0 0-1.342 0l-1.105.553a.5.5 0 0 1-.448 0l-1.105-.553a1.5 1.5 0 0 0-1.342 0l-1.034.517a.5.5 0 0 1-.5-.03l-1.018-.679ZM7.408 0h3.378c.164 0 .264.195.174.342L9.1 2.75a.108.108 0 0 0 .032.145c.015.01.032.014.05.014h1.956c.26 0 .394.332.216.535L5.572 10 6.95 4.949a.11.11 0 0 0-.016-.091.1.1 0 0 0-.034-.03.092.092 0 0 0-.043-.012h-2a.282.282 0 0 1-.147-.041.307.307 0 0 1-.109-.115.333.333 0 0 1-.004-.314L7.098.195A.352.352 0 0 1 7.408 0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1055" viewBox="0 0 16 16">
<path d="M8.924 0C6.038-.05 3.844 1.95 3.64 4.383c-.225 2.684 1.618 3.8 1.865 4.02-1.464-.023-3.206-1.65-3.499-3.175a.08.08 0 0 0-.076-.066.079.079 0 0 0-.08.088c.31 2.707 2.616 4.762 5.215 4.75 2.769-.012 5.007-1.65 5.293-4.336.269-2.531-1.62-3.846-1.862-4.065 1.554.015 3.197 1.652 3.487 3.174a.08.08 0 0 0 .076.066.079.079 0 0 0 .076-.055c.004-.01.005-.022.004-.033-.312-2.704-2.616-4.705-5.215-4.75Zm-.93 6.155a1.154 1.154 0 1 1 0-2.308 1.154 1.154 0 0 1 0 2.308ZM.777 11.644a.5.5 0 1 0-.554.832l1.017.678a1.5 1.5 0 0 0 1.503.094l1.033-.517a.5.5 0 0 1 .448 0l1.105.553a1.5 1.5 0 0 0 1.342 0l1.105-.553a.5.5 0 0 1 .448 0l1.105.553a1.5 1.5 0 0 0 1.342 0l1.105-.553a.5.5 0 0 1 .448 0l1.033.517a1.5 1.5 0 0 0 1.503-.094l1.017-.678a.5.5 0 1 0-.554-.832l-1.017.678a.5.5 0 0 1-.501.031l-1.034-.517a1.5 1.5 0 0 0-1.342 0l-1.105.553a.5.5 0 0 1-.448 0l-1.105-.553a1.5 1.5 0 0 0-1.342 0l-1.105.553a.5.5 0 0 1-.448 0l-1.105-.553a1.5 1.5 0 0 0-1.342 0l-1.034.517a.5.5 0 0 1-.5-.03l-1.018-.679Zm0 2.5a.5.5 0 1 0-.554.832l1.017.678a1.5 1.5 0 0 0 1.503.094l1.033-.517a.5.5 0 0 1 .448 0l1.105.553a1.5 1.5 0 0 0 1.342 0l1.105-.553a.5.5 0 0 1 .448 0l1.105.553a1.5 1.5 0 0 0 1.342 0l1.105-.553a.5.5 0 0 1 .448 0l1.033.517a1.5 1.5 0 0 0 1.503-.094l1.017-.678a.5.5 0 1 0-.554-.832l-1.017.678a.5.5 0 0 1-.501.031l-1.034-.517a1.5 1.5 0 0 0-1.342 0l-1.105.553a.5.5 0 0 1-.448 0l-1.105-.553a1.5 1.5 0 0 0-1.342 0l-1.105.553a.5.5 0 0 1-.448 0l-1.105-.553a1.5 1.5 0 0 0-1.342 0l-1.034.517a.5.5 0 0 1-.5-.03l-1.018-.679Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1056" viewBox="0 0 16 16">
<rect width="6" height="1.2" y="4.5" rx=".6"/>
<path d="M11.5 7a.5.5 0 0 0-.5.5v2.063a2 2 0 1 0 1 0V7.5a.5.5 0 0 0-.5-.5Z"/>
<path d="m10.2 8.399-.532.356a3.3 3.3 0 1 0 3.665 0l-.533-.356V2.5a1.3 1.3 0 1 0-2.6 0v5.899ZM9 2.5a2.5 2.5 0 0 1 5 0v5.258a4.5 4.5 0 1 1-5 0V2.5Z"/>
</svg>

After

Width:  |  Height:  |  Size: 407 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1057" viewBox="0 0 16 16">
<path d="m10.591 8.276-3.254.264c-.364.03-.717.13-1.043.297l-1.266.65-.868-2.415c1.095-.75 1.94-1.436 2.276-2.545a.409.409 0 0 0-.424-.525.397.397 0 0 0-.348.286c-.307 1.009-1.179 1.593-2.498 2.475l-.017.011c-.448.3-.937.627-1.457 1.01C-.146 9.134-.137 10.97.126 11.75a.405.405 0 0 0 .51.256.406.406 0 0 0 .256-.514C.871 11.427.418 9.94 1.904 8.655l1.329 2.385c.221.395.603.674 1.044.762l2.01.404c.368.074.7.272.94.56l1.746 2.098a.378.378 0 0 0 .38.125l.024-.006a.378.378 0 0 0 .289-.381.384.384 0 0 0-.043-.167l-1.603-3.11a1.113 1.113 0 0 0-.685-.56l-.662-.186.252-.211a2.74 2.74 0 0 1 .998-.53l2.803-.813a.38.38 0 0 0 .268-.436.383.383 0 0 0-.403-.313Z"/>
<path d="M7.052 14.708c-2.037-.36-3.766-1.441-5.352-2.68a.13.13 0 0 0-.18.02l-.488.59a.137.137 0 0 0-.03.108.139.139 0 0 0 .059.095c1.733 1.14 3.816 1.709 5.985 1.91l.006-.043Zm-4.284-.452a.13.13 0 0 0-.094-.003.133.133 0 0 0-.072.062l-.254.462a.139.139 0 0 0 .078.197A23.178 23.178 0 0 0 9 16l-.002-.057c-1.967-.106-4.174-.814-6.23-1.687Zm-.796-7.492a1 1 0 1 0 0-2 1 1 0 0 0 0 2Zm9.563-4.297a.467.467 0 0 1 .933 0v.725l.628-.363a.467.467 0 0 1 .467.808L12.935 4l.628.363a.467.467 0 0 1-.467.808l-.628-.363v.725a.467.467 0 1 1-.933 0v-.725l-.628.363a.467.467 0 1 1-.467-.808L11.068 4l-.628-.363a.467.467 0 0 1 .467-.808l.628.363v-.725Z"/>
<path d="M15.325 1.872 12.175.048a.346.346 0 0 0-.35 0l-3.15 1.824a.353.353 0 0 0-.175.304v3.648c0 .126.067.242.175.305l3.15 1.824a.344.344 0 0 0 .35 0l3.149-1.824a.351.351 0 0 0 .176-.305V2.176a.353.353 0 0 0-.175-.304Zm-.526 3.75L12 7.241l-2.799-1.62V2.38L12 .758l2.799 1.621v3.242Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1058" viewBox="0 0 16 16">
<path d="M15.497 3.077S7.525.722 4.845.023a.794.794 0 0 0-.956.568L.024 15a.81.81 0 0 0 .544.968.811.811 0 0 0 1-.554l1.671-6.23 12.373-4.817a.696.696 0 0 0-.115-1.291Zm-2.562 7.525c-.085 0-.141-.08-.103-.15l1.267-2.3c.038-.07-.018-.152-.103-.152H11.47a.234.234 0 0 0-.122.031.253.253 0 0 0-.092.092l-2.235 4.22c-.067.125.035.27.189.27h2.272c.08 0 .135.07.11.139l-1.182 3.11c-.044.11.115.188.198.1l4.765-5.189c.062-.067.01-.171-.088-.171h-2.35Z"/>
</svg>

After

Width:  |  Height:  |  Size: 576 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1059" viewBox="0 0 16 16">
<path d="M5.735 2.405a.454.454 0 0 1-.235.062.466.466 0 0 1-.235-.872.454.454 0 0 1 .235-.062.454.454 0 0 1 .403.23.45.45 0 0 1 .065.236.467.467 0 0 1-.233.406Z"/>
<path d="M3 .5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 .5.5v3h3.072l-8 6-8-6H3v-3Zm1.997 2.367A.975.975 0 0 0 5.5 3a.975.975 0 0 0 .503-.133c.151-.09.272-.211.362-.362a.976.976 0 0 0 .135-.506A1.006 1.006 0 0 0 5.5 1a1.006 1.006 0 0 0-1 .999.98.98 0 0 0 .135.506c.09.15.21.272.362.362Zm6.503-.158a2.102 2.102 0 0 0-.217-.723 1.76 1.76 0 0 0-1-.872A2.118 2.118 0 0 0 9.58 1c-.396 0-.75.099-1.064.297a2.04 2.04 0 0 0-.742.858c-.182.372-.273.82-.273 1.345 0 .523.09.971.27 1.345.18.374.427.66.74.858.314.198.67.297 1.069.297.27 0 .516-.043.738-.128a1.824 1.824 0 0 0 .981-.899c.104-.202.171-.42.202-.65l-.829-.006c-.025.143-.07.27-.137.38a1.018 1.018 0 0 1-.563.454 1.238 1.238 0 0 1-.381.058c-.245 0-.463-.065-.654-.193a1.298 1.298 0 0 1-.448-.575c-.107-.255-.161-.569-.161-.941 0-.366.054-.676.161-.93.108-.254.257-.448.448-.579.19-.133.41-.2.656-.2.139 0 .268.021.388.062a.965.965 0 0 1 .56.468c.064.113.108.242.131.388h.829Z"/>
<path d="M13.333 7 8 11 2.667 7H0l8 6 8-6h-2.667Z"/>
<path d="M13.333 10 8 14l-5.333-4H0l8 6 8-6h-2.667Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1060" viewBox="0 0 16 16">
<path d="M8 9.8a1.8 1.8 0 1 1 0-3.6 1.8 1.8 0 0 1 0 3.6Z"/>
<path d="M14 8c0 1.167-3.59 3.5-6 3.5S2 9.167 2 8c0-1.167 3.59-3.5 6-3.5s6 2.333 6 3.5Zm-1.25-.211.001-.003-.004.008a.11.11 0 0 0 .003-.005Zm-.259.1c-.278-.286-.71-.623-1.245-.949C10.14 6.267 8.875 5.812 8 5.812s-2.14.455-3.246 1.128c-.536.326-.967.663-1.245.948-.04.04-.074.078-.104.112.03.034.064.071.104.112.278.285.71.622 1.245.948C5.86 9.733 7.125 10.187 8 10.187s2.14-.454 3.246-1.127c.536-.326.967-.663 1.245-.948.04-.04.075-.078.104-.112a2.783 2.783 0 0 0-.104-.112ZM0 0v4h1.5V1.5H4V0H0Zm16 0v4h-1.5V1.5H12V0h4ZM0 16v-4h1.5v2.5H4V16H0Zm16 0h-4v-1.5h2.5V12H16v4Z"/>
<path d="m12.773 4.288-8.485 8.485-1.061-1.06 8.485-8.486 1.06 1.06Z"/>
</svg>

After

Width:  |  Height:  |  Size: 837 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1061" viewBox="0 0 16 16">
<path d="M15.197 14.54.808 14.5H.806a.844.844 0 0 0-.568.212.703.703 0 0 0-.238.516c0 .096.02.192.061.28.04.09.1.17.174.238a.81.81 0 0 0 .261.159.869.869 0 0 0 .307.055l14.389.04h.002a.844.844 0 0 0 .568-.212.703.703 0 0 0 .238-.516.677.677 0 0 0-.061-.28.733.733 0 0 0-.174-.238.81.81 0 0 0-.261-.159.868.868 0 0 0-.307-.055ZM3 2a1 1 0 1 0 0-2 1 1 0 0 0 0 2Zm3.5 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"/>
<path d="m5.725 14.514-3.455-.01a2 2 0 1 1 3.455.01ZM10.5 13a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM7 4.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0ZM9.5 4a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM15 8a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 757 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1062" viewBox="0 0 16 16">
<path d="M6.053 10.177a.312.312 0 0 1 .433-.087l.636.424c.093.062.212.07.313.02l.646-.324a.937.937 0 0 1 .838 0l.691.346a.312.312 0 0 0 .28 0l.69-.346a.937.937 0 0 1 .84 0l.69.346a.312.312 0 0 0 .28 0l.69-.346a.937.937 0 0 1 .84 0l.645.323c.1.05.22.043.313-.02l.636-.423a.313.313 0 0 1 .347.52l-.636.424a.937.937 0 0 1-.94.058l-.645-.323a.312.312 0 0 0-.28 0l-.69.346a.938.938 0 0 1-.84 0l-.69-.346a.313.313 0 0 0-.28 0l-.69.346a.938.938 0 0 1-.84 0l-.69-.346a.312.312 0 0 0-.28 0l-.646.323c-.3.15-.66.128-.94-.058l-.635-.424a.312.312 0 0 1-.086-.433Zm0 1.5a.312.312 0 0 1 .433-.087l.636.424c.093.062.212.07.313.02l.646-.324a.938.938 0 0 1 .838 0l.691.346a.312.312 0 0 0 .28 0l.69-.346a.938.938 0 0 1 .84 0l.69.346a.312.312 0 0 0 .28 0l.69-.346a.938.938 0 0 1 .84 0l.645.323c.1.05.22.043.313-.02l.636-.423a.312.312 0 1 1 .347.52l-.636.424a.938.938 0 0 1-.94.058l-.645-.323a.312.312 0 0 0-.28 0l-.69.346a.937.937 0 0 1-.84 0l-.69-.346a.313.313 0 0 0-.28 0l-.69.346a.937.937 0 0 1-.84 0l-.69-.346a.312.312 0 0 0-.28 0l-.646.323c-.3.15-.66.128-.94-.058l-.635-.424a.312.312 0 0 1-.086-.433Zm.433 1.413a.312.312 0 1 0-.347.52l.636.424c.28.186.638.209.94.058l.645-.323a.313.313 0 0 1 .28 0l.69.346a.937.937 0 0 0 .84 0l.69-.346a.313.313 0 0 1 .28 0l.69.346a.937.937 0 0 0 .84 0l.69-.346a.313.313 0 0 1 .28 0l.646.323c.3.15.66.128.94-.058l.635-.424a.313.313 0 0 0-.347-.52l-.636.424a.313.313 0 0 1-.313.02l-.646-.324a.938.938 0 0 0-.838 0l-.691.346a.312.312 0 0 1-.28 0l-.69-.346a.938.938 0 0 0-.84 0l-.69.346a.312.312 0 0 1-.28 0l-.69-.346a.938.938 0 0 0-.84 0l-.645.323a.31.31 0 0 1-.313-.02l-.636-.423Z"/>
<path d="M15.497 3.077S7.525.722 4.845.023a.794.794 0 0 0-.956.568L.024 15a.81.81 0 0 0 .544.968.811.811 0 0 0 1-.554l1.671-6.23 12.373-4.817a.696.696 0 0 0-.115-1.291Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1063" viewBox="0 0 16 16">
<path d="M.293 10.707A1 1 0 0 1 0 10c0-.5.555-1.395 1-2 .445.605 1 1.5 1 2a1 1 0 0 1-1.707.707Zm3 3A1 1 0 0 1 3 13c0-.5.555-1.395 1-2 .445.605 1 1.5 1 2a1 1 0 0 1-1.707.707ZM7 15a1 1 0 1 0 2 0c0-.5-.555-1.395-1-2-.445.605-1 1.5-1 2Zm4.293-1.293A1 1 0 0 1 11 13c0-.5.555-1.395 1-2 .445.605 1 1.5 1 2a1 1 0 0 1-1.707.707ZM14 10a1 1 0 0 0 2 0c0-.5-.555-1.395-1-2-.445.605-1 1.5-1 2ZM8.5 4.5a.5.5 0 0 0-1 0v2.3a.7.7 0 0 0 .7.7h2.3a.5.5 0 0 0 0-1h-2v-2Z"/>
<path d="M7.1 0a.6.6 0 0 0 0 1.2h.3v.836a5 5 0 1 0 4.407 1.723L12 3.566l.217.217a.4.4 0 1 0 .566-.566l-1-1a.4.4 0 1 0-.566.566l.217.217-.193.193A4.979 4.979 0 0 0 8.6 2.036V1.2h.3a.6.6 0 0 0 0-1.2H7.1Zm4.7 7a3.8 3.8 0 1 1-7.6 0 3.8 3.8 0 0 1 7.6 0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 834 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="qi-1064" viewBox="0 0 16 16">
<path d="M8.5 4.5a.5.5 0 0 0-1 0v2.365a.7.7 0 0 0 .44.65l2.374.95a.5.5 0 1 0 .372-.93L8.5 6.662V4.5Z"/>
<path d="M7.1 0a.6.6 0 0 0 0 1.2h.3v.836a5 5 0 1 0 4.407 1.723L12 3.566l.217.217a.4.4 0 1 0 .566-.566l-1-1a.4.4 0 1 0-.566.566l.217.217-.193.193A4.979 4.979 0 0 0 8.6 2.036V1.2h.3a.6.6 0 0 0 0-1.2H7.1Zm4.7 7a3.8 3.8 0 1 1-7.6 0 3.8 3.8 0 0 1 7.6 0ZM.293 10.707A1 1 0 0 1 0 10c0-.5.555-1.395 1-2 .445.605 1 1.5 1 2a1 1 0 0 1-1.707.707Zm3 3A1 1 0 0 1 3 13c0-.5.555-1.395 1-2 .445.605 1 1.5 1 2a1 1 0 0 1-1.707.707ZM7 15a1 1 0 1 0 2 0c0-.5-.555-1.395-1-2-.445.605-1 1.5-1 2Zm4.293-1.293A1 1 0 0 1 11 13c0-.5.555-1.395 1-2 .445.605 1 1.5 1 2a1 1 0 0 1-1.707.707ZM14 10a1 1 0 0 0 2 0c0-.5-.555-1.395-1-2-.445.605-1 1.5-1 2Z"/>
</svg>

After

Width:  |  Height:  |  Size: 856 B

Some files were not shown because too many files have changed in this diff Show More