yt-dlp-controller 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- yt_dlp_controller/__init__.py +4 -0
- yt_dlp_controller/app.py +269 -0
- yt_dlp_controller/app.tcss +238 -0
- yt_dlp_controller/config.py +260 -0
- yt_dlp_controller/main.py +61 -0
- yt_dlp_controller/queue.py +711 -0
- yt_dlp_controller/screens/__init__.py +1 -0
- yt_dlp_controller/screens/home.py +90 -0
- yt_dlp_controller/screens/modals.py +335 -0
- yt_dlp_controller/screens/new_download.py +573 -0
- yt_dlp_controller/screens/queue.py +492 -0
- yt_dlp_controller/screens/settings.py +418 -0
- yt_dlp_controller/ui.py +368 -0
- yt_dlp_controller/widgets.py +988 -0
- yt_dlp_controller/ytdlp.py +572 -0
- yt_dlp_controller-0.1.0.dist-info/METADATA +226 -0
- yt_dlp_controller-0.1.0.dist-info/RECORD +20 -0
- yt_dlp_controller-0.1.0.dist-info/WHEEL +5 -0
- yt_dlp_controller-0.1.0.dist-info/entry_points.txt +2 -0
- yt_dlp_controller-0.1.0.dist-info/top_level.txt +1 -0
yt_dlp_controller/app.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Textual 应用:全局配置、队列单例、状态栏、启动恢复与退出保护。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from rich.spinner import SPINNERS
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
from textual import work
|
|
11
|
+
from textual.app import App, ComposeResult
|
|
12
|
+
from textual.binding import Binding
|
|
13
|
+
from textual.widget import Widget
|
|
14
|
+
from textual.widgets import Static
|
|
15
|
+
|
|
16
|
+
from . import __version__
|
|
17
|
+
from .config import load_config
|
|
18
|
+
from .queue import QueueManager
|
|
19
|
+
from .widgets import ConfirmModal, ProgressCover
|
|
20
|
+
from .ytdlp import human_speed
|
|
21
|
+
|
|
22
|
+
_DOTS = SPINNERS["dots"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class StatusBar(Static):
|
|
26
|
+
"""底部状态栏:展示最近一次操作结果 / 进行中提示;spinner=True 时显示点阵转圈。"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
29
|
+
super().__init__(*args, **kwargs)
|
|
30
|
+
self._text = ""
|
|
31
|
+
self._busy = False
|
|
32
|
+
self._frame = 0
|
|
33
|
+
self._timer = None
|
|
34
|
+
|
|
35
|
+
def render(self) -> Text:
|
|
36
|
+
if self._busy:
|
|
37
|
+
glyph = _DOTS["frames"][self._frame % len(_DOTS["frames"])]
|
|
38
|
+
return Text.assemble((glyph, "bold"), " ", self._text)
|
|
39
|
+
return Text(self._text)
|
|
40
|
+
|
|
41
|
+
def set_status(self, text: str) -> None:
|
|
42
|
+
self._busy = False
|
|
43
|
+
self._text = text
|
|
44
|
+
self._stop_timer()
|
|
45
|
+
self.refresh()
|
|
46
|
+
|
|
47
|
+
def set_spinner(self, text: str) -> None:
|
|
48
|
+
self._busy = True
|
|
49
|
+
self._text = text
|
|
50
|
+
self._ensure_timer()
|
|
51
|
+
self.refresh()
|
|
52
|
+
|
|
53
|
+
def _ensure_timer(self) -> None:
|
|
54
|
+
if self._timer is None and self.is_mounted:
|
|
55
|
+
self._timer = self.set_interval(_DOTS["interval"] / 1000, self._tick)
|
|
56
|
+
|
|
57
|
+
def _stop_timer(self) -> None:
|
|
58
|
+
if self._timer is not None:
|
|
59
|
+
self._timer.stop()
|
|
60
|
+
self._timer = None
|
|
61
|
+
|
|
62
|
+
def _tick(self) -> None:
|
|
63
|
+
self._frame += 1
|
|
64
|
+
self.refresh()
|
|
65
|
+
|
|
66
|
+
def on_mount(self) -> None:
|
|
67
|
+
self._ensure_timer()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class YtdlpControllerApp(App):
|
|
71
|
+
"""yt-dlp 图形化终端界面。"""
|
|
72
|
+
|
|
73
|
+
CSS_PATH = Path(__file__).parent / "app.tcss"
|
|
74
|
+
TITLE = "YT-DLP Controller"
|
|
75
|
+
SUB_TITLE = f"v{__version__}"
|
|
76
|
+
ENABLE_COMMAND_PALETTE = False
|
|
77
|
+
|
|
78
|
+
BINDINGS = [
|
|
79
|
+
Binding("ctrl+q", "quit", "退出", priority=True),
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
def __init__(self) -> None:
|
|
83
|
+
super().__init__()
|
|
84
|
+
self.config: dict = {}
|
|
85
|
+
self._queue: QueueManager | None = None
|
|
86
|
+
self._quitting = False
|
|
87
|
+
self._quit_pending = False
|
|
88
|
+
self._booted = False
|
|
89
|
+
|
|
90
|
+
# ------------------------------------------------------------ 全局资源
|
|
91
|
+
|
|
92
|
+
def get_queue(self) -> QueueManager:
|
|
93
|
+
"""队列管理器单例(延迟创建,首次访问时加载 queue.json)。"""
|
|
94
|
+
if self._queue is None:
|
|
95
|
+
self._queue = QueueManager(lambda: self.config)
|
|
96
|
+
return self._queue
|
|
97
|
+
|
|
98
|
+
def get_settings(self) -> dict:
|
|
99
|
+
return self.config
|
|
100
|
+
|
|
101
|
+
def reload_config(self) -> list[str]:
|
|
102
|
+
"""重新读取配置并返回告警(坏值已回落,不阻断启动)。"""
|
|
103
|
+
config, warnings = load_config()
|
|
104
|
+
self.config = config
|
|
105
|
+
return warnings
|
|
106
|
+
|
|
107
|
+
# ------------------------------------------------------------ 状态栏 / 提示
|
|
108
|
+
|
|
109
|
+
def compose(self) -> ComposeResult:
|
|
110
|
+
yield StatusBar("", id="status")
|
|
111
|
+
|
|
112
|
+
def get_loading_widget(self) -> Widget:
|
|
113
|
+
"""全局默认:任意控件 .loading=True 时用 indeterminate 进度条覆盖其区域。"""
|
|
114
|
+
return ProgressCover()
|
|
115
|
+
|
|
116
|
+
def status(self, message: str, kind: str = "") -> None:
|
|
117
|
+
try:
|
|
118
|
+
bar = self.query_one("#status", StatusBar)
|
|
119
|
+
except Exception:
|
|
120
|
+
return
|
|
121
|
+
bar.update_classes({"ok": kind == "ok", "warn": kind == "warn", "err": kind == "err"})
|
|
122
|
+
bar.set_status(message)
|
|
123
|
+
|
|
124
|
+
def spinner(self, on: bool, message: str = "加载中…") -> None:
|
|
125
|
+
"""状态栏点阵 spinner(无遮罩、不阻断输入):适合轻量、即时的后台动作。"""
|
|
126
|
+
try:
|
|
127
|
+
bar = self.query_one("#status", StatusBar)
|
|
128
|
+
except Exception:
|
|
129
|
+
return
|
|
130
|
+
bar.update_classes({})
|
|
131
|
+
if on:
|
|
132
|
+
bar.set_spinner(message)
|
|
133
|
+
else:
|
|
134
|
+
bar.set_status("")
|
|
135
|
+
|
|
136
|
+
def notify_ok(self, message: str) -> None:
|
|
137
|
+
self.status(message, "ok")
|
|
138
|
+
self.notify(message, severity="success", timeout=4)
|
|
139
|
+
|
|
140
|
+
def notify_err(self, message: str) -> None:
|
|
141
|
+
self.status(message, "err")
|
|
142
|
+
self.notify(message, severity="error", timeout=8)
|
|
143
|
+
|
|
144
|
+
def notify_warn(self, message: str) -> None:
|
|
145
|
+
self.status(message, "warn")
|
|
146
|
+
self.notify(message, severity="warning", timeout=5)
|
|
147
|
+
|
|
148
|
+
# ------------------------------------------------------------ 退出
|
|
149
|
+
|
|
150
|
+
def action_quit(self) -> None:
|
|
151
|
+
"""全局退出(Ctrl+Q / 首页退出):有未完成任务时先确认。"""
|
|
152
|
+
if self._quitting or self._quit_pending:
|
|
153
|
+
return
|
|
154
|
+
queue = self.get_queue()
|
|
155
|
+
active = queue.active_count()
|
|
156
|
+
if active > 0:
|
|
157
|
+
self._quit_pending = True
|
|
158
|
+
self.push_screen(
|
|
159
|
+
ConfirmModal(
|
|
160
|
+
f"仍有 {active} 个任务进行中,退出将中断下载。确定退出吗?",
|
|
161
|
+
title="退出确认",
|
|
162
|
+
yes="退出",
|
|
163
|
+
no="继续下载",
|
|
164
|
+
default_yes=False,
|
|
165
|
+
),
|
|
166
|
+
self._on_quit_confirmed,
|
|
167
|
+
)
|
|
168
|
+
return
|
|
169
|
+
self._shutdown_and_exit()
|
|
170
|
+
|
|
171
|
+
def _on_quit_confirmed(self, confirmed: bool) -> None:
|
|
172
|
+
self._quit_pending = False
|
|
173
|
+
if confirmed:
|
|
174
|
+
self._shutdown_and_exit()
|
|
175
|
+
|
|
176
|
+
@work
|
|
177
|
+
async def _shutdown_and_exit(self) -> None:
|
|
178
|
+
self._quitting = True
|
|
179
|
+
try:
|
|
180
|
+
await self.get_queue().shutdown()
|
|
181
|
+
finally:
|
|
182
|
+
self.exit()
|
|
183
|
+
|
|
184
|
+
# ------------------------------------------------------------ 启动
|
|
185
|
+
|
|
186
|
+
def on_mount(self) -> None:
|
|
187
|
+
self._boot()
|
|
188
|
+
|
|
189
|
+
@work
|
|
190
|
+
async def _boot(self) -> None:
|
|
191
|
+
if self._booted:
|
|
192
|
+
return
|
|
193
|
+
self._booted = True
|
|
194
|
+
try:
|
|
195
|
+
warnings = self.reload_config()
|
|
196
|
+
except Exception as exc: # 配置层不应抛错,兜底避免白屏
|
|
197
|
+
self.notify_err(f"配置加载失败:{exc}")
|
|
198
|
+
warnings = []
|
|
199
|
+
for warning in warnings:
|
|
200
|
+
self.notify_warn(warning)
|
|
201
|
+
|
|
202
|
+
from .screens.home import HomeScreen
|
|
203
|
+
|
|
204
|
+
self.push_screen(HomeScreen())
|
|
205
|
+
|
|
206
|
+
queue = self.get_queue()
|
|
207
|
+
interrupted = queue.interrupted
|
|
208
|
+
if interrupted:
|
|
209
|
+
self.status(f"{len(interrupted)} 个任务上次被中断,等待确认续传", "warn")
|
|
210
|
+
resume = await self.push_screen_wait(
|
|
211
|
+
ConfirmModal(
|
|
212
|
+
f"检测到 {len(interrupted)} 个任务上次被中断,是否继续下载(保留 .part 断点续传)?",
|
|
213
|
+
title="恢复任务",
|
|
214
|
+
yes="继续下载",
|
|
215
|
+
no="稍后处理",
|
|
216
|
+
default_yes=True,
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
if resume:
|
|
220
|
+
count = queue.resume_interrupted()
|
|
221
|
+
self.notify_ok(f"已重新排队 {count} 个任务")
|
|
222
|
+
queue.start()
|
|
223
|
+
self._consume_queue_events()
|
|
224
|
+
|
|
225
|
+
@work(exclusive=True)
|
|
226
|
+
async def _consume_queue_events(self) -> None:
|
|
227
|
+
"""统一消费队列事件:每 200ms 批量 drain 一次,再分发给栈内各屏。
|
|
228
|
+
|
|
229
|
+
单消费者避免「队列页 / 历史页同时挂载时互相抢事件」;每屏按 task id
|
|
230
|
+
增量更新单元格,不做整表重建。
|
|
231
|
+
"""
|
|
232
|
+
queue = self.get_queue()
|
|
233
|
+
while True:
|
|
234
|
+
first = await queue.events.get()
|
|
235
|
+
batch = [first]
|
|
236
|
+
await asyncio.sleep(0.2)
|
|
237
|
+
while not queue.events.empty():
|
|
238
|
+
batch.append(queue.events.get_nowait())
|
|
239
|
+
for screen in list(self.screen_stack):
|
|
240
|
+
handler = getattr(screen, "apply_queue_events", None)
|
|
241
|
+
if not callable(handler):
|
|
242
|
+
continue
|
|
243
|
+
try:
|
|
244
|
+
handler(batch)
|
|
245
|
+
except Exception:
|
|
246
|
+
# 单个屏幕刷新失败不应中断全局事件消费,下一批事件或重进页面会自愈
|
|
247
|
+
continue
|
|
248
|
+
|
|
249
|
+
# ------------------------------------------------------------ 汇总文案
|
|
250
|
+
|
|
251
|
+
def queue_summary(self) -> str:
|
|
252
|
+
queue = self.get_queue()
|
|
253
|
+
counts = queue.counts()
|
|
254
|
+
parts = []
|
|
255
|
+
if counts["running"]:
|
|
256
|
+
parts.append(f"↓ {human_speed(queue.total_speed())}")
|
|
257
|
+
parts.append(f"{counts['running']} 进行中")
|
|
258
|
+
if counts["queued"]:
|
|
259
|
+
parts.append(f"{counts['queued']} 等待")
|
|
260
|
+
if counts["paused"]:
|
|
261
|
+
parts.append(f"{counts['paused']} 暂停")
|
|
262
|
+
if counts["failed"]:
|
|
263
|
+
parts.append(f"{counts['failed']} 失败")
|
|
264
|
+
if not parts:
|
|
265
|
+
parts.append(f"{counts['completed']} 已完成")
|
|
266
|
+
return " · ".join(parts)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
__all__ = ["YtdlpControllerApp", "StatusBar"]
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/* app.tcss — yt-dlp-controller 样式(复用 aliyun-controller 规范,按本项目裁剪) */
|
|
2
|
+
|
|
3
|
+
Screen {
|
|
4
|
+
background: $surface;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/* ---------- 按钮:全局单行(无边框;四项目一致) ---------- */
|
|
8
|
+
Button {
|
|
9
|
+
height: 1;
|
|
10
|
+
border: none;
|
|
11
|
+
min-width: 0;
|
|
12
|
+
padding: 0 1;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
Button:focus {
|
|
16
|
+
border: none;
|
|
17
|
+
text-style: bold reverse;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
Button:disabled {
|
|
21
|
+
text-opacity: 60%;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/* ---------- 模态三段式:标题栏 / 内容 / 按钮栏 ---------- */
|
|
25
|
+
.modal-box {
|
|
26
|
+
layout: vertical;
|
|
27
|
+
height: auto;
|
|
28
|
+
max-height: 90%;
|
|
29
|
+
border: round $primary;
|
|
30
|
+
background: $surface;
|
|
31
|
+
padding: 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.modal-title {
|
|
35
|
+
height: auto;
|
|
36
|
+
padding: 0 2;
|
|
37
|
+
border-bottom: hkey $secondary;
|
|
38
|
+
content-align: left middle;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.modal-main {
|
|
42
|
+
height: auto;
|
|
43
|
+
padding: 1 2;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.btn-row {
|
|
47
|
+
height: auto;
|
|
48
|
+
padding: 0 2;
|
|
49
|
+
border-top: hkey $secondary;
|
|
50
|
+
align-horizontal: right;
|
|
51
|
+
dock: bottom;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.btn-row Button {
|
|
55
|
+
margin-left: 2;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* ---------- 页面公共 ---------- */
|
|
59
|
+
.page-hint {
|
|
60
|
+
height: 1;
|
|
61
|
+
padding: 0 1;
|
|
62
|
+
color: $text-muted;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* ---------- 内容区小容器(筛选 / 排序 / 主操作按钮) ---------- */
|
|
66
|
+
.filter-row {
|
|
67
|
+
height: auto;
|
|
68
|
+
layout: horizontal;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.filter-row.gap-top {
|
|
72
|
+
margin-top: 1;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.filter-row .fl-label {
|
|
76
|
+
width: auto;
|
|
77
|
+
color: $text-muted;
|
|
78
|
+
margin-right: 1;
|
|
79
|
+
content-align: left middle;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.filter-row Input {
|
|
83
|
+
width: 1fr;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.filter-row .fill {
|
|
87
|
+
width: 1fr;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.filter-row Select {
|
|
91
|
+
margin-right: 2;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.filter-row Button {
|
|
95
|
+
margin-left: 2;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* ---------- 表格 ---------- */
|
|
99
|
+
.tbl {
|
|
100
|
+
height: 1fr;
|
|
101
|
+
margin: 1 1 0 1;
|
|
102
|
+
border: round $secondary;
|
|
103
|
+
overflow-x: hidden;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.tbl:focus {
|
|
107
|
+
border: round $primary;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/* ---------- 信息面板 ---------- */
|
|
111
|
+
.panel {
|
|
112
|
+
height: auto;
|
|
113
|
+
margin: 1 1 0 1;
|
|
114
|
+
padding: 1 2;
|
|
115
|
+
border: round $secondary;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/* 紧凑面板:80x28 下容纳三块内容而不触发外层滚动 */
|
|
119
|
+
.panel.tight {
|
|
120
|
+
padding: 0 1;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* ---------- 首页 ---------- */
|
|
124
|
+
#home-main {
|
|
125
|
+
height: 1fr;
|
|
126
|
+
align: center middle;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
#home-col {
|
|
130
|
+
width: auto;
|
|
131
|
+
max-width: 90%;
|
|
132
|
+
height: auto;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
#home-banner {
|
|
136
|
+
width: 1fr;
|
|
137
|
+
text-align: left;
|
|
138
|
+
color: $primary;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#home-plain {
|
|
142
|
+
width: 1fr;
|
|
143
|
+
text-align: center;
|
|
144
|
+
color: $primary;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
#home-version {
|
|
148
|
+
width: 1fr;
|
|
149
|
+
text-align: right;
|
|
150
|
+
color: $text-muted;
|
|
151
|
+
margin-bottom: 1;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
#home-list {
|
|
155
|
+
width: 1fr;
|
|
156
|
+
height: auto;
|
|
157
|
+
border: none;
|
|
158
|
+
background: transparent;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.logo-page #home-plain {
|
|
162
|
+
display: none;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.logo-page.-sm #home-banner {
|
|
166
|
+
display: none;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.logo-page.-sm #home-plain {
|
|
170
|
+
display: block;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.logo-page.-sm #home-col {
|
|
174
|
+
width: 1fr;
|
|
175
|
+
padding: 0 2;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/* ---------- 设置页 / 信息页:kv 条目 ---------- */
|
|
179
|
+
.kv-row {
|
|
180
|
+
height: 1;
|
|
181
|
+
layout: horizontal;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.kv-row.gap-top {
|
|
185
|
+
margin-top: 1;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.kv-label {
|
|
189
|
+
width: 18;
|
|
190
|
+
color: $text-muted;
|
|
191
|
+
content-align: left middle;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.kv-label.sm {
|
|
195
|
+
width: 10;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.kv-value {
|
|
199
|
+
width: 1fr;
|
|
200
|
+
content-align: left middle;
|
|
201
|
+
text-style: bold;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.kv-value.dim {
|
|
205
|
+
text-style: none;
|
|
206
|
+
color: $text-muted;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/* ---------- 下载选项 ---------- */
|
|
210
|
+
.panel .opt-row {
|
|
211
|
+
height: auto;
|
|
212
|
+
layout: horizontal;
|
|
213
|
+
align-vertical: middle;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
.panel .opt-row .opt-label {
|
|
217
|
+
width: auto;
|
|
218
|
+
color: $text-muted;
|
|
219
|
+
margin-right: 1;
|
|
220
|
+
content-align: left middle;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.panel .opt-row Select {
|
|
224
|
+
margin-right: 3;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/* ---------- 底部状态栏 ---------- */
|
|
228
|
+
#status {
|
|
229
|
+
height: 1;
|
|
230
|
+
dock: bottom;
|
|
231
|
+
padding: 0 1;
|
|
232
|
+
background: $panel;
|
|
233
|
+
color: $text;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
#status.ok { color: $success; }
|
|
237
|
+
#status.warn { color: $warning; }
|
|
238
|
+
#status.err { color: $error; }
|