kryten-webqueue 0.1.1__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.
- kryten_webqueue/__init__.py +0 -0
- kryten_webqueue/__main__.py +10 -0
- kryten_webqueue/api_gate/__init__.py +0 -0
- kryten_webqueue/api_gate/client.py +113 -0
- kryten_webqueue/app.py +184 -0
- kryten_webqueue/auth/__init__.py +0 -0
- kryten_webqueue/auth/otp.py +10 -0
- kryten_webqueue/auth/rate_limit.py +29 -0
- kryten_webqueue/auth/session.py +40 -0
- kryten_webqueue/catalog/__init__.py +0 -0
- kryten_webqueue/catalog/db.py +562 -0
- kryten_webqueue/catalog/images.py +114 -0
- kryten_webqueue/catalog/sync.py +96 -0
- kryten_webqueue/config.py +46 -0
- kryten_webqueue/playlists/__init__.py +0 -0
- kryten_webqueue/playlists/fire.py +71 -0
- kryten_webqueue/playlists/importer.py +92 -0
- kryten_webqueue/playlists/scheduler.py +72 -0
- kryten_webqueue/queue/__init__.py +0 -0
- kryten_webqueue/queue/ordering.py +186 -0
- kryten_webqueue/queue/poller.py +43 -0
- kryten_webqueue/queue/shadow.py +116 -0
- kryten_webqueue/routes/__init__.py +0 -0
- kryten_webqueue/routes/admin_playlists.py +98 -0
- kryten_webqueue/routes/admin_queue.py +64 -0
- kryten_webqueue/routes/admin_schedules.py +129 -0
- kryten_webqueue/routes/auth.py +83 -0
- kryten_webqueue/routes/catalog.py +44 -0
- kryten_webqueue/routes/pages.py +82 -0
- kryten_webqueue/routes/queue.py +144 -0
- kryten_webqueue/routes/user.py +35 -0
- kryten_webqueue/static/css/main.css +470 -0
- kryten_webqueue/static/js/main.js +26 -0
- kryten_webqueue/templates/admin/index.html +98 -0
- kryten_webqueue/templates/auth/login.html +69 -0
- kryten_webqueue/templates/base.html +41 -0
- kryten_webqueue/templates/catalog/browse.html +105 -0
- kryten_webqueue/templates/queue/index.html +126 -0
- kryten_webqueue/templates/user/dashboard.html +87 -0
- kryten_webqueue/ws/__init__.py +0 -0
- kryten_webqueue/ws/handler.py +59 -0
- kryten_webqueue/ws/manager.py +57 -0
- kryten_webqueue-0.1.1.dist-info/METADATA +127 -0
- kryten_webqueue-0.1.1.dist-info/RECORD +46 -0
- kryten_webqueue-0.1.1.dist-info/WHEEL +4 -0
- kryten_webqueue-0.1.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from fastapi import APIRouter, Request, Depends, HTTPException
|
|
2
|
+
|
|
3
|
+
from ..auth.session import get_current_user
|
|
4
|
+
from ..queue.ordering import insert_pay_queue, insert_pay_playnext
|
|
5
|
+
|
|
6
|
+
router = APIRouter(prefix="/queue", tags=["queue"])
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@router.get("/state")
|
|
10
|
+
async def get_queue_state(request: Request, user: dict = Depends(get_current_user)):
|
|
11
|
+
"""Get current queue state."""
|
|
12
|
+
shadow = request.app.state.shadow
|
|
13
|
+
return shadow.get_queue_state()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@router.post("/add")
|
|
17
|
+
async def add_to_queue(request: Request, user: dict = Depends(get_current_user)):
|
|
18
|
+
"""Add an item to the pay queue (FIFO)."""
|
|
19
|
+
body = await request.json()
|
|
20
|
+
friendly_token = body.get("friendly_token")
|
|
21
|
+
tier = body.get("tier", "queue")
|
|
22
|
+
|
|
23
|
+
if not friendly_token:
|
|
24
|
+
raise HTTPException(400, "friendly_token required")
|
|
25
|
+
|
|
26
|
+
db = request.app.state.db
|
|
27
|
+
api_gate = request.app.state.api_gate
|
|
28
|
+
shadow = request.app.state.shadow
|
|
29
|
+
|
|
30
|
+
# Check pre-fire lock
|
|
31
|
+
if await db.is_pre_fire_lock_active():
|
|
32
|
+
raise HTTPException(423, "Queue is locked: scheduled playlist firing soon")
|
|
33
|
+
|
|
34
|
+
# Look up catalog item
|
|
35
|
+
item = await db.get_item(friendly_token)
|
|
36
|
+
if not item:
|
|
37
|
+
raise HTTPException(404, "Item not found in catalog")
|
|
38
|
+
|
|
39
|
+
# Preview cost
|
|
40
|
+
preview = await api_gate.queue_preview(
|
|
41
|
+
username=user["username"],
|
|
42
|
+
duration_sec=item["duration_sec"],
|
|
43
|
+
tier=tier,
|
|
44
|
+
)
|
|
45
|
+
if not preview.get("success"):
|
|
46
|
+
raise HTTPException(400, preview.get("error", "Cost preview failed"))
|
|
47
|
+
|
|
48
|
+
z_cost = preview["cost"]
|
|
49
|
+
|
|
50
|
+
result = await insert_pay_queue(
|
|
51
|
+
api_gate=api_gate,
|
|
52
|
+
shadow=shadow,
|
|
53
|
+
db=db,
|
|
54
|
+
username=user["username"],
|
|
55
|
+
media_type="cm",
|
|
56
|
+
media_id=friendly_token,
|
|
57
|
+
title=item["title"],
|
|
58
|
+
duration_sec=item["duration_sec"],
|
|
59
|
+
tier=tier,
|
|
60
|
+
z_cost=z_cost,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
if not result["success"]:
|
|
64
|
+
raise HTTPException(400, result.get("error", "Queue add failed"))
|
|
65
|
+
return result
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@router.post("/playnext")
|
|
69
|
+
async def play_next(request: Request, user: dict = Depends(get_current_user)):
|
|
70
|
+
"""Add item as play-next (premium tier)."""
|
|
71
|
+
body = await request.json()
|
|
72
|
+
friendly_token = body.get("friendly_token")
|
|
73
|
+
tier = "playnext"
|
|
74
|
+
|
|
75
|
+
if not friendly_token:
|
|
76
|
+
raise HTTPException(400, "friendly_token required")
|
|
77
|
+
|
|
78
|
+
db = request.app.state.db
|
|
79
|
+
api_gate = request.app.state.api_gate
|
|
80
|
+
shadow = request.app.state.shadow
|
|
81
|
+
|
|
82
|
+
# Check pre-fire lock
|
|
83
|
+
if await db.is_pre_fire_lock_active():
|
|
84
|
+
raise HTTPException(423, "Queue is locked: scheduled playlist firing soon")
|
|
85
|
+
|
|
86
|
+
# Look up catalog item
|
|
87
|
+
item = await db.get_item(friendly_token)
|
|
88
|
+
if not item:
|
|
89
|
+
raise HTTPException(404, "Item not found in catalog")
|
|
90
|
+
|
|
91
|
+
# Preview cost
|
|
92
|
+
preview = await api_gate.queue_preview(
|
|
93
|
+
username=user["username"],
|
|
94
|
+
duration_sec=item["duration_sec"],
|
|
95
|
+
tier=tier,
|
|
96
|
+
)
|
|
97
|
+
if not preview.get("success"):
|
|
98
|
+
raise HTTPException(400, preview.get("error", "Cost preview failed"))
|
|
99
|
+
|
|
100
|
+
z_cost = preview["cost"]
|
|
101
|
+
|
|
102
|
+
result = await insert_pay_playnext(
|
|
103
|
+
api_gate=api_gate,
|
|
104
|
+
shadow=shadow,
|
|
105
|
+
db=db,
|
|
106
|
+
username=user["username"],
|
|
107
|
+
media_type="cm",
|
|
108
|
+
media_id=friendly_token,
|
|
109
|
+
title=item["title"],
|
|
110
|
+
duration_sec=item["duration_sec"],
|
|
111
|
+
tier=tier,
|
|
112
|
+
z_cost=z_cost,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if not result["success"]:
|
|
116
|
+
raise HTTPException(400, result.get("error", "Playnext failed"))
|
|
117
|
+
return result
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@router.get("/preview")
|
|
121
|
+
async def cost_preview(request: Request, friendly_token: str, tier: str = "queue",
|
|
122
|
+
user: dict = Depends(get_current_user)):
|
|
123
|
+
"""Preview cost for queuing an item."""
|
|
124
|
+
db = request.app.state.db
|
|
125
|
+
api_gate = request.app.state.api_gate
|
|
126
|
+
|
|
127
|
+
item = await db.get_item(friendly_token)
|
|
128
|
+
if not item:
|
|
129
|
+
raise HTTPException(404, "Item not found")
|
|
130
|
+
|
|
131
|
+
preview = await api_gate.queue_preview(
|
|
132
|
+
username=user["username"],
|
|
133
|
+
duration_sec=item["duration_sec"],
|
|
134
|
+
tier=tier,
|
|
135
|
+
)
|
|
136
|
+
return preview
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@router.get("/history")
|
|
140
|
+
async def queue_history(request: Request, user: dict = Depends(get_current_user)):
|
|
141
|
+
"""Get user's queue history."""
|
|
142
|
+
db = request.app.state.db
|
|
143
|
+
history = await db.get_user_queue_history(user["username"])
|
|
144
|
+
return {"items": history}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from fastapi import APIRouter, Request, Depends
|
|
2
|
+
|
|
3
|
+
from ..auth.session import get_current_user
|
|
4
|
+
|
|
5
|
+
router = APIRouter(prefix="/user", tags=["user"])
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@router.get("/balance")
|
|
9
|
+
async def get_balance(request: Request, user: dict = Depends(get_current_user)):
|
|
10
|
+
"""Get user's economy balance."""
|
|
11
|
+
api_gate = request.app.state.api_gate
|
|
12
|
+
return await api_gate.get_balance(user["username"])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@router.get("/transactions")
|
|
16
|
+
async def get_transactions(request: Request, limit: int = 20, offset: int = 0,
|
|
17
|
+
user: dict = Depends(get_current_user)):
|
|
18
|
+
"""Get user's transaction history."""
|
|
19
|
+
api_gate = request.app.state.api_gate
|
|
20
|
+
return await api_gate.get_transactions(user["username"], limit=limit, offset=offset)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@router.get("/profile")
|
|
24
|
+
async def get_profile(request: Request, user: dict = Depends(get_current_user)):
|
|
25
|
+
"""Get current user profile info from api-gate."""
|
|
26
|
+
api_gate = request.app.state.api_gate
|
|
27
|
+
try:
|
|
28
|
+
user_data = await api_gate.get_user(user["username"])
|
|
29
|
+
except Exception:
|
|
30
|
+
user_data = {}
|
|
31
|
+
return {
|
|
32
|
+
"username": user["username"],
|
|
33
|
+
"rank": user["rank"],
|
|
34
|
+
"online": user_data.get("online", False) if user_data else False,
|
|
35
|
+
}
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
/* kryten-webqueue — Main Stylesheet */
|
|
2
|
+
|
|
3
|
+
:root {
|
|
4
|
+
--bg-primary: #0f0f14;
|
|
5
|
+
--bg-secondary: #1a1a24;
|
|
6
|
+
--bg-card: #22222e;
|
|
7
|
+
--bg-hover: #2a2a3a;
|
|
8
|
+
--text-primary: #e8e8f0;
|
|
9
|
+
--text-secondary: #9090a8;
|
|
10
|
+
--accent: #6c5ce7;
|
|
11
|
+
--accent-hover: #7f70f0;
|
|
12
|
+
--success: #00b894;
|
|
13
|
+
--warning: #fdcb6e;
|
|
14
|
+
--danger: #e17055;
|
|
15
|
+
--border: #33334a;
|
|
16
|
+
--radius: 8px;
|
|
17
|
+
--shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
* {
|
|
21
|
+
margin: 0;
|
|
22
|
+
padding: 0;
|
|
23
|
+
box-sizing: border-box;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
body {
|
|
27
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
28
|
+
background: var(--bg-primary);
|
|
29
|
+
color: var(--text-primary);
|
|
30
|
+
line-height: 1.6;
|
|
31
|
+
min-height: 100vh;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
a {
|
|
35
|
+
color: var(--accent);
|
|
36
|
+
text-decoration: none;
|
|
37
|
+
}
|
|
38
|
+
a:hover {
|
|
39
|
+
color: var(--accent-hover);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* Navbar */
|
|
43
|
+
.navbar {
|
|
44
|
+
display: flex;
|
|
45
|
+
justify-content: space-between;
|
|
46
|
+
align-items: center;
|
|
47
|
+
padding: 0.75rem 2rem;
|
|
48
|
+
background: var(--bg-secondary);
|
|
49
|
+
border-bottom: 1px solid var(--border);
|
|
50
|
+
position: sticky;
|
|
51
|
+
top: 0;
|
|
52
|
+
z-index: 100;
|
|
53
|
+
}
|
|
54
|
+
.nav-brand a {
|
|
55
|
+
font-size: 1.2rem;
|
|
56
|
+
font-weight: 700;
|
|
57
|
+
color: var(--text-primary);
|
|
58
|
+
}
|
|
59
|
+
.nav-links {
|
|
60
|
+
display: flex;
|
|
61
|
+
gap: 1.5rem;
|
|
62
|
+
}
|
|
63
|
+
.nav-links a {
|
|
64
|
+
color: var(--text-secondary);
|
|
65
|
+
font-size: 0.9rem;
|
|
66
|
+
}
|
|
67
|
+
.nav-links a:hover {
|
|
68
|
+
color: var(--text-primary);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* Container */
|
|
72
|
+
.container {
|
|
73
|
+
max-width: 1400px;
|
|
74
|
+
margin: 0 auto;
|
|
75
|
+
padding: 2rem;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* Buttons */
|
|
79
|
+
.btn {
|
|
80
|
+
display: inline-block;
|
|
81
|
+
padding: 0.5rem 1rem;
|
|
82
|
+
border: 1px solid var(--border);
|
|
83
|
+
border-radius: var(--radius);
|
|
84
|
+
background: var(--bg-card);
|
|
85
|
+
color: var(--text-primary);
|
|
86
|
+
font-size: 0.9rem;
|
|
87
|
+
cursor: pointer;
|
|
88
|
+
transition: all 0.2s;
|
|
89
|
+
}
|
|
90
|
+
.btn:hover {
|
|
91
|
+
background: var(--bg-hover);
|
|
92
|
+
}
|
|
93
|
+
.btn-primary {
|
|
94
|
+
background: var(--accent);
|
|
95
|
+
border-color: var(--accent);
|
|
96
|
+
color: white;
|
|
97
|
+
}
|
|
98
|
+
.btn-primary:hover {
|
|
99
|
+
background: var(--accent-hover);
|
|
100
|
+
}
|
|
101
|
+
.btn-danger {
|
|
102
|
+
background: var(--danger);
|
|
103
|
+
border-color: var(--danger);
|
|
104
|
+
color: white;
|
|
105
|
+
}
|
|
106
|
+
.btn-sm {
|
|
107
|
+
padding: 0.3rem 0.6rem;
|
|
108
|
+
font-size: 0.8rem;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* Catalog Grid */
|
|
112
|
+
.catalog-header {
|
|
113
|
+
margin-bottom: 1.5rem;
|
|
114
|
+
}
|
|
115
|
+
.catalog-header h1 {
|
|
116
|
+
margin-bottom: 1rem;
|
|
117
|
+
}
|
|
118
|
+
.catalog-controls {
|
|
119
|
+
display: flex;
|
|
120
|
+
gap: 1rem;
|
|
121
|
+
align-items: center;
|
|
122
|
+
flex-wrap: wrap;
|
|
123
|
+
}
|
|
124
|
+
.search-form {
|
|
125
|
+
display: flex;
|
|
126
|
+
gap: 0.5rem;
|
|
127
|
+
}
|
|
128
|
+
.search-form input {
|
|
129
|
+
padding: 0.5rem 1rem;
|
|
130
|
+
border: 1px solid var(--border);
|
|
131
|
+
border-radius: var(--radius);
|
|
132
|
+
background: var(--bg-secondary);
|
|
133
|
+
color: var(--text-primary);
|
|
134
|
+
width: 300px;
|
|
135
|
+
}
|
|
136
|
+
.category-filter select {
|
|
137
|
+
padding: 0.5rem;
|
|
138
|
+
border: 1px solid var(--border);
|
|
139
|
+
border-radius: var(--radius);
|
|
140
|
+
background: var(--bg-secondary);
|
|
141
|
+
color: var(--text-primary);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.catalog-grid {
|
|
145
|
+
display: grid;
|
|
146
|
+
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
147
|
+
gap: 1.5rem;
|
|
148
|
+
}
|
|
149
|
+
.catalog-card {
|
|
150
|
+
background: var(--bg-card);
|
|
151
|
+
border-radius: var(--radius);
|
|
152
|
+
overflow: hidden;
|
|
153
|
+
transition: transform 0.2s, box-shadow 0.2s;
|
|
154
|
+
}
|
|
155
|
+
.catalog-card:hover {
|
|
156
|
+
transform: translateY(-4px);
|
|
157
|
+
box-shadow: var(--shadow);
|
|
158
|
+
}
|
|
159
|
+
.card-poster {
|
|
160
|
+
aspect-ratio: 2/3;
|
|
161
|
+
overflow: hidden;
|
|
162
|
+
background: var(--bg-secondary);
|
|
163
|
+
}
|
|
164
|
+
.card-poster img {
|
|
165
|
+
width: 100%;
|
|
166
|
+
height: 100%;
|
|
167
|
+
object-fit: cover;
|
|
168
|
+
}
|
|
169
|
+
.card-poster-placeholder {
|
|
170
|
+
width: 100%;
|
|
171
|
+
height: 100%;
|
|
172
|
+
display: flex;
|
|
173
|
+
align-items: center;
|
|
174
|
+
justify-content: center;
|
|
175
|
+
font-size: 3rem;
|
|
176
|
+
color: var(--text-secondary);
|
|
177
|
+
background: linear-gradient(135deg, var(--bg-secondary), var(--bg-card));
|
|
178
|
+
}
|
|
179
|
+
.card-info {
|
|
180
|
+
padding: 0.75rem;
|
|
181
|
+
}
|
|
182
|
+
.card-title {
|
|
183
|
+
font-size: 0.85rem;
|
|
184
|
+
font-weight: 600;
|
|
185
|
+
margin-bottom: 0.25rem;
|
|
186
|
+
white-space: nowrap;
|
|
187
|
+
overflow: hidden;
|
|
188
|
+
text-overflow: ellipsis;
|
|
189
|
+
}
|
|
190
|
+
.card-duration {
|
|
191
|
+
font-size: 0.75rem;
|
|
192
|
+
color: var(--text-secondary);
|
|
193
|
+
}
|
|
194
|
+
.card-actions {
|
|
195
|
+
padding: 0 0.75rem 0.75rem;
|
|
196
|
+
display: flex;
|
|
197
|
+
gap: 0.5rem;
|
|
198
|
+
}
|
|
199
|
+
.btn-queue {
|
|
200
|
+
background: var(--accent);
|
|
201
|
+
border-color: var(--accent);
|
|
202
|
+
color: white;
|
|
203
|
+
}
|
|
204
|
+
.btn-playnext {
|
|
205
|
+
background: var(--warning);
|
|
206
|
+
border-color: var(--warning);
|
|
207
|
+
color: #333;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/* Queue Page */
|
|
211
|
+
.queue-layout {
|
|
212
|
+
display: grid;
|
|
213
|
+
grid-template-columns: 1fr 2fr 250px;
|
|
214
|
+
gap: 2rem;
|
|
215
|
+
}
|
|
216
|
+
@media (max-width: 900px) {
|
|
217
|
+
.queue-layout {
|
|
218
|
+
grid-template-columns: 1fr;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.now-playing-card {
|
|
223
|
+
background: var(--bg-card);
|
|
224
|
+
border-radius: var(--radius);
|
|
225
|
+
padding: 1.5rem;
|
|
226
|
+
}
|
|
227
|
+
.np-info h3 {
|
|
228
|
+
margin-bottom: 0.5rem;
|
|
229
|
+
}
|
|
230
|
+
.np-progress {
|
|
231
|
+
height: 4px;
|
|
232
|
+
background: var(--border);
|
|
233
|
+
border-radius: 2px;
|
|
234
|
+
overflow: hidden;
|
|
235
|
+
margin: 0.5rem 0;
|
|
236
|
+
}
|
|
237
|
+
.progress-bar {
|
|
238
|
+
height: 100%;
|
|
239
|
+
background: var(--accent);
|
|
240
|
+
transition: width 1s linear;
|
|
241
|
+
}
|
|
242
|
+
.np-time {
|
|
243
|
+
font-size: 0.8rem;
|
|
244
|
+
color: var(--text-secondary);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
.queue-list {
|
|
248
|
+
display: flex;
|
|
249
|
+
flex-direction: column;
|
|
250
|
+
gap: 0.5rem;
|
|
251
|
+
}
|
|
252
|
+
.queue-item {
|
|
253
|
+
display: flex;
|
|
254
|
+
align-items: center;
|
|
255
|
+
gap: 0.75rem;
|
|
256
|
+
padding: 0.75rem 1rem;
|
|
257
|
+
background: var(--bg-card);
|
|
258
|
+
border-radius: var(--radius);
|
|
259
|
+
border-left: 3px solid transparent;
|
|
260
|
+
}
|
|
261
|
+
.queue-item-paid {
|
|
262
|
+
border-left-color: var(--accent);
|
|
263
|
+
}
|
|
264
|
+
.qi-pos {
|
|
265
|
+
font-size: 0.75rem;
|
|
266
|
+
color: var(--text-secondary);
|
|
267
|
+
min-width: 1.5rem;
|
|
268
|
+
}
|
|
269
|
+
.qi-title {
|
|
270
|
+
flex: 1;
|
|
271
|
+
font-size: 0.9rem;
|
|
272
|
+
white-space: nowrap;
|
|
273
|
+
overflow: hidden;
|
|
274
|
+
text-overflow: ellipsis;
|
|
275
|
+
}
|
|
276
|
+
.qi-duration {
|
|
277
|
+
font-size: 0.8rem;
|
|
278
|
+
color: var(--text-secondary);
|
|
279
|
+
}
|
|
280
|
+
.qi-badge {
|
|
281
|
+
font-size: 0.7rem;
|
|
282
|
+
padding: 0.15rem 0.4rem;
|
|
283
|
+
border-radius: 3px;
|
|
284
|
+
background: var(--accent);
|
|
285
|
+
color: white;
|
|
286
|
+
}
|
|
287
|
+
.badge-paid {
|
|
288
|
+
background: var(--accent);
|
|
289
|
+
}
|
|
290
|
+
.qi-user {
|
|
291
|
+
font-size: 0.75rem;
|
|
292
|
+
color: var(--text-secondary);
|
|
293
|
+
}
|
|
294
|
+
.qi-eta {
|
|
295
|
+
font-size: 0.75rem;
|
|
296
|
+
color: var(--text-secondary);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/* WebSocket status */
|
|
300
|
+
.ws-connected {
|
|
301
|
+
color: var(--success);
|
|
302
|
+
}
|
|
303
|
+
.ws-disconnected {
|
|
304
|
+
color: var(--danger);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/* Auth */
|
|
308
|
+
.auth-container {
|
|
309
|
+
max-width: 400px;
|
|
310
|
+
margin: 4rem auto;
|
|
311
|
+
text-align: center;
|
|
312
|
+
}
|
|
313
|
+
.auth-container h1 {
|
|
314
|
+
margin-bottom: 1rem;
|
|
315
|
+
}
|
|
316
|
+
.auth-form {
|
|
317
|
+
display: flex;
|
|
318
|
+
flex-direction: column;
|
|
319
|
+
gap: 0.75rem;
|
|
320
|
+
margin-top: 1.5rem;
|
|
321
|
+
}
|
|
322
|
+
.auth-form input {
|
|
323
|
+
padding: 0.75rem 1rem;
|
|
324
|
+
border: 1px solid var(--border);
|
|
325
|
+
border-radius: var(--radius);
|
|
326
|
+
background: var(--bg-secondary);
|
|
327
|
+
color: var(--text-primary);
|
|
328
|
+
font-size: 1rem;
|
|
329
|
+
text-align: center;
|
|
330
|
+
}
|
|
331
|
+
.hidden {
|
|
332
|
+
display: none !important;
|
|
333
|
+
}
|
|
334
|
+
.error-msg {
|
|
335
|
+
color: var(--danger);
|
|
336
|
+
margin-top: 1rem;
|
|
337
|
+
}
|
|
338
|
+
.otp-sent-msg {
|
|
339
|
+
color: var(--success);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/* User Dashboard */
|
|
343
|
+
.dashboard-grid {
|
|
344
|
+
display: grid;
|
|
345
|
+
grid-template-columns: 1fr 1fr 1fr;
|
|
346
|
+
gap: 1.5rem;
|
|
347
|
+
margin-top: 1.5rem;
|
|
348
|
+
}
|
|
349
|
+
@media (max-width: 900px) {
|
|
350
|
+
.dashboard-grid {
|
|
351
|
+
grid-template-columns: 1fr;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
.balance-card, .history-card, .transactions-card {
|
|
355
|
+
background: var(--bg-card);
|
|
356
|
+
border-radius: var(--radius);
|
|
357
|
+
padding: 1.5rem;
|
|
358
|
+
}
|
|
359
|
+
.balance-amount {
|
|
360
|
+
font-size: 2rem;
|
|
361
|
+
font-weight: 700;
|
|
362
|
+
color: var(--accent);
|
|
363
|
+
margin-top: 0.5rem;
|
|
364
|
+
}
|
|
365
|
+
.history-item, .tx-item {
|
|
366
|
+
display: flex;
|
|
367
|
+
justify-content: space-between;
|
|
368
|
+
padding: 0.5rem 0;
|
|
369
|
+
border-bottom: 1px solid var(--border);
|
|
370
|
+
font-size: 0.85rem;
|
|
371
|
+
}
|
|
372
|
+
.tx-credit {
|
|
373
|
+
color: var(--success);
|
|
374
|
+
}
|
|
375
|
+
.tx-debit {
|
|
376
|
+
color: var(--danger);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/* Admin */
|
|
380
|
+
.admin-nav {
|
|
381
|
+
display: flex;
|
|
382
|
+
gap: 1rem;
|
|
383
|
+
margin-bottom: 2rem;
|
|
384
|
+
}
|
|
385
|
+
.admin-section {
|
|
386
|
+
margin-bottom: 2rem;
|
|
387
|
+
}
|
|
388
|
+
.admin-section h2 {
|
|
389
|
+
margin-bottom: 1rem;
|
|
390
|
+
}
|
|
391
|
+
.quick-actions {
|
|
392
|
+
display: flex;
|
|
393
|
+
gap: 1rem;
|
|
394
|
+
}
|
|
395
|
+
.admin-table {
|
|
396
|
+
width: 100%;
|
|
397
|
+
border-collapse: collapse;
|
|
398
|
+
font-size: 0.85rem;
|
|
399
|
+
}
|
|
400
|
+
.admin-table th, .admin-table td {
|
|
401
|
+
padding: 0.5rem;
|
|
402
|
+
text-align: left;
|
|
403
|
+
border-bottom: 1px solid var(--border);
|
|
404
|
+
}
|
|
405
|
+
.admin-table th {
|
|
406
|
+
color: var(--text-secondary);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/* Empty state */
|
|
410
|
+
.empty-state {
|
|
411
|
+
color: var(--text-secondary);
|
|
412
|
+
text-align: center;
|
|
413
|
+
padding: 2rem;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/* Pagination */
|
|
417
|
+
.pagination {
|
|
418
|
+
display: flex;
|
|
419
|
+
align-items: center;
|
|
420
|
+
justify-content: center;
|
|
421
|
+
gap: 1rem;
|
|
422
|
+
margin-top: 2rem;
|
|
423
|
+
}
|
|
424
|
+
.page-num {
|
|
425
|
+
color: var(--text-secondary);
|
|
426
|
+
font-size: 0.9rem;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/* Toast */
|
|
430
|
+
.toast {
|
|
431
|
+
position: fixed;
|
|
432
|
+
bottom: 2rem;
|
|
433
|
+
right: 2rem;
|
|
434
|
+
padding: 0.75rem 1.5rem;
|
|
435
|
+
border-radius: var(--radius);
|
|
436
|
+
background: var(--bg-card);
|
|
437
|
+
border: 1px solid var(--border);
|
|
438
|
+
color: var(--text-primary);
|
|
439
|
+
font-size: 0.9rem;
|
|
440
|
+
z-index: 1000;
|
|
441
|
+
animation: slideUp 0.3s ease;
|
|
442
|
+
}
|
|
443
|
+
.toast-success {
|
|
444
|
+
border-color: var(--success);
|
|
445
|
+
}
|
|
446
|
+
.toast-error {
|
|
447
|
+
border-color: var(--danger);
|
|
448
|
+
}
|
|
449
|
+
@keyframes slideUp {
|
|
450
|
+
from { transform: translateY(20px); opacity: 0; }
|
|
451
|
+
to { transform: translateY(0); opacity: 1; }
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/* Footer */
|
|
455
|
+
.footer {
|
|
456
|
+
text-align: center;
|
|
457
|
+
padding: 2rem;
|
|
458
|
+
color: var(--text-secondary);
|
|
459
|
+
font-size: 0.8rem;
|
|
460
|
+
border-top: 1px solid var(--border);
|
|
461
|
+
margin-top: 4rem;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/* Schedule info */
|
|
465
|
+
.schedule-info {
|
|
466
|
+
background: var(--bg-card);
|
|
467
|
+
border-radius: var(--radius);
|
|
468
|
+
padding: 1rem;
|
|
469
|
+
border-left: 3px solid var(--warning);
|
|
470
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/* kryten-webqueue — Main JavaScript */
|
|
2
|
+
|
|
3
|
+
// Toast notification system
|
|
4
|
+
function showToast(message, type = 'success') {
|
|
5
|
+
const toast = document.createElement('div');
|
|
6
|
+
toast.className = `toast toast-${type}`;
|
|
7
|
+
toast.textContent = message;
|
|
8
|
+
document.body.appendChild(toast);
|
|
9
|
+
setTimeout(() => {
|
|
10
|
+
toast.style.opacity = '0';
|
|
11
|
+
toast.style.transition = 'opacity 0.3s';
|
|
12
|
+
setTimeout(() => toast.remove(), 300);
|
|
13
|
+
}, 3000);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Logout handler
|
|
17
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
18
|
+
const logoutBtn = document.getElementById('logout-btn');
|
|
19
|
+
if (logoutBtn) {
|
|
20
|
+
logoutBtn.addEventListener('click', async (e) => {
|
|
21
|
+
e.preventDefault();
|
|
22
|
+
await fetch('/auth/logout', { method: 'POST' });
|
|
23
|
+
window.location.href = '/auth/login';
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
});
|