readloops 2.3.0__tar.gz → 2.3.1__tar.gz

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.
Files changed (49) hide show
  1. {readloops-2.3.0 → readloops-2.3.1}/PKG-INFO +16 -3
  2. {readloops-2.3.0 → readloops-2.3.1}/README.md +16 -3
  3. {readloops-2.3.0 → readloops-2.3.1}/app/api/words.py +17 -4
  4. {readloops-2.3.0 → readloops-2.3.1}/app/main.py +25 -3
  5. {readloops-2.3.0 → readloops-2.3.1}/app/services/ai.py +42 -26
  6. readloops-2.3.1/app/web/apple-touch-icon.png +0 -0
  7. {readloops-2.3.0 → readloops-2.3.1}/app/web/css/style.css +157 -11
  8. readloops-2.3.1/app/web/favicon-16.png +0 -0
  9. readloops-2.3.1/app/web/favicon-32.png +0 -0
  10. readloops-2.3.1/app/web/favicon.ico +0 -0
  11. readloops-2.3.1/app/web/icon-192.png +0 -0
  12. readloops-2.3.1/app/web/icon-512.png +0 -0
  13. readloops-2.3.1/app/web/icon-maskable-512.png +0 -0
  14. readloops-2.3.1/app/web/icon.svg +12 -0
  15. {readloops-2.3.0 → readloops-2.3.1}/app/web/index.html +19 -5
  16. {readloops-2.3.0 → readloops-2.3.1}/app/web/js/app.js +74 -19
  17. readloops-2.3.1/app/web/manifest.json +36 -0
  18. {readloops-2.3.0 → readloops-2.3.1}/pyproject.toml +1 -1
  19. {readloops-2.3.0 → readloops-2.3.1}/readloops.egg-info/PKG-INFO +16 -3
  20. {readloops-2.3.0 → readloops-2.3.1}/readloops.egg-info/SOURCES.txt +7 -0
  21. readloops-2.3.0/app/web/icon.svg +0 -14
  22. readloops-2.3.0/app/web/manifest.json +0 -24
  23. {readloops-2.3.0 → readloops-2.3.1}/LICENSE +0 -0
  24. {readloops-2.3.0 → readloops-2.3.1}/app/__init__.py +0 -0
  25. {readloops-2.3.0 → readloops-2.3.1}/app/api/__init__.py +0 -0
  26. {readloops-2.3.0 → readloops-2.3.1}/app/api/articles.py +0 -0
  27. {readloops-2.3.0 → readloops-2.3.1}/app/api/reading.py +0 -0
  28. {readloops-2.3.0 → readloops-2.3.1}/app/api/stats.py +0 -0
  29. {readloops-2.3.0 → readloops-2.3.1}/app/cli.py +0 -0
  30. {readloops-2.3.0 → readloops-2.3.1}/app/config.py +0 -0
  31. {readloops-2.3.0 → readloops-2.3.1}/app/database.py +0 -0
  32. {readloops-2.3.0 → readloops-2.3.1}/app/models.py +0 -0
  33. {readloops-2.3.0 → readloops-2.3.1}/app/services/__init__.py +0 -0
  34. {readloops-2.3.0 → readloops-2.3.1}/app/services/corpus.py +0 -0
  35. {readloops-2.3.0 → readloops-2.3.1}/app/services/dict_import.py +0 -0
  36. {readloops-2.3.0 → readloops-2.3.1}/app/services/similarity.py +0 -0
  37. {readloops-2.3.0 → readloops-2.3.1}/app/services/smart_test.py +0 -0
  38. {readloops-2.3.0 → readloops-2.3.1}/app/services/srs.py +0 -0
  39. {readloops-2.3.0 → readloops-2.3.1}/readloops.egg-info/dependency_links.txt +0 -0
  40. {readloops-2.3.0 → readloops-2.3.1}/readloops.egg-info/entry_points.txt +0 -0
  41. {readloops-2.3.0 → readloops-2.3.1}/readloops.egg-info/requires.txt +0 -0
  42. {readloops-2.3.0 → readloops-2.3.1}/readloops.egg-info/top_level.txt +0 -0
  43. {readloops-2.3.0 → readloops-2.3.1}/setup.cfg +0 -0
  44. {readloops-2.3.0 → readloops-2.3.1}/tests/test_api_endpoints.py +0 -0
  45. {readloops-2.3.0 → readloops-2.3.1}/tests/test_cli.py +0 -0
  46. {readloops-2.3.0 → readloops-2.3.1}/tests/test_database.py +0 -0
  47. {readloops-2.3.0 → readloops-2.3.1}/tests/test_similarity.py +0 -0
  48. {readloops-2.3.0 → readloops-2.3.1}/tests/test_smoke.py +0 -0
  49. {readloops-2.3.0 → readloops-2.3.1}/tests/test_srs.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: readloops
3
- Version: 2.3.0
3
+ Version: 2.3.1
4
4
  Summary: AI 驱动的英语阅读训练器:可理解输入 + FSRS 间隔重复
5
5
  Author: Fangyuan2711
6
6
  License: MIT
@@ -38,9 +38,22 @@ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
38
38
  Requires-Dist: pre-commit>=3.7; extra == "dev"
39
39
  Dynamic: license-file
40
40
 
41
- # ReadLoops
41
+ <p align="center">
42
+ <img src="https://raw.githubusercontent.com/pekoqq/ReadLoops/main/assets/logo.png" width="104" alt="ReadLoops logo">
43
+ </p>
42
44
 
43
- > AI 驱动的英语阅读训练器。基于「可理解输入(i+1)」+ 阅读驱动的 FSRS 间隔重复,面向大学英语四级(进阶六级)。
45
+ <h1 align="center">ReadLoops</h1>
46
+
47
+ <p align="center">
48
+ <a href="https://pypi.org/project/readloops/"><img alt="PyPI" src="https://img.shields.io/pypi/v/readloops"></a>
49
+ <a href="https://pypi.org/project/readloops/"><img alt="Python versions" src="https://img.shields.io/pypi/pyversions/readloops"></a>
50
+ <a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-green"></a>
51
+ <a href="https://github.com/pekoqq/ReadLoops/releases"><img alt="Release" src="https://img.shields.io/github/v/release/pekoqq/ReadLoops"></a>
52
+ </p>
53
+
54
+ <p align="center">
55
+ AI 驱动的英语阅读训练器。基于「可理解输入(i+1)」+ 阅读驱动的 FSRS 间隔重复,面向大学英语四级(进阶六级)。
56
+ </p>
44
57
 
45
58
  ## 它解决什么问题
46
59
 
@@ -1,6 +1,19 @@
1
- # ReadLoops
2
-
3
- > AI 驱动的英语阅读训练器。基于「可理解输入(i+1)」+ 阅读驱动的 FSRS 间隔重复,面向大学英语四级(进阶六级)。
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/pekoqq/ReadLoops/main/assets/logo.png" width="104" alt="ReadLoops logo">
3
+ </p>
4
+
5
+ <h1 align="center">ReadLoops</h1>
6
+
7
+ <p align="center">
8
+ <a href="https://pypi.org/project/readloops/"><img alt="PyPI" src="https://img.shields.io/pypi/v/readloops"></a>
9
+ <a href="https://pypi.org/project/readloops/"><img alt="Python versions" src="https://img.shields.io/pypi/pyversions/readloops"></a>
10
+ <a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-green"></a>
11
+ <a href="https://github.com/pekoqq/ReadLoops/releases"><img alt="Release" src="https://img.shields.io/github/v/release/pekoqq/ReadLoops"></a>
12
+ </p>
13
+
14
+ <p align="center">
15
+ AI 驱动的英语阅读训练器。基于「可理解输入(i+1)」+ 阅读驱动的 FSRS 间隔重复,面向大学英语四级(进阶六级)。
16
+ </p>
4
17
 
5
18
  ## 它解决什么问题
6
19
 
@@ -1,7 +1,9 @@
1
1
  """单词路由:查词、生词本、批量添加。"""
2
+ import asyncio
2
3
  import time
3
4
 
4
5
  from fastapi import APIRouter
6
+ from fastapi.responses import JSONResponse
5
7
  from pydantic import BaseModel
6
8
 
7
9
  from app.database import get_db
@@ -42,11 +44,22 @@ class TranslateRequest(BaseModel):
42
44
  async def translate(req: TranslateRequest):
43
45
  """翻译句子 / 短语。
44
46
 
45
- 划词选中多个单词时走这里 —— 词典查不到整句,
46
- 以前会直接显示「查询失败」。
47
+ 划词选中多个单词时走这里 —— 词典查不到整句,需要调 AI。
48
+ AI 调用是同步阻塞的,放到线程池避免卡住事件循环;失败返回 502 与原因,
49
+ 前端据此提示「点击重试」而不是笼统的「翻译失败」。
47
50
  """
48
- translation = translate_sentence(req.text)
49
- return {"text": req.text, "translation": translation, "ok": bool(translation)}
51
+ text = (req.text or "").strip()
52
+ if not text:
53
+ return {"text": "", "translation": "", "ok": False, "error": "选中文本为空"}
54
+ try:
55
+ translation = await asyncio.to_thread(translate_sentence, text)
56
+ except Exception as exc:
57
+ return JSONResponse(
58
+ status_code=502,
59
+ content={"text": text, "translation": "", "ok": False,
60
+ "error": f"AI 服务暂时不可用:{str(exc)[:160]}"},
61
+ )
62
+ return {"text": text, "translation": translation, "ok": bool(translation)}
50
63
 
51
64
 
52
65
  @router.post("/add")
@@ -1,5 +1,6 @@
1
1
  """FastAPI 入口。"""
2
2
  import re
3
+ from importlib import metadata
3
4
 
4
5
  from fastapi import FastAPI
5
6
  from fastapi.responses import FileResponse, HTMLResponse
@@ -8,7 +9,12 @@ from fastapi.staticfiles import StaticFiles
8
9
  from app.api import articles, reading, stats, words
9
10
  from app.config import WEB_DIR
10
11
 
11
- app = FastAPI(title="ReadLoops", version="2.2.1")
12
+ try:
13
+ __version__ = metadata.version('readloops')
14
+ except metadata.PackageNotFoundError: # 源码直接运行、未 pip install 时
15
+ __version__ = '0.0.0.dev'
16
+
17
+ app = FastAPI(title="ReadLoops", version=__version__)
12
18
 
13
19
  # 注册路由
14
20
  app.include_router(articles.router)
@@ -53,9 +59,25 @@ async def manifest():
53
59
  return FileResponse(str(WEB_DIR / "manifest.json"))
54
60
 
55
61
 
56
- @app.get("/icon.svg")
62
+ @app.get("/icon.svg", include_in_schema=False)
57
63
  async def icon():
58
- return FileResponse(str(WEB_DIR / "icon.svg"))
64
+ return FileResponse(str(WEB_DIR / "icon.svg"), headers={"Cache-Control": "no-cache"})
65
+
66
+
67
+ # favicon / iOS 主屏 / PWA 各尺寸 PNG(与 manifest.json 中的路径对应)
68
+ _ICON_ROUTES = {
69
+ '/favicon.ico': 'favicon.ico',
70
+ '/favicon-16.png': 'favicon-16.png',
71
+ '/favicon-32.png': 'favicon-32.png',
72
+ '/apple-touch-icon.png': 'apple-touch-icon.png',
73
+ '/icon-192.png': 'icon-192.png',
74
+ '/icon-512.png': 'icon-512.png',
75
+ '/icon-maskable-512.png': 'icon-maskable-512.png',
76
+ }
77
+ for _url, _fname in _ICON_ROUTES.items():
78
+ def _icon_endpoint(_fname: str = _fname):
79
+ return FileResponse(str(WEB_DIR / _fname), headers={"Cache-Control": "no-cache"})
80
+ app.get(_url, include_in_schema=False)(_icon_endpoint)
59
81
 
60
82
 
61
83
  @app.get("/api/health")
@@ -22,29 +22,49 @@ def _get_settings():
22
22
  }
23
23
 
24
24
 
25
- def _chat(messages, max_tokens=1500, temperature=0.7):
26
- """调用 AI 聊天接口,关闭思考模式。"""
25
+ def _chat(messages, max_tokens=1500, temperature=0.7, retries=2):
26
+ """调用 AI 聊天接口,关闭思考模式。
27
+
28
+ deepseek-flash 等模型偶发:① 网络抖动 / 5xx;② 思考没被关住,正文为空、
29
+ 内容落到 reasoning_content。两类都按可恢复错误处理,最多重试 ``retries`` 次。
30
+ """
27
31
  cfg = _get_settings()
28
32
  if not cfg["api_key"]:
29
33
  raise ValueError("未配置 API Key")
30
- resp = httpx.post(
31
- f"{cfg['base_url'].rstrip('/')}/chat/completions",
32
- headers={
33
- "Authorization": f"Bearer {cfg['api_key']}",
34
- "Content-Type": "application/json",
35
- },
36
- json={
37
- "model": cfg["model"],
38
- "messages": messages,
39
- "max_tokens": max_tokens,
40
- "temperature": temperature,
41
- "thinking": {"type": "disabled"},
42
- },
43
- timeout=60,
44
- )
45
- resp.raise_for_status()
46
- data = resp.json()
47
- return data["choices"][0]["message"]["content"]
34
+ last_exc = None
35
+ for attempt in range(retries + 1):
36
+ try:
37
+ resp = httpx.post(
38
+ f"{cfg['base_url'].rstrip('/')}/chat/completions",
39
+ headers={
40
+ "Authorization": f"Bearer {cfg['api_key']}",
41
+ "Content-Type": "application/json",
42
+ },
43
+ json={
44
+ "model": cfg["model"],
45
+ "messages": messages,
46
+ "max_tokens": max_tokens,
47
+ "temperature": temperature,
48
+ "thinking": {"type": "disabled"},
49
+ },
50
+ timeout=60,
51
+ )
52
+ resp.raise_for_status()
53
+ data = resp.json()
54
+ message = data["choices"][0]["message"]
55
+ content = (message.get("content") or "").strip()
56
+ if not content:
57
+ # 正文为空通常是思考泄漏到 reasoning_content —— 关思考没生效,值得重试
58
+ reasoning = (message.get("reasoning_content") or "").strip()
59
+ raise ValueError(f"AI 返回内容为空(reasoning_content {len(reasoning)} 字)")
60
+ return content
61
+ except Exception as exc: # 网络 / HTTP / 空内容都走重试
62
+ last_exc = exc
63
+ if attempt < retries:
64
+ time.sleep(0.6 * (attempt + 1))
65
+ continue
66
+ raise
67
+ raise last_exc
48
68
 
49
69
 
50
70
  def _extract_json(text):
@@ -563,9 +583,5 @@ def translate_sentence(text):
563
583
  Return ONLY the translation itself — no explanation, no quotes, no pinyin.
564
584
 
565
585
  Text: {text}"""
566
- try:
567
- result = _chat([{"role": "user", "content": prompt}], max_tokens=500, temperature=0.3)
568
- return (result or "").strip().strip('"').strip("'")
569
- except Exception as e:
570
- print(f"翻译失败: {e}")
571
- return ""
586
+ result = _chat([{"role": "user", "content": prompt}], max_tokens=500, temperature=0.3)
587
+ return (result or "").strip().strip('"').strip("'")
@@ -51,6 +51,8 @@
51
51
  玻璃会显得很平;加入极淡光斑后,模糊才产生真正的层次感。 */
52
52
  --ambient-1: rgba(140,130,255,0.07);
53
53
  --ambient-2: rgba(255,160,100,0.045);
54
+ /* 专注模式:四周收暗的暗角色(把视线收向中央阅读卡),各主题单独配浓度 */
55
+ --focus-shade: rgba(0,0,0,0.42);
54
56
 
55
57
  /* ===== 按钮设计系统 =====
56
58
  全站按钮共用这套 token。之前每个按钮各写各的,
@@ -217,6 +219,7 @@
217
219
  --ripple-color: rgba(60,45,20,0.12);
218
220
  --ambient-1: rgba(190,150,70,0.12);
219
221
  --ambient-2: rgba(130,95,50,0.07);
222
+ --focus-shade: rgba(58,42,18,0.26);
220
223
  }
221
224
 
222
225
  /* 明亮 — 柔和米白,不刺眼 */
@@ -236,6 +239,7 @@
236
239
  --ripple-color: rgba(0,0,0,0.11);
237
240
  --ambient-1: rgba(120,140,255,0.08);
238
241
  --ambient-2: rgba(255,180,120,0.07);
242
+ --focus-shade: rgba(35,38,52,0.15);
239
243
  }
240
244
 
241
245
  /* 纯黑 */
@@ -255,6 +259,7 @@
255
259
  --ripple-color: rgba(255,255,255,0.08);
256
260
  --ambient-1: rgba(140,130,255,0.09);
257
261
  --ambient-2: rgba(255,160,100,0.055);
262
+ --focus-shade: rgba(0,0,0,0.58);
258
263
  }
259
264
 
260
265
  * { margin: 0; padding: 0; box-sizing: border-box; }
@@ -270,17 +275,67 @@ body {
270
275
  font-size: 13px;
271
276
  }
272
277
 
273
- /* 氛围光层:固定在最底层,为毛玻璃提供可折射的内容 */
274
- body::before {
278
+ /* 氛围光层:两团极淡光斑固定在最底层,为毛玻璃提供可折射内容。
279
+ 拆成两个独立伪元素、用 transform 平移 —— CSS 渐变的圆心坐标无法平滑过渡,
280
+ 只有 transform 能让光源「滑动」。
281
+ 日常:左上冷光 + 右下暖光的对角光;进入专注:两团光平滑上移、在顶部汇聚成顶光。
282
+ 过渡延迟 .3s、持续 3s,与水纹涟漪(setTimeout 300 + rippleWave 3s)同时起落。 */
283
+ body::before, body::after {
275
284
  content: '';
285
+ position: fixed;
286
+ left: 0;
287
+ top: 0;
288
+ width: 64vw;
289
+ height: 64vh;
290
+ z-index: -1; /* 严格压在所有内容之下、body 底色之上的背景光层 */
291
+ pointer-events: none;
292
+ will-change: transform, filter, opacity;
293
+ filter: brightness(1);
294
+ opacity: 1;
295
+ transition: transform 0.6s var(--ease-out), filter 0.6s var(--ease-out),
296
+ opacity 0.6s var(--ease-out); /* 退出时跟手回位 */
297
+ }
298
+ body::before {
299
+ background: radial-gradient(closest-side, var(--ambient-1), transparent 72%);
300
+ transform: translate(-16vw, -22vh); /* 光斑中心≈视口 16% / 10% */
301
+ }
302
+ body::after {
303
+ background: radial-gradient(closest-side, var(--ambient-2), transparent 72%);
304
+ transform: translate(54vw, 58vh); /* 光斑中心≈视口 86% / 90% */
305
+ }
306
+ /* 进入专注:只留一整团统一的顶光居中铺在上方(放大一圈),另一团对角光淡出,
307
+ 避免顶部出现两个冷暖异色的「球」。延迟 .3s、时长 3s,与水纹涟漪同时落定。 */
308
+ body.focus-mode::before {
309
+ transform: translate(18vw, -38vh) scale(1.7);
310
+ filter: brightness(2.6);
311
+ transition: transform 3s 0.3s cubic-bezier(0.16, 1, 0.3, 1),
312
+ filter 3s 0.3s cubic-bezier(0.16, 1, 0.3, 1);
313
+ }
314
+ body.focus-mode::after {
315
+ opacity: 0; /* 第二团光淡出,顶部只剩一团 */
316
+ transition: opacity 2.4s 0.3s var(--ease-out);
317
+ }
318
+ @media (prefers-reduced-motion: reduce) {
319
+ body.focus-mode::before, body.focus-mode::after { transition-duration: 0.01s; transition-delay: 0s; }
320
+ }
321
+
322
+ /* 专注聚焦暗角:中央(阅读卡所在)保持通透,向四周、底部轻微压暗,收束视线。
323
+ 层级:背景光 -1 < 暗角 0 < 应用内容 .app(1)。 */
324
+ .focus-shade {
276
325
  position: fixed;
277
326
  inset: 0;
278
327
  z-index: 0;
279
328
  pointer-events: none;
280
- background:
281
- radial-gradient(58% 48% at 16% 10%, var(--ambient-1), transparent 68%),
282
- radial-gradient(52% 46% at 86% 90%, var(--ambient-2), transparent 70%);
283
- transition: background 0.4s ease;
329
+ opacity: 0;
330
+ background: radial-gradient(118% 82% at 50% 38%, transparent 50%, var(--focus-shade) 100%);
331
+ transition: opacity 0.6s var(--ease-out);
332
+ }
333
+ body.focus-mode .focus-shade {
334
+ opacity: 1;
335
+ transition: opacity 3s 0.3s cubic-bezier(0.16, 1, 0.3, 1); /* 与涟漪、顶光同时落定 */
336
+ }
337
+ @media (prefers-reduced-motion: reduce) {
338
+ body.focus-mode .focus-shade { transition-duration: 0.01s; transition-delay: 0s; }
284
339
  }
285
340
 
286
341
  /* 主题切换过渡:只在切换的那 420ms 内生效(JS 临时加类),
@@ -299,6 +354,8 @@ body.theme-transition *::after {
299
354
  /* 布局 */
300
355
  .app {
301
356
  display: flex;
357
+ position: relative;
358
+ z-index: 1; /* 抬到专注暗角(.focus-shade z:0)与背景光(z:-1)之上 */
302
359
  height: 100vh;
303
360
  transform: translateZ(0);
304
361
  transition: background 0.5s cubic-bezier(0.32, 0.72, 0, 1);
@@ -337,7 +394,9 @@ body.theme-transition *::after {
337
394
  }
338
395
 
339
396
  .sidebar-header {
340
- display: block;
397
+ display: flex;
398
+ align-items: center;
399
+ gap: 9px;
341
400
  padding: 18px 18px 12px;
342
401
  font-size: 13px;
343
402
  font-weight: 700;
@@ -348,11 +407,33 @@ body.theme-transition *::after {
348
407
  color: inherit;
349
408
  text-decoration: none;
350
409
  cursor: pointer;
351
- transition: opacity 0.2s var(--ease-out), letter-spacing 0.25s var(--ease-out);
410
+ transition: opacity 0.2s var(--ease-out);
352
411
  }
353
- .sidebar-header:hover {
354
- opacity: 0.72;
355
- letter-spacing: 0.11em;
412
+ /* logo 单色跟随当前主题文字色,不引入额外颜色 */
413
+ .sidebar-logo {
414
+ width: 20px;
415
+ height: 20px;
416
+ flex: none;
417
+ display: inline-flex;
418
+ transition: transform 0.18s var(--ease-out);
419
+ }
420
+ .sidebar-logo svg { width: 100%; height: 100%; display: block; }
421
+ .sidebar-title { transition: letter-spacing 0.25s var(--ease-out); }
422
+ .sidebar-header:hover { opacity: 0.85; }
423
+ .sidebar-header:hover .sidebar-title { letter-spacing: 0.11em; }
424
+ /* hover 时循环环顺时针转一圈并轻微放大,点题 ReadLoops;
425
+ 用 animation(而非 transition)避免鼠标离开时整圈倒转 */
426
+ .sidebar-header:hover .sidebar-logo svg {
427
+ animation: logoCycle 0.85s var(--ease-damped) forwards;
428
+ }
429
+ .sidebar-header:active .sidebar-logo { transform: scale(0.86); }
430
+ @keyframes logoCycle {
431
+ 0% { transform: rotate(0deg) scale(1); }
432
+ 70% { transform: rotate(310deg) scale(1.14); }
433
+ 100% { transform: rotate(360deg) scale(1.06); }
434
+ }
435
+ @media (prefers-reduced-motion: reduce) {
436
+ .sidebar-header:hover .sidebar-logo svg { animation: none; }
356
437
  }
357
438
  .sidebar-nav { padding: 8px 10px; flex: 1; overflow-y: auto; }
358
439
  .nav-section {
@@ -720,6 +801,23 @@ body.theme-transition *::after {
720
801
  /* 句子翻译模式:整句不该进生词本,隐藏该按钮 */
721
802
  .lookup-panel.sentence-mode #lpAdd { display: none; }
722
803
  .lookup-panel.sentence-mode { max-width: 380px; }
804
+ /* 整句翻译失败的提示与重试 */
805
+ .lookup-panel .translate-hint { font-size: 12px; color: var(--text-tertiary); margin-bottom: 8px; }
806
+ .lookup-panel .translate-retry-btn {
807
+ height: var(--btn-h-sm);
808
+ padding: var(--btn-pad-sm);
809
+ border-radius: var(--btn-radius);
810
+ border: 1px solid var(--glass-border-strong);
811
+ background: var(--glass);
812
+ color: var(--text-secondary);
813
+ cursor: pointer;
814
+ font-size: 11px;
815
+ font-weight: 500;
816
+ font-family: var(--mono);
817
+ transition: background 0.2s var(--ease-out), color 0.2s var(--ease-out), transform 0.18s var(--ease-spring);
818
+ }
819
+ .lookup-panel .translate-retry-btn:hover { background: var(--glass-strong); color: var(--text); transform: translateY(-1px); }
820
+ .lookup-panel .translate-retry-btn:active { transform: scale(0.97); }
723
821
  .lookup-panel .action-btn {
724
822
  height: var(--btn-h-sm);
725
823
  padding: var(--btn-pad-sm);
@@ -1862,6 +1960,54 @@ body.theme-transition *::after {
1862
1960
  .right-panel.show {
1863
1961
  transform: translateX(0);
1864
1962
  }
1963
+ /* 新手引导箭头:指向右侧抽屉的浮动小玻璃圆钮,触发抽屉后由 JS 加 .dismiss 淡出 */
1964
+ .right-guide-arrow {
1965
+ position: fixed;
1966
+ right: 18px;
1967
+ top: 50%;
1968
+ margin-top: -19px; /* 高 38 的一半:垂直居中;transform 留给显隐/退场动画 */
1969
+ width: 38px;
1970
+ height: 38px;
1971
+ z-index: 55; /* 在右边缘触发带(50)之上、抽屉面板(60)之下 */
1972
+ display: flex;
1973
+ align-items: center;
1974
+ justify-content: center;
1975
+ border-radius: 50%;
1976
+ background: var(--glass-strong);
1977
+ backdrop-filter: var(--glass-liquid-strong);
1978
+ -webkit-backdrop-filter: var(--glass-liquid-strong);
1979
+ border: 1px solid var(--glass-border-strong);
1980
+ box-shadow: var(--glass-inner-shadow), 0 6px 20px rgba(0,0,0,0.25);
1981
+ color: var(--text);
1982
+ opacity: 0;
1983
+ transform: translateX(10px) scale(0.9);
1984
+ transition: opacity 0.4s var(--ease-out), transform 0.45s var(--ease-damped);
1985
+ pointer-events: none;
1986
+ }
1987
+ .right-guide-arrow svg { width: 20px; height: 20px; }
1988
+ .right-guide-arrow.show {
1989
+ opacity: 1;
1990
+ transform: translateX(0) scale(1);
1991
+ }
1992
+ /* 箭头本体持续向右轻推,提示「右边有东西」;显隐交给外层 transform,互不打架 */
1993
+ .right-guide-arrow.show svg {
1994
+ animation: guideNudge 2.2s ease-in-out infinite;
1995
+ }
1996
+ .right-guide-arrow.dismiss {
1997
+ opacity: 0;
1998
+ transform: translateX(16px) scale(0.8);
1999
+ animation: none;
2000
+ }
2001
+ .right-guide-arrow.dismiss svg { animation: none; }
2002
+ @keyframes guideNudge {
2003
+ 0%, 100% { transform: translateX(0); }
2004
+ 50% { transform: translateX(3px); }
2005
+ }
2006
+ /* 专注模式追求极简,隐藏引导 */
2007
+ .app.focus-mode .right-guide-arrow { display: none; }
2008
+ @media (prefers-reduced-motion: reduce) {
2009
+ .right-guide-arrow.show svg { animation: none; }
2010
+ }
1865
2011
  .right-panel.show .right-panel-header {
1866
2012
  animation: slideInRight 0.4s 0.1s cubic-bezier(0.16, 1, 0.3, 1) both;
1867
2013
  }
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,12 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="ReadLoops">
2
+ <rect width="512" height="512" rx="104" fill="#1a1818"/>
3
+ <defs>
4
+ <linearGradient id="trail" gradientUnits="userSpaceOnUse" x1="382.9" y1="179.8" x2="299.3" y2="114.5">
5
+ <stop offset="0%" stop-color="#fdfcfc" stop-opacity="0.32"/>
6
+ <stop offset="55%" stop-color="#fdfcfc" stop-opacity="0.72"/>
7
+ <stop offset="100%" stop-color="#fdfcfc" stop-opacity="1"/>
8
+ </linearGradient>
9
+ </defs>
10
+ <path d="M 382.86 179.77 A 148 148 0 1 1 299.27 114.47" fill="none" stroke="url(#trail)" stroke-width="38" stroke-linecap="round"/>
11
+ <path d="M 331.61 124.35 L 291.96 138.37 L 306.58 90.56 Z" fill="#fdfcfc" stroke-linejoin="round"/>
12
+ </svg>
@@ -5,12 +5,15 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>ReadLoops</title>
7
7
  <link rel="manifest" href="/manifest.json">
8
- <meta name="theme-color" content="#1a1a1c">
8
+ <meta name="theme-color" content="#1a1818">
9
9
  <meta name="apple-mobile-web-app-capable" content="yes">
10
10
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
11
+ <link rel="icon" href="/favicon.ico" sizes="32x32">
11
12
  <link rel="icon" type="image/svg+xml" href="/icon.svg">
12
- <link rel="apple-touch-icon" href="/icon.svg">
13
- <link rel="stylesheet" href="/static/css/style.css?v=37">
13
+ <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png">
14
+ <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png">
15
+ <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
16
+ <link rel="stylesheet" href="/static/css/style.css?v=42">
14
17
  </head>
15
18
  <body>
16
19
  <!-- 液态玻璃 SVG 滤镜(边缘折射效果) -->
@@ -33,7 +36,10 @@
33
36
  </svg>
34
37
  <div class="app">
35
38
  <aside class="sidebar" id="sidebar">
36
- <a class="sidebar-header" id="projectHomeLink" href="https://github.com/pekoqq/ReadLoops" target="_blank" rel="noopener noreferrer" title="在 GitHub 上查看项目">ReadLoops</a>
39
+ <a class="sidebar-header" id="projectHomeLink" href="https://github.com/pekoqq/ReadLoops" target="_blank" rel="noopener noreferrer" title="在 GitHub 上查看项目">
40
+ <span class="sidebar-logo" aria-hidden="true"><svg viewBox="0 0 512 512" fill="none"><path d="M 382.86 179.77 A 148 148 0 1 1 299.27 114.47" stroke="currentColor" stroke-width="38" stroke-linecap="round"/><path d="M 331.79 124.41 L 291.96 138.37 L 306.58 90.56 Z" fill="currentColor" stroke-linejoin="round"/></svg></span>
41
+ <span class="sidebar-title">ReadLoops</span>
42
+ </a>
37
43
  <nav class="sidebar-nav">
38
44
  <div class="nav-section">阅读</div>
39
45
  <div class="nav-item active" data-view="reader">阅读</div>
@@ -174,6 +180,14 @@
174
180
  </div>
175
181
  </div>
176
182
 
177
- <script src="/static/js/app.js?v=37"></script>
183
+ <!-- 专注聚焦遮罩:四周轻微收暗,把视线收向中央阅读卡(body.focus-mode 时淡入) -->
184
+ <div class="focus-shade" aria-hidden="true"></div>
185
+
186
+ <!-- 新手引导:浮动小玻璃箭头,指向右侧查词记录抽屉,触发一次抽屉后消失 -->
187
+ <div class="right-guide-arrow" id="rightGuideArrow" aria-hidden="true">
188
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6l6 6-6 6"/></svg>
189
+ </div>
190
+
191
+ <script src="/static/js/app.js?v=40"></script>
178
192
  </body>
179
193
  </html>
@@ -247,10 +247,14 @@ function toggleFocusMode() {
247
247
  }
248
248
  focusMode = !focusMode;
249
249
  document.querySelector('.app').classList.toggle('focus-mode', focusMode);
250
+ // body 同步切类:驱动氛围光层上移 / 回位(过渡与水纹涟漪对齐)
251
+ document.body.classList.toggle('focus-mode', focusMode);
250
252
  if (focusMode) {
251
253
  focusStartTime = Date.now();
252
254
  $('#focusBtn').textContent = '退出专注';
253
255
  $('#focusBtn').classList.add('active');
256
+ // 进入专注 = 开始沉浸阅读:有文章且计时未跑时同步开始计时
257
+ if (currentArticle && !timerRunning) startTimer();
254
258
  toast('已进入专注模式');
255
259
  // 触发水纹扩散引导
256
260
  setTimeout(triggerRipple, 300);
@@ -259,6 +263,10 @@ function toggleFocusMode() {
259
263
  focusTotalTime += duration;
260
264
  $('#focusBtn').textContent = '专注';
261
265
  $('#focusBtn').classList.remove('active');
266
+ // 退出专注 = 暂停阅读:同步暂停计时(再次进入会从当前读数继续)
267
+ if (timerRunning) pauseTimer();
268
+ // 已非专注,计时按钮回到「开始(即进入专注)」语义,而不是「继续」
269
+ $('#timerBtn').textContent = '开始 [空格]';
262
270
  const mins = Math.floor(duration / 60);
263
271
  const secs = duration % 60;
264
272
  toast(`本次专注 ${mins}分${secs}秒`);
@@ -369,11 +377,6 @@ document.addEventListener('keydown', (e) => {
369
377
  toggleFocusMode();
370
378
  return;
371
379
  }
372
- // Space 暂停/继续计时(不在输入框中时)
373
- if (e.key === ' ' && !['INPUT','TEXTAREA'].includes(e.target.tagName)) {
374
- e.preventDefault();
375
- toggleTimer();
376
- }
377
380
  });
378
381
 
379
382
  // 计时器
@@ -383,6 +386,8 @@ function formatTime(s) {
383
386
  return `${m}:${sec}`;
384
387
  }
385
388
  function startTimer() {
389
+ // 计时器与专注模式一体:非专注状态下一律不计时(根防线,任何入口都绕不过)
390
+ if (!focusMode) return;
386
391
  if (timerRunning) return;
387
392
  timerRunning = true;
388
393
  $('#timerBtn').textContent = '暂停 [空格]';
@@ -396,12 +401,22 @@ function pauseTimer() {
396
401
  clearInterval(timerInterval);
397
402
  $('#timerBtn').textContent = '继续 [空格]';
398
403
  }
399
- $('#timerBtn').addEventListener('click', () => timerRunning ? pauseTimer() : startTimer());
404
+ // 计时与专注一体:非专注时「开始计时」即进入专注模式;已在专注中则只做暂停 / 继续
405
+ function toggleTimerWithFocus() {
406
+ if (!focusMode) {
407
+ const view = document.querySelector('.nav-item.active')?.dataset.view;
408
+ if (view !== 'reader' || !currentArticle) return; // 非阅读页 / 还没文章:不启动
409
+ toggleFocusMode(); // 进入专注,其进入分支会负责 startTimer
410
+ } else {
411
+ timerRunning ? pauseTimer() : startTimer();
412
+ }
413
+ }
414
+ $('#timerBtn').addEventListener('click', toggleTimerWithFocus);
400
415
 
401
416
  document.addEventListener('keydown', (e) => {
402
417
  if (e.code === 'Space' && !['INPUT','SELECT','TEXTAREA'].includes(e.target.tagName)) {
403
418
  e.preventDefault();
404
- timerRunning ? pauseTimer() : startTimer();
419
+ toggleTimerWithFocus();
405
420
  }
406
421
  if (e.key === 'Escape') { hideLookupPanel(); closeSettings(); }
407
422
  });
@@ -669,7 +684,8 @@ document.addEventListener('mouseup', (e) => {
669
684
  if (lookupPanel.contains(e.target)) return;
670
685
  const sel = window.getSelection();
671
686
  const text = sel.toString().trim();
672
- if (!text || text.length > 60 || !/[a-zA-Z]/.test(text)) { hideLookupPanel(); return; }
687
+ // 上限 280:整句翻译需要容纳 1~2 个完整句子(原 60 稍长一点的句子直接不弹面板)
688
+ if (!text || text.length > 280 || !/[a-zA-Z]/.test(text)) { hideLookupPanel(); return; }
673
689
  if (!$('#reader').contains(sel.anchorNode)) { hideLookupPanel(); return; }
674
690
  currentSelection = text;
675
691
  showLookupPanel(e.clientX, e.clientY, text);
@@ -682,18 +698,14 @@ async function showLookupPanel(x, y, word) {
682
698
  $('#lpWord').textContent = word;
683
699
  $('#lpPhonetic').textContent = '';
684
700
  $('#lpMeaning').textContent = '查询中...';
685
- if (!timerRunning && currentArticle) startTimer();
701
+ // 计时由「划词自动进入专注」统一带动(mouseup → tryAutoFocus → 进专注即计时),
702
+ // 这里不再单独 startTimer,避免出现非专注计时
686
703
 
687
704
  // 选中多个单词 → 翻译整句;单个单词 → 查词典
688
705
  if (/\s/.test(word.trim())) {
689
706
  lookupPanel.classList.add('sentence-mode');
690
707
  lookupPanel.dataset.wordId = '';
691
- try {
692
- const res = await api.post('/api/words/translate', { text: word });
693
- $('#lpMeaning').textContent = res.translation || '翻译失败';
694
- } catch (err) {
695
- $('#lpMeaning').textContent = '翻译失败';
696
- }
708
+ requestSentenceTranslate(word);
697
709
  return;
698
710
  }
699
711
  lookupPanel.classList.remove('sentence-mode');
@@ -713,6 +725,34 @@ async function showLookupPanel(x, y, word) {
713
725
  }
714
726
  } catch (err) { $('#lpMeaning').textContent = '查询失败'; }
715
727
  }
728
+ // 整句翻译:独立函数,失败时在面板内给出「点击重试」而不是一句死掉的「翻译失败」
729
+ async function requestSentenceTranslate(word) {
730
+ const meaning = $('#lpMeaning');
731
+ meaning.textContent = '翻译中...';
732
+ try {
733
+ const res = await api.post('/api/words/translate', { text: word });
734
+ if (res && res.ok && res.translation) {
735
+ meaning.textContent = res.translation;
736
+ return;
737
+ }
738
+ renderTranslateRetry(word, res && res.error);
739
+ } catch (err) {
740
+ renderTranslateRetry(word, String(err));
741
+ }
742
+ }
743
+ function renderTranslateRetry(word, reason) {
744
+ if (reason) console.warn('整句翻译失败:', reason);
745
+ const meaning = $('#lpMeaning');
746
+ meaning.innerHTML = '';
747
+ const hint = document.createElement('div');
748
+ hint.className = 'translate-hint';
749
+ hint.textContent = '翻译失败(网络或 AI 服务波动)';
750
+ const btn = document.createElement('button');
751
+ btn.className = 'translate-retry-btn';
752
+ btn.textContent = '点击重试';
753
+ btn.addEventListener('click', () => requestSentenceTranslate(word));
754
+ meaning.append(hint, btn);
755
+ }
716
756
  function hideLookupPanel() { lookupPanel.classList.remove('show'); }
717
757
 
718
758
  $('#lpPronounce').addEventListener('click', () => {
@@ -954,9 +994,9 @@ $$('.nav-item').forEach(item => {
954
994
  <h2>ReadLoops</h2>
955
995
  <p>点击「生成文章」开始阅读训练</p>
956
996
  <div class="empty-hints">
957
- <span><kbd>空格</kbd>开始 / 暂停计时</span>
997
+ <span><kbd>空格</kbd>开始专注计时 · 暂停继续</span>
958
998
  <span><kbd>选中</kbd>查词 · 选整句翻译</span>
959
- <span><kbd>F</kbd>专注模式</span>
999
+ <span><kbd>F</kbd>切换专注模式</span>
960
1000
  </div>
961
1001
  </div>`;
962
1002
  }
@@ -1187,9 +1227,9 @@ async function loadVocab() {
1187
1227
  <h2>ReadLoops</h2>
1188
1228
  <p>点击「生成文章」开始阅读训练</p>
1189
1229
  <div class="empty-hints">
1190
- <span><kbd>空格</kbd>开始 / 暂停计时</span>
1230
+ <span><kbd>空格</kbd>开始专注计时 · 暂停继续</span>
1191
1231
  <span><kbd>选中</kbd>查词 · 选整句翻译</span>
1192
- <span><kbd>F</kbd>专注模式</span>
1232
+ <span><kbd>F</kbd>切换专注模式</span>
1193
1233
  </div>
1194
1234
  </div>`;
1195
1235
  }
@@ -1791,6 +1831,19 @@ const rightPanelTrigger = $('#rightPanelTrigger');
1791
1831
  let panelHoverTimer = null;
1792
1832
  let panelPinned = false; // 工具栏按钮打开时固定显示
1793
1833
 
1834
+ // 新手引导箭头:没触发过右侧抽屉才显示,首次把抽屉唤出后永久记住(不再打扰)
1835
+ const guideArrow = $('#rightGuideArrow');
1836
+ const GUIDE_SEEN_KEY = 'readloops_right_guide_seen';
1837
+ if (guideArrow && !localStorage.getItem(GUIDE_SEEN_KEY)) {
1838
+ setTimeout(() => guideArrow.classList.add('show'), 900);
1839
+ }
1840
+ function dismissGuideArrow() {
1841
+ if (!guideArrow || guideArrow.classList.contains('dismiss')) return;
1842
+ guideArrow.classList.remove('show');
1843
+ guideArrow.classList.add('dismiss');
1844
+ localStorage.setItem(GUIDE_SEEN_KEY, '1');
1845
+ }
1846
+
1794
1847
  // 鼠标移入右侧边缘触发(带防抖,避免快速划过误触)
1795
1848
  rightPanelTrigger.addEventListener('mouseenter', () => {
1796
1849
  clearTimeout(panelHoverTimer);
@@ -1798,6 +1851,7 @@ rightPanelTrigger.addEventListener('mouseenter', () => {
1798
1851
  if (!panelPinned) {
1799
1852
  rightPanel.classList.add('show');
1800
1853
  loadLookupHistory();
1854
+ dismissGuideArrow(); // 用户已发现右侧抽屉,引导箭头退场
1801
1855
  }
1802
1856
  }, 120);
1803
1857
  });
@@ -1817,6 +1871,7 @@ rightPanel.addEventListener('mouseleave', () => {
1817
1871
  $('#lookupHistoryBtn').addEventListener('click', () => {
1818
1872
  rightPanel.classList.add('show');
1819
1873
  loadLookupHistory();
1874
+ dismissGuideArrow();
1820
1875
  });
1821
1876
 
1822
1877
  async function loadLookupHistory() {
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "ReadLoops",
3
+ "short_name": "ReadLoops",
4
+ "description": "AI 驱动的英语阅读训练器",
5
+ "start_url": "/",
6
+ "display": "standalone",
7
+ "background_color": "#1a1818",
8
+ "theme_color": "#1a1818",
9
+ "orientation": "any",
10
+ "icons": [
11
+ {
12
+ "src": "/icon-192.png",
13
+ "sizes": "192x192",
14
+ "type": "image/png",
15
+ "purpose": "any"
16
+ },
17
+ {
18
+ "src": "/icon-512.png",
19
+ "sizes": "512x512",
20
+ "type": "image/png",
21
+ "purpose": "any"
22
+ },
23
+ {
24
+ "src": "/icon-maskable-512.png",
25
+ "sizes": "512x512",
26
+ "type": "image/png",
27
+ "purpose": "maskable"
28
+ },
29
+ {
30
+ "src": "/icon.svg",
31
+ "sizes": "any",
32
+ "type": "image/svg+xml",
33
+ "purpose": "any"
34
+ }
35
+ ]
36
+ }
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "readloops"
7
- version = "2.3.0"
7
+ version = "2.3.1"
8
8
  description = "AI 驱动的英语阅读训练器:可理解输入 + FSRS 间隔重复"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: readloops
3
- Version: 2.3.0
3
+ Version: 2.3.1
4
4
  Summary: AI 驱动的英语阅读训练器:可理解输入 + FSRS 间隔重复
5
5
  Author: Fangyuan2711
6
6
  License: MIT
@@ -38,9 +38,22 @@ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
38
38
  Requires-Dist: pre-commit>=3.7; extra == "dev"
39
39
  Dynamic: license-file
40
40
 
41
- # ReadLoops
41
+ <p align="center">
42
+ <img src="https://raw.githubusercontent.com/pekoqq/ReadLoops/main/assets/logo.png" width="104" alt="ReadLoops logo">
43
+ </p>
42
44
 
43
- > AI 驱动的英语阅读训练器。基于「可理解输入(i+1)」+ 阅读驱动的 FSRS 间隔重复,面向大学英语四级(进阶六级)。
45
+ <h1 align="center">ReadLoops</h1>
46
+
47
+ <p align="center">
48
+ <a href="https://pypi.org/project/readloops/"><img alt="PyPI" src="https://img.shields.io/pypi/v/readloops"></a>
49
+ <a href="https://pypi.org/project/readloops/"><img alt="Python versions" src="https://img.shields.io/pypi/pyversions/readloops"></a>
50
+ <a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-green"></a>
51
+ <a href="https://github.com/pekoqq/ReadLoops/releases"><img alt="Release" src="https://img.shields.io/github/v/release/pekoqq/ReadLoops"></a>
52
+ </p>
53
+
54
+ <p align="center">
55
+ AI 驱动的英语阅读训练器。基于「可理解输入(i+1)」+ 阅读驱动的 FSRS 间隔重复,面向大学英语四级(进阶六级)。
56
+ </p>
44
57
 
45
58
  ## 它解决什么问题
46
59
 
@@ -19,6 +19,13 @@ app/services/dict_import.py
19
19
  app/services/similarity.py
20
20
  app/services/smart_test.py
21
21
  app/services/srs.py
22
+ app/web/apple-touch-icon.png
23
+ app/web/favicon-16.png
24
+ app/web/favicon-32.png
25
+ app/web/favicon.ico
26
+ app/web/icon-192.png
27
+ app/web/icon-512.png
28
+ app/web/icon-maskable-512.png
22
29
  app/web/icon.svg
23
30
  app/web/index.html
24
31
  app/web/manifest.json
@@ -1,14 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
2
- <defs>
3
- <linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
4
- <stop offset="0%" style="stop-color:#2d2d30"/>
5
- <stop offset="100%" style="stop-color:#1a1a1c"/>
6
- </linearGradient>
7
- </defs>
8
- <rect width="512" height="512" rx="96" fill="url(#bg)"/>
9
- <g transform="translate(256, 256)">
10
- <path d="M -90 -60 A 110 110 0 1 1 -90 60" fill="none" stroke="white" stroke-width="22" stroke-linecap="round" opacity="0.9"/>
11
- <polygon points="-90,80 -60,55 -120,55" fill="white" opacity="0.9"/>
12
- <path d="M 30 -30 L 30 30 M 30 30 L 70 0" fill="none" stroke="white" stroke-width="16" stroke-linecap="round" stroke-linejoin="round" opacity="0.7"/>
13
- </g>
14
- </svg>
@@ -1,24 +0,0 @@
1
- {
2
- "name": "ReadLoops",
3
- "short_name": "ReadLoops",
4
- "description": "AI 驱动的英语阅读训练器",
5
- "start_url": "/",
6
- "display": "standalone",
7
- "background_color": "#1a1a1c",
8
- "theme_color": "#1a1a1c",
9
- "orientation": "any",
10
- "icons": [
11
- {
12
- "src": "/icon.svg",
13
- "sizes": "any",
14
- "type": "image/svg+xml",
15
- "purpose": "any"
16
- },
17
- {
18
- "src": "/icon.svg",
19
- "sizes": "any",
20
- "type": "image/svg+xml",
21
- "purpose": "maskable"
22
- }
23
- ]
24
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes