First Push
@@ -0,0 +1,11 @@
|
||||
.idea/
|
||||
.reasonix/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.db
|
||||
*.db.backup
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
.DS_Store
|
||||
审计报告.md
|
||||
@@ -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
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -0,0 +1,272 @@
|
||||
# Dashboard - 多人副屏系统
|
||||
|
||||
[](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
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()})
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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:
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |