kryten-webqueue 0.6.0__tar.gz → 0.6.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.
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/CHANGELOG.md +10 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/PKG-INFO +1 -1
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/routes/queue.py +38 -2
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/static/css/main.css +87 -0
- kryten_webqueue-0.6.1/kryten_webqueue/static/js/main.js +234 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/pyproject.toml +1 -1
- kryten_webqueue-0.6.0/kryten_webqueue/static/js/main.js +0 -132
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/.gitignore +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/README.md +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/config.example.json +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/api_gate/client.py +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/app.py +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/catalog/db.py +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/config.py +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/jobs/manager.py +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/playlists/fire.py +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/playlists/importer.py +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/playlists/scheduler.py +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/queue/ordering.py +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/queue/poller.py +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/queue/shadow.py +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/routes/admin_jobs.py +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/routes/admin_playlists.py +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/routes/admin_schedules.py +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/routes/catalog.py +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/routes/pages.py +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/templates/admin/index.html +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/templates/admin/playlists.html +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/templates/admin/schedules.html +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/templates/catalog/browse.html +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/templates/queue/index.html +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/ws/manager.py +0 -0
|
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
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
|
|
|
8
|
+
## [0.6.1] - 2026-06-05
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Receipt confirmation modal for Queue & Play Next** — Clicking **Queue** or **Play Next** now opens a receipt-style modal before spending. It calls `/queue/preview` and shows the item title, price, any rank discount, total cost, current balance, and balance after the transaction, then asks the user to confirm. Unavailable purchases (insufficient balance, cooldown, daily limit, blackout) disable the confirm button with an explanatory message.
|
|
13
|
+
- **Enriched `/queue/preview` receipt data** — The preview endpoint now returns the catalog `title`, `base_cost`, `discount_amount`, current `balance`, and `balance_after` in addition to the existing cost/discount fields. `base_cost` comes from the economy service when available and is otherwise derived from the discount percentage.
|
|
14
|
+
- **Shared modal styling** — Added CSS for `.modal-overlay`/`.modal-box`/`.modal-actions` (also styling the existing admin queue modal) plus a dedicated receipt table layout.
|
|
15
|
+
|
|
16
|
+
[0.6.1]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.6.1
|
|
17
|
+
|
|
8
18
|
## [0.6.0] - 2026-06-05
|
|
9
19
|
|
|
10
20
|
### Added
|
|
@@ -129,7 +129,12 @@ async def play_next(request: Request, user: dict = Depends(get_current_user)):
|
|
|
129
129
|
@router.get("/preview")
|
|
130
130
|
async def cost_preview(request: Request, friendly_token: str, tier: str = "queue",
|
|
131
131
|
user: dict = Depends(get_current_user)):
|
|
132
|
-
"""Preview cost
|
|
132
|
+
"""Preview the cost of queuing an item as a confirmation receipt.
|
|
133
|
+
|
|
134
|
+
Returns the catalog title, pricing breakdown (base cost, discount, total)
|
|
135
|
+
and the user's balance before/after the transaction so the UI can show a
|
|
136
|
+
receipt before the user confirms.
|
|
137
|
+
"""
|
|
133
138
|
db = request.app.state.db
|
|
134
139
|
api_gate = request.app.state.api_gate
|
|
135
140
|
|
|
@@ -142,7 +147,38 @@ async def cost_preview(request: Request, friendly_token: str, tier: str = "queue
|
|
|
142
147
|
duration_sec=item["duration_sec"],
|
|
143
148
|
tier=tier,
|
|
144
149
|
)
|
|
145
|
-
|
|
150
|
+
|
|
151
|
+
cost_z = preview.get("cost_z")
|
|
152
|
+
discount_pct = preview.get("discount_pct", 0) or 0
|
|
153
|
+
# base_cost is provided by newer economy builds; derive it as a fallback.
|
|
154
|
+
base_cost = preview.get("base_cost")
|
|
155
|
+
if base_cost is None and cost_z is not None:
|
|
156
|
+
if discount_pct and discount_pct < 100:
|
|
157
|
+
base_cost = round(cost_z / (1 - discount_pct / 100))
|
|
158
|
+
else:
|
|
159
|
+
base_cost = cost_z
|
|
160
|
+
discount_amount = (base_cost - cost_z) if (base_cost is not None and cost_z is not None) else 0
|
|
161
|
+
|
|
162
|
+
balance = None
|
|
163
|
+
try:
|
|
164
|
+
bal = await api_gate.get_balance(user["username"])
|
|
165
|
+
balance = bal.get("balance")
|
|
166
|
+
except Exception:
|
|
167
|
+
balance = None
|
|
168
|
+
|
|
169
|
+
balance_after = (balance - cost_z) if (balance is not None and cost_z is not None) else None
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
**preview,
|
|
173
|
+
"friendly_token": friendly_token,
|
|
174
|
+
"title": item["title"],
|
|
175
|
+
"duration_sec": item["duration_sec"],
|
|
176
|
+
"tier": tier,
|
|
177
|
+
"base_cost": base_cost,
|
|
178
|
+
"discount_amount": discount_amount,
|
|
179
|
+
"balance": balance,
|
|
180
|
+
"balance_after": balance_after,
|
|
181
|
+
}
|
|
146
182
|
|
|
147
183
|
|
|
148
184
|
@router.get("/history")
|
|
@@ -637,3 +637,90 @@ a:hover {
|
|
|
637
637
|
padding: 1rem;
|
|
638
638
|
border-left: 3px solid var(--warning);
|
|
639
639
|
}
|
|
640
|
+
|
|
641
|
+
/* Modal */
|
|
642
|
+
.modal-overlay {
|
|
643
|
+
position: fixed;
|
|
644
|
+
inset: 0;
|
|
645
|
+
background: rgba(0, 0, 0, 0.6);
|
|
646
|
+
display: flex;
|
|
647
|
+
align-items: center;
|
|
648
|
+
justify-content: center;
|
|
649
|
+
z-index: 2000;
|
|
650
|
+
padding: 1rem;
|
|
651
|
+
animation: fadeIn 0.15s ease;
|
|
652
|
+
}
|
|
653
|
+
@keyframes fadeIn {
|
|
654
|
+
from { opacity: 0; }
|
|
655
|
+
to { opacity: 1; }
|
|
656
|
+
}
|
|
657
|
+
.modal-box {
|
|
658
|
+
background: var(--bg-secondary);
|
|
659
|
+
border: 1px solid var(--border);
|
|
660
|
+
border-radius: var(--radius);
|
|
661
|
+
box-shadow: var(--shadow);
|
|
662
|
+
padding: 1.5rem;
|
|
663
|
+
width: 100%;
|
|
664
|
+
max-width: 420px;
|
|
665
|
+
}
|
|
666
|
+
.modal-box h3 {
|
|
667
|
+
margin-bottom: 1rem;
|
|
668
|
+
}
|
|
669
|
+
.modal-actions {
|
|
670
|
+
display: flex;
|
|
671
|
+
gap: 0.75rem;
|
|
672
|
+
justify-content: flex-end;
|
|
673
|
+
flex-wrap: wrap;
|
|
674
|
+
margin-top: 1.25rem;
|
|
675
|
+
}
|
|
676
|
+
.btn-secondary {
|
|
677
|
+
background: var(--bg-card);
|
|
678
|
+
color: var(--text-secondary);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/* Receipt modal */
|
|
682
|
+
.receipt-loading {
|
|
683
|
+
color: var(--text-secondary);
|
|
684
|
+
padding: 1rem 0;
|
|
685
|
+
text-align: center;
|
|
686
|
+
}
|
|
687
|
+
.receipt-table {
|
|
688
|
+
width: 100%;
|
|
689
|
+
border-collapse: collapse;
|
|
690
|
+
font-size: 0.9rem;
|
|
691
|
+
}
|
|
692
|
+
.receipt-table th,
|
|
693
|
+
.receipt-table td {
|
|
694
|
+
padding: 0.5rem 0;
|
|
695
|
+
border-bottom: 1px solid var(--border);
|
|
696
|
+
}
|
|
697
|
+
.receipt-table th {
|
|
698
|
+
text-align: left;
|
|
699
|
+
color: var(--text-secondary);
|
|
700
|
+
font-weight: 400;
|
|
701
|
+
}
|
|
702
|
+
.receipt-table td {
|
|
703
|
+
text-align: right;
|
|
704
|
+
font-variant-numeric: tabular-nums;
|
|
705
|
+
}
|
|
706
|
+
.receipt-discount td {
|
|
707
|
+
color: var(--success);
|
|
708
|
+
}
|
|
709
|
+
.receipt-total th,
|
|
710
|
+
.receipt-total td {
|
|
711
|
+
font-weight: 700;
|
|
712
|
+
color: var(--text-primary);
|
|
713
|
+
}
|
|
714
|
+
.receipt-negative td {
|
|
715
|
+
color: var(--danger);
|
|
716
|
+
}
|
|
717
|
+
.receipt-warning {
|
|
718
|
+
margin-top: 1rem;
|
|
719
|
+
padding: 0.6rem 0.75rem;
|
|
720
|
+
border-radius: var(--radius);
|
|
721
|
+
background: rgba(225, 112, 85, 0.12);
|
|
722
|
+
border: 1px solid var(--danger);
|
|
723
|
+
color: var(--danger);
|
|
724
|
+
font-size: 0.85rem;
|
|
725
|
+
}
|
|
726
|
+
|
|
@@ -0,0 +1,234 @@
|
|
|
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
|
+
});
|
|
27
|
+
|
|
28
|
+
// --- Time formatting (always in the browser's local timezone) ---
|
|
29
|
+
|
|
30
|
+
function formatLocalDateTime(iso) {
|
|
31
|
+
if (!iso) return '';
|
|
32
|
+
const d = new Date(iso);
|
|
33
|
+
if (isNaN(d.getTime())) return iso;
|
|
34
|
+
return d.toLocaleString([], {
|
|
35
|
+
year: 'numeric', month: 'short', day: 'numeric',
|
|
36
|
+
hour: '2-digit', minute: '2-digit'
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function formatLocalTime(iso) {
|
|
41
|
+
if (!iso) return '';
|
|
42
|
+
const d = new Date(iso);
|
|
43
|
+
if (isNaN(d.getTime())) return iso;
|
|
44
|
+
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// --- Shared queue actions (used by catalog browse and item detail) ---
|
|
48
|
+
|
|
49
|
+
function formatZ(amount) {
|
|
50
|
+
if (amount == null) return '—';
|
|
51
|
+
return Number(amount).toLocaleString() + ' Z';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Queue / Play Next now open a receipt-style confirmation modal first.
|
|
55
|
+
function queueItem(token) {
|
|
56
|
+
showReceiptModal(token, 'queue');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function playNext(token) {
|
|
60
|
+
showReceiptModal(token, 'playnext');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function showReceiptModal(token, tier) {
|
|
64
|
+
closeReceiptModal();
|
|
65
|
+
const overlay = document.createElement('div');
|
|
66
|
+
overlay.id = 'receipt-modal';
|
|
67
|
+
overlay.className = 'modal-overlay';
|
|
68
|
+
overlay.innerHTML = `
|
|
69
|
+
<div class="modal-box receipt-box" role="dialog" aria-modal="true">
|
|
70
|
+
<h3>${tier === 'playnext' ? 'Play Next' : 'Add to Queue'}</h3>
|
|
71
|
+
<div class="receipt-body"><p class="receipt-loading">Calculating cost…</p></div>
|
|
72
|
+
</div>`;
|
|
73
|
+
overlay.addEventListener('click', (e) => {
|
|
74
|
+
if (e.target === overlay) closeReceiptModal();
|
|
75
|
+
const action = e.target.getAttribute('data-action');
|
|
76
|
+
if (action === 'cancel') closeReceiptModal();
|
|
77
|
+
if (action === 'confirm') confirmQueueAction(token, tier);
|
|
78
|
+
});
|
|
79
|
+
document.body.appendChild(overlay);
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const resp = await fetch(
|
|
83
|
+
`/queue/preview?friendly_token=${encodeURIComponent(token)}&tier=${encodeURIComponent(tier)}`,
|
|
84
|
+
{ credentials: 'same-origin' }
|
|
85
|
+
);
|
|
86
|
+
const data = await resp.json();
|
|
87
|
+
if (!resp.ok) {
|
|
88
|
+
renderReceiptError(data.detail || `Could not load cost (${resp.status})`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
renderReceipt(data, tier);
|
|
92
|
+
} catch (e) {
|
|
93
|
+
renderReceiptError(`Network error: ${e.message}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function renderReceipt(data, tier) {
|
|
98
|
+
const body = document.querySelector('#receipt-modal .receipt-body');
|
|
99
|
+
if (!body) return;
|
|
100
|
+
|
|
101
|
+
const unavailable = data.available === false;
|
|
102
|
+
const discount = data.discount_amount || 0;
|
|
103
|
+
const discountPct = data.discount_pct || 0;
|
|
104
|
+
const insufficient = data.balance != null && data.cost_z != null && data.balance < data.cost_z;
|
|
105
|
+
|
|
106
|
+
let warning = '';
|
|
107
|
+
if (unavailable) {
|
|
108
|
+
warning = `<p class="receipt-warning">${escapeHtml(receiptErrorText(data.error_code))}</p>`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
body.innerHTML = `
|
|
112
|
+
<table class="receipt-table">
|
|
113
|
+
<tr><th>Item</th><td>${escapeHtml(data.title || 'Unknown')}</td></tr>
|
|
114
|
+
<tr><th>Price</th><td>${formatZ(data.base_cost)}</td></tr>
|
|
115
|
+
${discount > 0 ? `<tr class="receipt-discount"><th>Discount${discountPct ? ` (${discountPct}%)` : ''}</th><td>-${formatZ(discount)}</td></tr>` : ''}
|
|
116
|
+
<tr class="receipt-total"><th>Total</th><td>${formatZ(data.cost_z)}</td></tr>
|
|
117
|
+
<tr><th>Balance</th><td>${formatZ(data.balance)}</td></tr>
|
|
118
|
+
<tr class="${insufficient ? 'receipt-negative' : ''}"><th>Balance after</th><td>${formatZ(data.balance_after)}</td></tr>
|
|
119
|
+
</table>
|
|
120
|
+
${warning}
|
|
121
|
+
<div class="modal-actions">
|
|
122
|
+
<button class="btn btn-secondary" data-action="cancel">Cancel</button>
|
|
123
|
+
<button class="btn btn-primary" data-action="confirm" ${unavailable ? 'disabled' : ''}>
|
|
124
|
+
${tier === 'playnext' ? 'Confirm Play Next' : 'Confirm Queue'}
|
|
125
|
+
</button>
|
|
126
|
+
</div>`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function renderReceiptError(message) {
|
|
130
|
+
const body = document.querySelector('#receipt-modal .receipt-body');
|
|
131
|
+
if (!body) return;
|
|
132
|
+
body.innerHTML = `
|
|
133
|
+
<p class="receipt-warning">${escapeHtml(message)}</p>
|
|
134
|
+
<div class="modal-actions">
|
|
135
|
+
<button class="btn btn-secondary" data-action="cancel">Close</button>
|
|
136
|
+
</div>`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function receiptErrorText(code) {
|
|
140
|
+
switch (code) {
|
|
141
|
+
case 'insufficient_balance': return 'You do not have enough Z for this.';
|
|
142
|
+
case 'cooldown_active': return 'You are on cooldown. Try again shortly.';
|
|
143
|
+
case 'daily_limit_reached': return 'You have reached your daily queue limit.';
|
|
144
|
+
case 'blackout_active': return 'Queuing is temporarily disabled.';
|
|
145
|
+
default: return code ? `Unavailable: ${code}` : 'This item is currently unavailable.';
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function closeReceiptModal() {
|
|
150
|
+
const existing = document.getElementById('receipt-modal');
|
|
151
|
+
if (existing) existing.remove();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function confirmQueueAction(token, tier) {
|
|
155
|
+
closeReceiptModal();
|
|
156
|
+
const url = tier === 'playnext' ? '/queue/playnext' : '/queue/add';
|
|
157
|
+
const payload = tier === 'playnext'
|
|
158
|
+
? { friendly_token: token }
|
|
159
|
+
: { friendly_token: token, tier: 'queue' };
|
|
160
|
+
try {
|
|
161
|
+
const resp = await fetch(url, {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: { 'Content-Type': 'application/json' },
|
|
164
|
+
credentials: 'same-origin',
|
|
165
|
+
body: JSON.stringify(payload)
|
|
166
|
+
});
|
|
167
|
+
const data = await resp.json();
|
|
168
|
+
const okMsg = tier === 'playnext' ? 'Playing next!' : 'Added to queue!';
|
|
169
|
+
showToast(resp.ok ? okMsg : (data.detail || `Failed (${resp.status})`), resp.ok ? 'success' : 'error');
|
|
170
|
+
} catch (e) {
|
|
171
|
+
showToast(`Network error: ${e.message}`, 'error');
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function escapeHtml(str) {
|
|
176
|
+
const div = document.createElement('div');
|
|
177
|
+
div.textContent = str == null ? '' : str;
|
|
178
|
+
return div.innerHTML;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Admin queue: prompt for how to resolve position, then submit the chosen mode.
|
|
182
|
+
function queueAsAdmin(token) {
|
|
183
|
+
showAdminQueueModal(token);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function submitAdminQueue(token, mode) {
|
|
187
|
+
closeAdminQueueModal();
|
|
188
|
+
if (mode === 'cancel') return;
|
|
189
|
+
try {
|
|
190
|
+
const resp = await fetch('/admin/queue/add', {
|
|
191
|
+
method: 'POST',
|
|
192
|
+
headers: { 'Content-Type': 'application/json' },
|
|
193
|
+
credentials: 'same-origin',
|
|
194
|
+
body: JSON.stringify({ friendly_token: token, mode })
|
|
195
|
+
});
|
|
196
|
+
const data = await resp.json();
|
|
197
|
+
if (resp.ok) {
|
|
198
|
+
const extra = data.refunded ? ` (${data.refunded} refunded)` : '';
|
|
199
|
+
showToast(`Queued as admin!${extra}`);
|
|
200
|
+
} else {
|
|
201
|
+
showToast(data.detail || `Failed (${resp.status})`, 'error');
|
|
202
|
+
}
|
|
203
|
+
} catch (e) {
|
|
204
|
+
showToast(`Network error: ${e.message}`, 'error');
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function showAdminQueueModal(token) {
|
|
209
|
+
closeAdminQueueModal();
|
|
210
|
+
const overlay = document.createElement('div');
|
|
211
|
+
overlay.id = 'admin-queue-modal';
|
|
212
|
+
overlay.className = 'modal-overlay';
|
|
213
|
+
overlay.innerHTML = `
|
|
214
|
+
<div class="modal-box" role="dialog" aria-modal="true">
|
|
215
|
+
<h3>Queue as Admin</h3>
|
|
216
|
+
<p>How should this item be positioned?</p>
|
|
217
|
+
<div class="modal-actions">
|
|
218
|
+
<button class="btn btn-primary" data-mode="playnext_refund">Play next & refund all pending</button>
|
|
219
|
+
<button class="btn" data-mode="after_purchased">Play after all purchased items</button>
|
|
220
|
+
<button class="btn btn-secondary" data-mode="cancel">Cancel</button>
|
|
221
|
+
</div>
|
|
222
|
+
</div>`;
|
|
223
|
+
overlay.addEventListener('click', (e) => {
|
|
224
|
+
if (e.target === overlay) closeAdminQueueModal();
|
|
225
|
+
const mode = e.target.getAttribute('data-mode');
|
|
226
|
+
if (mode) submitAdminQueue(token, mode);
|
|
227
|
+
});
|
|
228
|
+
document.body.appendChild(overlay);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function closeAdminQueueModal() {
|
|
232
|
+
const existing = document.getElementById('admin-queue-modal');
|
|
233
|
+
if (existing) existing.remove();
|
|
234
|
+
}
|
|
@@ -1,132 +0,0 @@
|
|
|
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
|
-
});
|
|
27
|
-
|
|
28
|
-
// --- Time formatting (always in the browser's local timezone) ---
|
|
29
|
-
|
|
30
|
-
function formatLocalDateTime(iso) {
|
|
31
|
-
if (!iso) return '';
|
|
32
|
-
const d = new Date(iso);
|
|
33
|
-
if (isNaN(d.getTime())) return iso;
|
|
34
|
-
return d.toLocaleString([], {
|
|
35
|
-
year: 'numeric', month: 'short', day: 'numeric',
|
|
36
|
-
hour: '2-digit', minute: '2-digit'
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function formatLocalTime(iso) {
|
|
41
|
-
if (!iso) return '';
|
|
42
|
-
const d = new Date(iso);
|
|
43
|
-
if (isNaN(d.getTime())) return iso;
|
|
44
|
-
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// --- Shared queue actions (used by catalog browse and item detail) ---
|
|
48
|
-
|
|
49
|
-
async function queueItem(token) {
|
|
50
|
-
try {
|
|
51
|
-
const resp = await fetch('/queue/add', {
|
|
52
|
-
method: 'POST',
|
|
53
|
-
headers: { 'Content-Type': 'application/json' },
|
|
54
|
-
credentials: 'same-origin',
|
|
55
|
-
body: JSON.stringify({ friendly_token: token, tier: 'queue' })
|
|
56
|
-
});
|
|
57
|
-
const data = await resp.json();
|
|
58
|
-
showToast(resp.ok ? 'Added to queue!' : (data.detail || `Failed (${resp.status})`), resp.ok ? 'success' : 'error');
|
|
59
|
-
} catch (e) {
|
|
60
|
-
showToast(`Network error: ${e.message}`, 'error');
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
async function playNext(token) {
|
|
65
|
-
try {
|
|
66
|
-
const resp = await fetch('/queue/playnext', {
|
|
67
|
-
method: 'POST',
|
|
68
|
-
headers: { 'Content-Type': 'application/json' },
|
|
69
|
-
credentials: 'same-origin',
|
|
70
|
-
body: JSON.stringify({ friendly_token: token })
|
|
71
|
-
});
|
|
72
|
-
const data = await resp.json();
|
|
73
|
-
showToast(resp.ok ? 'Playing next!' : (data.detail || `Failed (${resp.status})`), resp.ok ? 'success' : 'error');
|
|
74
|
-
} catch (e) {
|
|
75
|
-
showToast(`Network error: ${e.message}`, 'error');
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Admin queue: prompt for how to resolve position, then submit the chosen mode.
|
|
80
|
-
function queueAsAdmin(token) {
|
|
81
|
-
showAdminQueueModal(token);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
async function submitAdminQueue(token, mode) {
|
|
85
|
-
closeAdminQueueModal();
|
|
86
|
-
if (mode === 'cancel') return;
|
|
87
|
-
try {
|
|
88
|
-
const resp = await fetch('/admin/queue/add', {
|
|
89
|
-
method: 'POST',
|
|
90
|
-
headers: { 'Content-Type': 'application/json' },
|
|
91
|
-
credentials: 'same-origin',
|
|
92
|
-
body: JSON.stringify({ friendly_token: token, mode })
|
|
93
|
-
});
|
|
94
|
-
const data = await resp.json();
|
|
95
|
-
if (resp.ok) {
|
|
96
|
-
const extra = data.refunded ? ` (${data.refunded} refunded)` : '';
|
|
97
|
-
showToast(`Queued as admin!${extra}`);
|
|
98
|
-
} else {
|
|
99
|
-
showToast(data.detail || `Failed (${resp.status})`, 'error');
|
|
100
|
-
}
|
|
101
|
-
} catch (e) {
|
|
102
|
-
showToast(`Network error: ${e.message}`, 'error');
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function showAdminQueueModal(token) {
|
|
107
|
-
closeAdminQueueModal();
|
|
108
|
-
const overlay = document.createElement('div');
|
|
109
|
-
overlay.id = 'admin-queue-modal';
|
|
110
|
-
overlay.className = 'modal-overlay';
|
|
111
|
-
overlay.innerHTML = `
|
|
112
|
-
<div class="modal-box" role="dialog" aria-modal="true">
|
|
113
|
-
<h3>Queue as Admin</h3>
|
|
114
|
-
<p>How should this item be positioned?</p>
|
|
115
|
-
<div class="modal-actions">
|
|
116
|
-
<button class="btn btn-primary" data-mode="playnext_refund">Play next & refund all pending</button>
|
|
117
|
-
<button class="btn" data-mode="after_purchased">Play after all purchased items</button>
|
|
118
|
-
<button class="btn btn-secondary" data-mode="cancel">Cancel</button>
|
|
119
|
-
</div>
|
|
120
|
-
</div>`;
|
|
121
|
-
overlay.addEventListener('click', (e) => {
|
|
122
|
-
if (e.target === overlay) closeAdminQueueModal();
|
|
123
|
-
const mode = e.target.getAttribute('data-mode');
|
|
124
|
-
if (mode) submitAdminQueue(token, mode);
|
|
125
|
-
});
|
|
126
|
-
document.body.appendChild(overlay);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function closeAdminQueueModal() {
|
|
130
|
-
const existing = document.getElementById('admin-queue-modal');
|
|
131
|
-
if (existing) existing.remove();
|
|
132
|
-
}
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/templates/admin/playlists.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/templates/admin/queue_mgmt.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/templates/admin/schedules.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/templates/catalog/browse.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/templates/catalog/item_detail.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.6.0 → kryten_webqueue-0.6.1}/kryten_webqueue/templates/user/dashboard.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|