kryten-webqueue 0.14.2__tar.gz → 0.15.0__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 (104) hide show
  1. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/CHANGELOG.md +12 -0
  2. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/api_gate/client.py +15 -0
  4. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/catalog/db.py +10 -3
  5. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/routes/queue.py +8 -4
  6. kryten_webqueue-0.15.0/kryten_webqueue/routes/user.py +89 -0
  7. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/static/css/main.css +164 -0
  8. kryten_webqueue-0.15.0/kryten_webqueue/templates/user/dashboard.html +449 -0
  9. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/pyproject.toml +1 -1
  10. kryten_webqueue-0.14.2/kryten_webqueue/routes/user.py +0 -35
  11. kryten_webqueue-0.14.2/kryten_webqueue/templates/user/dashboard.html +0 -126
  12. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/.github/workflows/python-publish.yml +0 -0
  13. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/.github/workflows/release.yml +0 -0
  14. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/.gitignore +0 -0
  15. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/README.md +0 -0
  16. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/config.example.json +0 -0
  17. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/deploy/kryten-webqueue.service +0 -0
  18. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/deploy/nginx-queue.conf +0 -0
  19. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  20. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/docs/IMPL_API_GATE.md +0 -0
  21. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/docs/IMPL_ECONOMY.md +0 -0
  22. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  23. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/docs/IMPL_ROBOT.md +0 -0
  24. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  25. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/docs/PRE_PLAN_GAPS.md +0 -0
  26. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/docs/PRODUCT_PLAN.md +0 -0
  27. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  28. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/__init__.py +0 -0
  29. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/__main__.py +0 -0
  30. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  31. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/app.py +0 -0
  32. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/auth/__init__.py +0 -0
  33. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/auth/otp.py +0 -0
  34. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  35. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/auth/session.py +0 -0
  36. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/catalog/__init__.py +0 -0
  37. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/catalog/images.py +0 -0
  38. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/catalog/mediacms.py +0 -0
  39. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/catalog/sync.py +0 -0
  40. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/config.py +0 -0
  41. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/integrations/__init__.py +0 -0
  42. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  43. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  44. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  45. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  46. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  47. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  48. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  49. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  50. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/jobs/__init__.py +0 -0
  51. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  52. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/jobs/manager.py +0 -0
  53. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/jobs/tasks.py +0 -0
  54. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/playlists/__init__.py +0 -0
  55. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
  56. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/playlists/fire.py +0 -0
  57. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/playlists/importer.py +0 -0
  58. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/playlists/ordering.py +0 -0
  59. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/playlists/scheduler.py +0 -0
  60. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/promos/__init__.py +0 -0
  61. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/promos/director.py +0 -0
  62. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/queue/__init__.py +0 -0
  63. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/queue/ordering.py +0 -0
  64. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/queue/poller.py +0 -0
  65. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/queue/presence.py +0 -0
  66. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/queue/shadow.py +0 -0
  67. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/routes/__init__.py +0 -0
  68. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
  69. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
  70. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
  71. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/routes/admin_promos.py +0 -0
  72. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/routes/admin_queue.py +0 -0
  73. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
  74. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/routes/auth.py +0 -0
  75. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/routes/catalog.py +0 -0
  76. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/routes/pages.py +0 -0
  77. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/static/js/main.js +0 -0
  78. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/templates/admin/index.html +0 -0
  79. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
  80. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/templates/admin/promos.html +0 -0
  81. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  82. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
  83. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/templates/auth/login.html +0 -0
  84. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/templates/base.html +0 -0
  85. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
  86. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  87. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  88. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/templates/queue/index.html +0 -0
  89. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/ws/__init__.py +0 -0
  90. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/ws/handler.py +0 -0
  91. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/kryten_webqueue/ws/manager.py +0 -0
  92. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/tests/__init__.py +0 -0
  93. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/tests/test_config_persistence.py +0 -0
  94. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/tests/test_fetchurls_sharepoint.py +0 -0
  95. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/tests/test_phase1.py +0 -0
  96. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/tests/test_phase2_jobs.py +0 -0
  97. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/tests/test_phase3_jobs.py +0 -0
  98. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/tests/test_phase4_live_fixes.py +0 -0
  99. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/tests/test_playlist_import.py +0 -0
  100. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/tests/test_presence_refund.py +0 -0
  101. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/tests/test_promo_director.py +0 -0
  102. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/tests/test_promo_pool_exclusion.py +0 -0
  103. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/tests/test_queue_announce.py +0 -0
  104. {kryten_webqueue-0.14.2 → kryten_webqueue-0.15.0}/tests/test_save_results_to_playlist.py +0 -0
@@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.15.0] — 2026-06-14
10
+
11
+ ### Added
12
+
13
+ - **Account progression panel on the Z-Coin dashboard.** The left column now surfaces the user's economy account in full: current rank/level, a progress bar toward the next milestone (remaining Z + percent), active perks (including spend discount), and their purchased vanity items. Powered by the new api-gate `GET /economy/account/{username}` endpoint (economy `account.summary`), proxied via `GET /user/account`.
14
+ - **Vanity item editing dialogs.** Inline **Edit** buttons open dialogs to purchase/update the custom greeting (textarea, 200-char limit) and custom chat color (native color picker synced with a 6-digit hex field). Backed by `POST /user/vanity/greeting` and `POST /user/vanity/color`, which proxy the economy `vanity.set_greeting` / `vanity.set_color` commands. The username is taken from the authenticated session, never the request body.
15
+ - **Transaction credit/debit filter.** The Recent Transactions column gains an All / Credits / Debits toggle and a **Load more** control, with friendlier titles derived from each transaction's `trigger_id` (e.g. `presence.base` → "Watching reward", `spend.vanity.chat_color` → "Custom color") instead of raw `earn` labels.
16
+
17
+ ### Changed
18
+
19
+ - **Queue history pagination.** `GET /queue/history` now accepts `limit`/`offset` and returns a `total` count; the dashboard's middle column paginates through the full history with Prev/Next controls instead of showing only the most recent 20 entries.
20
+
9
21
  ## [0.14.2] — 2026-06-13
10
22
 
11
23
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.14.2
3
+ Version: 0.15.0
4
4
  Summary: Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube
5
5
  Author: grobertson
6
6
  License-Expression: MIT
@@ -93,6 +93,21 @@ class ApiGateClient:
93
93
  async def get_transactions(self, username: str, limit: int = 20, offset: int = 0) -> dict:
94
94
  return await self.get(f"/economy/transactions/{username}", limit=limit, offset=offset)
95
95
 
96
+ async def get_account_summary(self, username: str) -> dict:
97
+ return await self.get(f"/economy/account/{username}")
98
+
99
+ async def set_vanity_greeting(self, username: str, value: str) -> dict:
100
+ return await self.post("/economy/vanity/greeting", json={
101
+ "username": username,
102
+ "value": value,
103
+ })
104
+
105
+ async def set_vanity_color(self, username: str, value: str) -> dict:
106
+ return await self.post("/economy/vanity/color", json={
107
+ "username": username,
108
+ "value": value,
109
+ })
110
+
96
111
  async def queue_preview(self, username: str, duration_sec: int, tier: str = "queue") -> dict:
97
112
  return await self.post("/economy/queue-preview", json={
98
113
  "username": username,
@@ -893,12 +893,19 @@ class Database:
893
893
  [username, friendly_token, title, tier, z_cost],
894
894
  )
895
895
 
896
- async def get_user_queue_history(self, username: str, limit: int = 50) -> list[dict]:
896
+ async def get_user_queue_history(self, username: str, limit: int = 50, offset: int = 0) -> list[dict]:
897
897
  return await self._fetch_all(
898
- "SELECT * FROM queue_history WHERE username=? ORDER BY id DESC LIMIT ?",
899
- [username, limit],
898
+ "SELECT * FROM queue_history WHERE username=? ORDER BY id DESC LIMIT ? OFFSET ?",
899
+ [username, limit, offset],
900
900
  )
901
901
 
902
+ async def get_user_queue_history_count(self, username: str) -> int:
903
+ row = await self._fetch_one(
904
+ "SELECT COUNT(*) AS c FROM queue_history WHERE username=?",
905
+ [username],
906
+ )
907
+ return int(row["c"]) if row else 0
908
+
902
909
  # --- Saved playlists ---
903
910
 
904
911
  async def get_saved_playlists(self) -> list[dict]:
@@ -223,11 +223,15 @@ async def cost_preview(request: Request, friendly_token: str, tier: str = "queue
223
223
 
224
224
 
225
225
  @router.get("/history")
226
- async def queue_history(request: Request, user: dict = Depends(get_current_user)):
227
- """Get user's queue history."""
226
+ async def queue_history(request: Request, limit: int = 20, offset: int = 0,
227
+ user: dict = Depends(get_current_user)):
228
+ """Get user's queue history (paginated, most recent first)."""
229
+ limit = max(1, min(limit, 100))
230
+ offset = max(0, offset)
228
231
  db = request.app.state.db
229
- history = await db.get_user_queue_history(user["username"])
230
- return {"items": history}
232
+ items = await db.get_user_queue_history(user["username"], limit=limit, offset=offset)
233
+ total = await db.get_user_queue_history_count(user["username"])
234
+ return {"items": items, "total": total, "limit": limit, "offset": offset}
231
235
 
232
236
 
233
237
  @router.get("/next-schedule")
@@ -0,0 +1,89 @@
1
+ from fastapi import APIRouter, Request, Depends, HTTPException
2
+ from pydantic import BaseModel
3
+
4
+ from ..auth.session import get_current_user
5
+
6
+ router = APIRouter(prefix="/user", tags=["user"])
7
+
8
+
9
+ @router.get("/balance")
10
+ async def get_balance(request: Request, user: dict = Depends(get_current_user)):
11
+ """Get user's economy balance."""
12
+ api_gate = request.app.state.api_gate
13
+ return await api_gate.get_balance(user["username"])
14
+
15
+
16
+ @router.get("/account")
17
+ async def get_account(request: Request, user: dict = Depends(get_current_user)):
18
+ """Get the user's full economy account summary (rank, progress, perks, vanity)."""
19
+ api_gate = request.app.state.api_gate
20
+ return await api_gate.get_account_summary(user["username"])
21
+
22
+
23
+ class GreetingUpdate(BaseModel):
24
+ value: str
25
+
26
+
27
+ class ColorUpdate(BaseModel):
28
+ value: str
29
+
30
+
31
+ @router.post("/vanity/greeting")
32
+ async def set_vanity_greeting(
33
+ body: GreetingUpdate, request: Request, user: dict = Depends(get_current_user)
34
+ ):
35
+ """Purchase/update the current user's custom greeting."""
36
+ api_gate = request.app.state.api_gate
37
+ try:
38
+ return await api_gate.set_vanity_greeting(user["username"], body.value)
39
+ except Exception as exc: # noqa: BLE001
40
+ raise HTTPException(status_code=400, detail=_economy_error(exc)) from exc
41
+
42
+
43
+ @router.post("/vanity/color")
44
+ async def set_vanity_color(
45
+ body: ColorUpdate, request: Request, user: dict = Depends(get_current_user)
46
+ ):
47
+ """Purchase/update the current user's custom chat color (6-digit hex)."""
48
+ api_gate = request.app.state.api_gate
49
+ try:
50
+ return await api_gate.set_vanity_color(user["username"], body.value)
51
+ except Exception as exc: # noqa: BLE001
52
+ raise HTTPException(status_code=400, detail=_economy_error(exc)) from exc
53
+
54
+
55
+ def _economy_error(exc: Exception) -> str:
56
+ """Extract a human-readable message from an api-gate HTTP error."""
57
+ import httpx
58
+
59
+ if isinstance(exc, httpx.HTTPStatusError):
60
+ try:
61
+ detail = exc.response.json().get("detail")
62
+ if detail:
63
+ return str(detail)
64
+ except Exception: # noqa: BLE001
65
+ pass
66
+ return "Purchase failed. Please try again."
67
+
68
+
69
+ @router.get("/transactions")
70
+ async def get_transactions(request: Request, limit: int = 20, offset: int = 0,
71
+ user: dict = Depends(get_current_user)):
72
+ """Get user's transaction history."""
73
+ api_gate = request.app.state.api_gate
74
+ return await api_gate.get_transactions(user["username"], limit=limit, offset=offset)
75
+
76
+
77
+ @router.get("/profile")
78
+ async def get_profile(request: Request, user: dict = Depends(get_current_user)):
79
+ """Get current user profile info from api-gate."""
80
+ api_gate = request.app.state.api_gate
81
+ try:
82
+ user_data = await api_gate.get_user(user["username"])
83
+ except Exception:
84
+ user_data = {}
85
+ return {
86
+ "username": user["username"],
87
+ "rank": user["rank"],
88
+ "online": user_data.get("online", False) if user_data else False,
89
+ }
@@ -790,6 +790,170 @@ a.np-chip {
790
790
  color: var(--danger);
791
791
  }
792
792
 
793
+ /* Account rank + progression (dashboard left column) */
794
+ .account-rank {
795
+ margin-top: 1.25rem;
796
+ padding-top: 1.25rem;
797
+ border-top: 1px solid var(--border);
798
+ }
799
+ .rank-name {
800
+ font-size: 1.1rem;
801
+ font-weight: 700;
802
+ }
803
+ .rank-level {
804
+ font-size: 0.8rem;
805
+ color: var(--text-secondary);
806
+ margin-top: 0.15rem;
807
+ }
808
+ .rank-progress {
809
+ margin-top: 0.85rem;
810
+ }
811
+ .rank-progress-head, .rank-progress-foot {
812
+ display: flex;
813
+ justify-content: space-between;
814
+ font-size: 0.8rem;
815
+ color: var(--text-secondary);
816
+ }
817
+ .rank-progress-head {
818
+ margin-bottom: 0.35rem;
819
+ }
820
+ .rank-progress-foot {
821
+ margin-top: 0.35rem;
822
+ }
823
+ .progress-track {
824
+ height: 8px;
825
+ border-radius: 999px;
826
+ background: var(--bg-secondary, #1a1a24);
827
+ overflow: hidden;
828
+ border: 1px solid var(--border);
829
+ }
830
+ .progress-fill {
831
+ height: 100%;
832
+ background: linear-gradient(90deg, var(--accent), var(--accent-hover));
833
+ transition: width 0.4s ease;
834
+ }
835
+ .account-perks {
836
+ margin-top: 1.25rem;
837
+ }
838
+ .account-perks h3 {
839
+ font-size: 0.85rem;
840
+ color: var(--text-secondary);
841
+ margin-bottom: 0.4rem;
842
+ }
843
+ .perk-list {
844
+ list-style: none;
845
+ padding: 0;
846
+ margin: 0;
847
+ display: flex;
848
+ flex-direction: column;
849
+ gap: 0.2rem;
850
+ }
851
+ .perk-list li {
852
+ font-size: 0.85rem;
853
+ }
854
+ .perk-list li::before {
855
+ content: "✓ ";
856
+ color: var(--success);
857
+ }
858
+
859
+ /* Vanity items (dashboard left column) */
860
+ .vanity-section {
861
+ margin-top: 1.25rem;
862
+ padding-top: 1.25rem;
863
+ border-top: 1px solid var(--border);
864
+ }
865
+ .vanity-section h3 {
866
+ font-size: 0.85rem;
867
+ color: var(--text-secondary);
868
+ margin-bottom: 0.6rem;
869
+ }
870
+ .vanity-item {
871
+ margin-bottom: 0.85rem;
872
+ }
873
+ .vanity-row {
874
+ display: flex;
875
+ align-items: center;
876
+ justify-content: space-between;
877
+ gap: 0.5rem;
878
+ }
879
+ .vanity-label {
880
+ font-size: 0.85rem;
881
+ font-weight: 600;
882
+ }
883
+ .vanity-value {
884
+ font-size: 0.85rem;
885
+ color: var(--text-primary);
886
+ margin-top: 0.25rem;
887
+ word-break: break-word;
888
+ display: flex;
889
+ align-items: center;
890
+ gap: 0.4rem;
891
+ }
892
+ .vanity-unset {
893
+ color: var(--text-secondary);
894
+ font-style: italic;
895
+ }
896
+ .color-swatch {
897
+ display: inline-block;
898
+ width: 1rem;
899
+ height: 1rem;
900
+ border-radius: 3px;
901
+ border: 1px solid var(--border);
902
+ flex-shrink: 0;
903
+ }
904
+
905
+ /* Color picker dialog */
906
+ .color-picker-row {
907
+ display: flex;
908
+ align-items: center;
909
+ gap: 0.75rem;
910
+ margin: 0.5rem 0 1rem;
911
+ }
912
+ .color-picker-row input[type="color"] {
913
+ width: 48px;
914
+ height: 40px;
915
+ padding: 0;
916
+ border: 1px solid var(--border);
917
+ border-radius: var(--radius);
918
+ background: transparent;
919
+ cursor: pointer;
920
+ }
921
+ .color-hex {
922
+ flex: 1;
923
+ text-transform: uppercase;
924
+ }
925
+
926
+ /* Transaction filter toggle */
927
+ .tx-toggle {
928
+ display: flex;
929
+ gap: 0.25rem;
930
+ margin: 0.5rem 0 0.75rem;
931
+ }
932
+ .tx-toggle-btn {
933
+ flex: 1;
934
+ padding: 0.35rem 0.5rem;
935
+ font-size: 0.8rem;
936
+ background: var(--bg-secondary, #1a1a24);
937
+ color: var(--text-secondary);
938
+ border: 1px solid var(--border);
939
+ border-radius: var(--radius);
940
+ cursor: pointer;
941
+ }
942
+ .tx-toggle-btn.active {
943
+ background: var(--accent);
944
+ border-color: var(--accent);
945
+ color: #fff;
946
+ }
947
+
948
+ /* Dashboard history / transaction pagers */
949
+ .history-pager, .tx-pager {
950
+ display: flex;
951
+ align-items: center;
952
+ justify-content: center;
953
+ gap: 0.5rem;
954
+ margin-top: 1rem;
955
+ }
956
+
793
957
  /* Admin */
794
958
  .admin-nav {
795
959
  display: flex;