kryten-webqueue 0.28.0__tar.gz → 0.29.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.
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/CHANGELOG.md +8 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/PKG-INFO +1 -1
- kryten_webqueue-0.28.0/kryten_webqueue/templates/admin/playlists.html → kryten_webqueue-0.29.0/kryten_webqueue/static/js/admin-playlists.js +1 -89
- kryten_webqueue-0.28.0/kryten_webqueue/templates/admin/promos.html → kryten_webqueue-0.29.0/kryten_webqueue/static/js/admin-promos.js +1 -49
- kryten_webqueue-0.28.0/kryten_webqueue/templates/admin/queue_mgmt.html → kryten_webqueue-0.29.0/kryten_webqueue/static/js/admin-queue-mgmt.js +1 -55
- kryten_webqueue-0.28.0/kryten_webqueue/templates/admin/schedules.html → kryten_webqueue-0.29.0/kryten_webqueue/static/js/admin-schedules.js +1 -39
- kryten_webqueue-0.28.0/kryten_webqueue/templates/admin/index.html → kryten_webqueue-0.29.0/kryten_webqueue/static/js/admin.js +10 -193
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/static/js/main.js +19 -0
- kryten_webqueue-0.29.0/kryten_webqueue/templates/admin/index.html +184 -0
- kryten_webqueue-0.29.0/kryten_webqueue/templates/admin/playlists.html +69 -0
- kryten_webqueue-0.29.0/kryten_webqueue/templates/admin/promos.html +45 -0
- kryten_webqueue-0.29.0/kryten_webqueue/templates/admin/queue_mgmt.html +35 -0
- kryten_webqueue-0.29.0/kryten_webqueue/templates/admin/schedules.html +25 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/pyproject.toml +1 -1
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/.gitignore +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/README.md +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/config.example.json +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/docs/Log for incorrect promo insertion problem.txt +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/docs/UX_POLISH_PLAN.md +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/api_gate/client.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/app.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/catalog/db/__init__.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/catalog/db/_catalog.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/catalog/db/_connection.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/catalog/db/_feedback.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/catalog/db/_playlists.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/catalog/db/_queue.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/catalog/mediacms.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/config.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/integrations/__init__.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/jobs/manager.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/jobs/tasks.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/logging_config.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/playlists/fire.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/playlists/importer.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/playlists/ordering.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/playlists/scheduler.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/promos/__init__.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/promos/director.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/queue/ordering.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/queue/poller.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/queue/presence.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/queue/race_poller.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/queue/shadow.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/routes/admin_feedback.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/routes/admin_moderation.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/routes/admin_promos.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/routes/catalog.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/routes/feedback.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/routes/pages.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/routes/queue.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/static/css/main.css +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/templates/feedback/index.html +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/templates/queue/index.html +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/templates/race.html +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/ws/manager.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/tests/__init__.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/tests/test_config_persistence.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/tests/test_feedback.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/tests/test_fetchurls_sharepoint.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/tests/test_phase1.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/tests/test_phase2_jobs.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/tests/test_phase3_jobs.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/tests/test_phase4_live_fixes.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/tests/test_playlist_import.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/tests/test_presence_refund.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/tests/test_promo_director.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/tests/test_promo_pool_exclusion.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/tests/test_queue_announce.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/tests/test_race_poller.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/tests/test_save_results_to_playlist.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/tests/test_schedule_lock.py +0 -0
- {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/tests/test_search_facets.py +0 -0
|
@@ -5,6 +5,14 @@ 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.29.0] — 2026-07-04
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- **Admin JavaScript moved to external static files.** All inline `{% block scripts %}` JavaScript has been extracted from the five admin templates into individual per-page files under `static/js/` (`admin.js`, `admin-playlists.js`, `admin-promos.js`, `admin-schedules.js`, `admin-queue-mgmt.js`). Shared utilities `showModal`/`closeModal` and `fmtDur` were promoted to `main.js` (previously only defined per-page). Duplicate `escapeHtml`/`fmtDur`/`showModal`/`closeModal` definitions are removed from per-page files. Each template now references its JS via a single `<script src="/static/js/...">` tag — no functional changes, better cacheability and maintainability.
|
|
13
|
+
|
|
14
|
+
[0.29.0]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.29.0
|
|
15
|
+
|
|
8
16
|
## [0.28.0] — 2026-07-04
|
|
9
17
|
|
|
10
18
|
### Added
|
|
@@ -1,83 +1,7 @@
|
|
|
1
|
-
{% extends "base.html" %}
|
|
2
|
-
{% block title %}Playlists - Admin{% endblock %}
|
|
3
|
-
{% block body_class %}admin-page{% endblock %}
|
|
4
|
-
|
|
5
|
-
{% block content %}
|
|
6
|
-
<div class="admin-dashboard">
|
|
7
|
-
<h1>Playlists</h1>
|
|
8
|
-
<p><a href="/admin">← Back to Admin</a></p>
|
|
9
|
-
|
|
10
|
-
<!-- LIST VIEW -->
|
|
11
|
-
<div id="list-view">
|
|
12
|
-
<div class="admin-section">
|
|
13
|
-
<div class="section-head">
|
|
14
|
-
<h2>Saved Playlists</h2>
|
|
15
|
-
<button class="btn btn-primary" onclick="showCreateModal()">+ New Playlist</button>
|
|
16
|
-
</div>
|
|
17
|
-
<div id="playlists-list">Loading…</div>
|
|
18
|
-
</div>
|
|
19
|
-
</div>
|
|
20
|
-
|
|
21
|
-
<!-- EDITOR VIEW -->
|
|
22
|
-
<div id="editor-view" class="hidden">
|
|
23
|
-
<div class="admin-section">
|
|
24
|
-
<div class="section-head">
|
|
25
|
-
<h2 id="editor-title">Edit Playlist</h2>
|
|
26
|
-
<div class="btn-group">
|
|
27
|
-
<button class="btn btn-secondary" onclick="closeEditor()">← Back</button>
|
|
28
|
-
<button class="btn" onclick="editMeta()">Rename</button>
|
|
29
|
-
<button class="btn btn-primary" onclick="saveItems()">Save Items</button>
|
|
30
|
-
<button class="btn" onclick="importToLive()" title="Load this playlist into the live CyTube queue">Import to Live</button>
|
|
31
|
-
</div>
|
|
32
|
-
</div>
|
|
33
|
-
<p id="editor-meta" class="muted"></p>
|
|
34
|
-
|
|
35
|
-
<div class="editor-grid">
|
|
36
|
-
<!-- Working item list -->
|
|
37
|
-
<div>
|
|
38
|
-
<h3>Items (<span id="item-count">0</span>)</h3>
|
|
39
|
-
<ul id="editor-items" class="editor-list"></ul>
|
|
40
|
-
</div>
|
|
41
|
-
|
|
42
|
-
<!-- Add panel -->
|
|
43
|
-
<div>
|
|
44
|
-
<h3>Add from Catalog</h3>
|
|
45
|
-
<div class="search-form">
|
|
46
|
-
<input type="text" id="cat-search" placeholder="Search catalog…" onkeydown="if(event.key==='Enter')catalogSearch()">
|
|
47
|
-
<button class="btn btn-sm" onclick="catalogSearch()">Search</button>
|
|
48
|
-
</div>
|
|
49
|
-
<div id="cat-results" class="cat-results"></div>
|
|
50
|
-
|
|
51
|
-
<h3 style="margin-top:1.5rem;">Bulk Text Import</h3>
|
|
52
|
-
<p class="muted" style="font-size:0.8rem;">One entry per line: a <strong>dropsugar.co</strong> link, a <strong>YouTube</strong>/<strong>youtu.be</strong> link (playlist & start-time args are stripped), <code>cm:token</code>, <code>yt:id</code>, or a bare catalog token. <code>#</code> starts a comment (whole-line or trailing). Unknown sites are skipped.</p>
|
|
53
|
-
<textarea id="import-text" class="import-text" rows="5" placeholder="https://www.dropsugar.co/view?m=abc123def https://youtu.be/dQw4w9WgXcQ?t=42 # start time is stripped yt:dQw4w9WgXcQ abc123def"></textarea>
|
|
54
|
-
<div class="btn-group" style="margin-top:0.25rem;">
|
|
55
|
-
<button class="btn btn-sm" onclick="parseImport()">Append Parsed Items</button>
|
|
56
|
-
<input type="file" id="import-file" accept=".txt,.csv,text/plain" style="display:none" onchange="loadImportFile(event)">
|
|
57
|
-
<button class="btn btn-sm" onclick="document.getElementById('import-file').click()">Choose text file…</button>
|
|
58
|
-
</div>
|
|
59
|
-
<div id="import-errors" class="import-errors"></div>
|
|
60
|
-
</div>
|
|
61
|
-
</div>
|
|
62
|
-
</div>
|
|
63
|
-
</div>
|
|
64
|
-
</div>
|
|
65
|
-
{% endblock %}
|
|
66
|
-
|
|
67
|
-
{% block scripts %}
|
|
68
|
-
<script>
|
|
69
1
|
let editorId = null;
|
|
70
2
|
let editorItems = []; // working list: {media_type, media_id, title, duration_sec}
|
|
71
3
|
let editorImmutable = false;
|
|
72
4
|
|
|
73
|
-
function fmtDur(sec) {
|
|
74
|
-
sec = Math.floor(sec || 0);
|
|
75
|
-
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
|
|
76
|
-
if (h > 0) return `${h}:${m.toString().padStart(2,'0')}:${s.toString().padStart(2,'0')}`;
|
|
77
|
-
return `${m}:${s.toString().padStart(2,'0')}`;
|
|
78
|
-
}
|
|
79
|
-
function escapeHtml(str) { const d = document.createElement('div'); d.textContent = str == null ? '' : str; return d.innerHTML; }
|
|
80
|
-
|
|
81
5
|
// ---------- LIST ----------
|
|
82
6
|
async function loadPlaylists() {
|
|
83
7
|
const resp = await fetch('/admin/playlists/');
|
|
@@ -327,16 +251,4 @@ async function saveMeta() {
|
|
|
327
251
|
}
|
|
328
252
|
|
|
329
253
|
// ---------- generic modal ----------
|
|
330
|
-
|
|
331
|
-
closeModal();
|
|
332
|
-
const o = document.createElement('div');
|
|
333
|
-
o.className = 'modal-overlay'; o.id = 'admin-modal';
|
|
334
|
-
o.innerHTML = `<div class="modal-box">${html}</div>`;
|
|
335
|
-
o.addEventListener('click', e => { if (e.target === o) closeModal(); });
|
|
336
|
-
document.body.appendChild(o);
|
|
337
|
-
}
|
|
338
|
-
function closeModal() { const m = document.getElementById('admin-modal'); if (m) m.remove(); }
|
|
339
|
-
|
|
340
|
-
loadPlaylists();
|
|
341
|
-
</script>
|
|
342
|
-
{% endblock %}
|
|
254
|
+
loadPlaylists();
|
|
@@ -1,47 +1,3 @@
|
|
|
1
|
-
{% extends "base.html" %}
|
|
2
|
-
{% block title %}Promos - Admin{% endblock %}
|
|
3
|
-
{% block body_class %}admin-page{% endblock %}
|
|
4
|
-
|
|
5
|
-
{% block content %}
|
|
6
|
-
<div class="admin-dashboard">
|
|
7
|
-
<h1>Promos</h1>
|
|
8
|
-
<p><a href="/admin">← Back to Admin</a></p>
|
|
9
|
-
|
|
10
|
-
<!-- POOL DESIGNATION -->
|
|
11
|
-
<div class="admin-section">
|
|
12
|
-
<div class="section-head">
|
|
13
|
-
<h2>Promo Pools</h2>
|
|
14
|
-
<a href="/admin/playlists" class="btn btn-sm">Manage Playlist Items →</a>
|
|
15
|
-
</div>
|
|
16
|
-
<p class="muted">
|
|
17
|
-
Designate a saved playlist as a promo pool by choosing its type. Pool
|
|
18
|
-
items are hidden from the public catalog and excluded from pay-to-play,
|
|
19
|
-
and are drawn from automatically by the promo director. Edit a pool's
|
|
20
|
-
clips on the <a href="/admin/playlists">Playlists</a> page.
|
|
21
|
-
</p>
|
|
22
|
-
<div id="pools-list">Loading…</div>
|
|
23
|
-
</div>
|
|
24
|
-
|
|
25
|
-
<!-- SETTINGS (editable) -->
|
|
26
|
-
<div class="admin-section">
|
|
27
|
-
<div class="section-head">
|
|
28
|
-
<h2>Promo Settings</h2>
|
|
29
|
-
<span class="badge badge-accent" title="Saved to the service config file and applied live">editable</span>
|
|
30
|
-
</div>
|
|
31
|
-
<p class="muted">
|
|
32
|
-
Promo cadence and per-type behaviour. Changes are saved to the service
|
|
33
|
-
config file and applied to the running promo director immediately — no
|
|
34
|
-
restart required.
|
|
35
|
-
</p>
|
|
36
|
-
<form id="settings-form">
|
|
37
|
-
<div id="settings-view">Loading…</div>
|
|
38
|
-
</form>
|
|
39
|
-
</div>
|
|
40
|
-
</div>
|
|
41
|
-
{% endblock %}
|
|
42
|
-
|
|
43
|
-
{% block scripts %}
|
|
44
|
-
<script>
|
|
45
1
|
const PROMO_TYPE_LABELS = {
|
|
46
2
|
channel_identity: 'Channel Identity',
|
|
47
3
|
event: 'Event',
|
|
@@ -53,8 +9,6 @@ const PROMO_TYPE_LABELS = {
|
|
|
53
9
|
let ALL_PLAYLISTS = [];
|
|
54
10
|
let PROMO_META = { promo_types: [], general_types: [], lead_in_types: [] };
|
|
55
11
|
|
|
56
|
-
function escapeHtml(str) { const d = document.createElement('div'); d.textContent = str == null ? '' : str; return d.innerHTML; }
|
|
57
|
-
|
|
58
12
|
function typeLabel(t) { return PROMO_TYPE_LABELS[t] || t; }
|
|
59
13
|
|
|
60
14
|
// ---------- POOLS ----------
|
|
@@ -207,6 +161,4 @@ document.getElementById('settings-form').addEventListener('submit', async (e) =>
|
|
|
207
161
|
});
|
|
208
162
|
|
|
209
163
|
loadPools();
|
|
210
|
-
loadSettings();
|
|
211
|
-
</script>
|
|
212
|
-
{% endblock %}
|
|
164
|
+
loadSettings();
|
|
@@ -1,47 +1,5 @@
|
|
|
1
|
-
{% extends "base.html" %}
|
|
2
|
-
{% block title %}Queue Management - Admin{% endblock %}
|
|
3
|
-
{% block body_class %}admin-page{% endblock %}
|
|
4
|
-
|
|
5
|
-
{% block content %}
|
|
6
|
-
<div class="admin-dashboard">
|
|
7
|
-
<h1>Queue Management</h1>
|
|
8
|
-
<p><a href="/admin">← Back to Admin</a></p>
|
|
9
|
-
|
|
10
|
-
<div class="admin-section">
|
|
11
|
-
<div class="section-head">
|
|
12
|
-
<h2>Live Queue</h2>
|
|
13
|
-
<div class="btn-group">
|
|
14
|
-
<button class="btn btn-sm" onclick="showAddModal()">+ Add Item</button>
|
|
15
|
-
<button class="btn btn-sm" onclick="loadQueue()">Refresh</button>
|
|
16
|
-
<button class="btn btn-sm btn-danger" onclick="clearAll()">Clear Queue</button>
|
|
17
|
-
</div>
|
|
18
|
-
</div>
|
|
19
|
-
<div id="np-line" class="muted" style="margin-bottom:0.75rem;"></div>
|
|
20
|
-
<div id="queue-table">Loading…</div>
|
|
21
|
-
</div>
|
|
22
|
-
|
|
23
|
-
<div class="admin-section">
|
|
24
|
-
<div class="section-head">
|
|
25
|
-
<h2>Catalog Sync</h2>
|
|
26
|
-
<button class="btn btn-sm" onclick="syncNow()">Sync Now</button>
|
|
27
|
-
</div>
|
|
28
|
-
<div id="sync-logs">Loading…</div>
|
|
29
|
-
</div>
|
|
30
|
-
</div>
|
|
31
|
-
{% endblock %}
|
|
32
|
-
|
|
33
|
-
{% block scripts %}
|
|
34
|
-
<script>
|
|
35
1
|
let refreshTimer = null;
|
|
36
2
|
|
|
37
|
-
function escapeHtml(str) { const d = document.createElement('div'); d.textContent = str == null ? '' : str; return d.innerHTML; }
|
|
38
|
-
function fmtDur(sec) {
|
|
39
|
-
sec = Math.floor(sec || 0);
|
|
40
|
-
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
|
|
41
|
-
if (h > 0) return `${h}:${m.toString().padStart(2,'0')}:${s.toString().padStart(2,'0')}`;
|
|
42
|
-
return `${m}:${s.toString().padStart(2,'0')}`;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
3
|
async function loadQueue() {
|
|
46
4
|
const resp = await fetch('/queue/state');
|
|
47
5
|
const el = document.getElementById('queue-table');
|
|
@@ -168,19 +126,7 @@ async function loadSyncLogs() {
|
|
|
168
126
|
</table>`;
|
|
169
127
|
}
|
|
170
128
|
|
|
171
|
-
function showModal(html) {
|
|
172
|
-
closeModal();
|
|
173
|
-
const o = document.createElement('div');
|
|
174
|
-
o.className = 'modal-overlay'; o.id = 'admin-modal';
|
|
175
|
-
o.innerHTML = `<div class="modal-box">${html}</div>`;
|
|
176
|
-
o.addEventListener('click', e => { if (e.target === o) closeModal(); });
|
|
177
|
-
document.body.appendChild(o);
|
|
178
|
-
}
|
|
179
|
-
function closeModal() { const m = document.getElementById('admin-modal'); if (m) m.remove(); }
|
|
180
|
-
|
|
181
129
|
loadQueue();
|
|
182
130
|
loadSyncLogs();
|
|
183
131
|
refreshTimer = setInterval(loadQueue, 5000);
|
|
184
|
-
window.addEventListener('beforeunload', () => clearInterval(refreshTimer));
|
|
185
|
-
</script>
|
|
186
|
-
{% endblock %}
|
|
132
|
+
window.addEventListener('beforeunload', () => clearInterval(refreshTimer));
|
|
@@ -1,32 +1,6 @@
|
|
|
1
|
-
{% extends "base.html" %}
|
|
2
|
-
{% block title %}Schedules - Admin{% endblock %}
|
|
3
|
-
{% block body_class %}admin-page{% endblock %}
|
|
4
|
-
|
|
5
|
-
{% block content %}
|
|
6
|
-
<div class="admin-dashboard">
|
|
7
|
-
<h1>Schedules</h1>
|
|
8
|
-
<p><a href="/admin">← Back to Admin</a></p>
|
|
9
|
-
|
|
10
|
-
<div id="lock-banner" class="lock-banner hidden"></div>
|
|
11
|
-
<div id="active-banner" class="schedule-info hidden" style="margin-bottom:1.5rem;"></div>
|
|
12
|
-
|
|
13
|
-
<div class="admin-section">
|
|
14
|
-
<div class="section-head">
|
|
15
|
-
<h2>Scheduled Events</h2>
|
|
16
|
-
<button class="btn btn-primary" onclick="showScheduleModal()">+ New Schedule</button>
|
|
17
|
-
</div>
|
|
18
|
-
<div id="schedules-list">Loading…</div>
|
|
19
|
-
</div>
|
|
20
|
-
</div>
|
|
21
|
-
{% endblock %}
|
|
22
|
-
|
|
23
|
-
{% block scripts %}
|
|
24
|
-
<script>
|
|
25
1
|
let playlistMap = {};
|
|
26
2
|
let playlistRows = [];
|
|
27
3
|
|
|
28
|
-
function escapeHtml(str) { const d = document.createElement('div'); d.textContent = str == null ? '' : str; return d.innerHTML; }
|
|
29
|
-
|
|
30
4
|
async function loadPlaylistsForSelect() {
|
|
31
5
|
const resp = await fetch('/admin/playlists/');
|
|
32
6
|
if (!resp.ok) return [];
|
|
@@ -257,16 +231,6 @@ async function deleteSchedule(id, label) {
|
|
|
257
231
|
loadSchedules();
|
|
258
232
|
}
|
|
259
233
|
|
|
260
|
-
function showModal(html) {
|
|
261
|
-
closeModal();
|
|
262
|
-
const o = document.createElement('div');
|
|
263
|
-
o.className = 'modal-overlay'; o.id = 'admin-modal';
|
|
264
|
-
o.innerHTML = `<div class="modal-box">${html}</div>`;
|
|
265
|
-
o.addEventListener('click', e => { if (e.target === o) closeModal(); });
|
|
266
|
-
document.body.appendChild(o);
|
|
267
|
-
}
|
|
268
|
-
function closeModal() { const m = document.getElementById('admin-modal'); if (m) m.remove(); }
|
|
269
|
-
|
|
270
234
|
loadSchedules();
|
|
271
235
|
|
|
272
236
|
// Keep the lock indicator and active-event banner fresh without a reload:
|
|
@@ -277,6 +241,4 @@ setInterval(() => {
|
|
|
277
241
|
loadLockStatus();
|
|
278
242
|
loadActive();
|
|
279
243
|
}
|
|
280
|
-
}, 15000);
|
|
281
|
-
</script>
|
|
282
|
-
{% endblock %}
|
|
244
|
+
}, 15000);
|
|
@@ -1,186 +1,3 @@
|
|
|
1
|
-
{% extends "base.html" %}
|
|
2
|
-
{% block title %}Admin{% endblock %}
|
|
3
|
-
{% block body_class %}admin-page{% endblock %}
|
|
4
|
-
|
|
5
|
-
{% block content %}
|
|
6
|
-
<div class="admin-dashboard">
|
|
7
|
-
<h1>Admin Panel</h1>
|
|
8
|
-
|
|
9
|
-
<div class="admin-nav">
|
|
10
|
-
<a href="/admin/playlists" class="btn">Playlists</a>
|
|
11
|
-
<a href="/admin/schedules" class="btn">Schedules</a>
|
|
12
|
-
<a href="/admin/queue-mgmt" class="btn">Queue Management</a>
|
|
13
|
-
<a href="/admin/promos" class="btn">Promos</a>
|
|
14
|
-
</div>
|
|
15
|
-
|
|
16
|
-
<!-- Tabbed dashboard. The sub-pages above stay as their own pages for now;
|
|
17
|
-
these tabs cover the at-a-glance dashboard plus the feedback and movie
|
|
18
|
-
suggestion triage queues. The container is intentionally generic so the
|
|
19
|
-
sub-pages can later fold in as additional tabs. -->
|
|
20
|
-
<div class="admin-tabs-card">
|
|
21
|
-
<div class="tabs" role="tablist">
|
|
22
|
-
<button class="tab-btn active" data-tab="overview" role="tab" aria-selected="true">Overview</button>
|
|
23
|
-
<button class="tab-btn" data-tab="jobs" role="tab" aria-selected="false">Jobs</button>
|
|
24
|
-
<button class="tab-btn" data-tab="sync" role="tab" aria-selected="false">Sync Logs</button>
|
|
25
|
-
<button class="tab-btn" data-tab="feedback" role="tab" aria-selected="false">Feedback <span class="tab-badge" id="feedback-badge" hidden></span></button>
|
|
26
|
-
<button class="tab-btn" data-tab="suggestions" role="tab" aria-selected="false">Suggestions <span class="tab-badge" id="suggestions-badge" hidden></span></button>
|
|
27
|
-
<button class="tab-btn" data-tab="moderation" role="tab" aria-selected="false">Moderation</button>
|
|
28
|
-
</div>
|
|
29
|
-
|
|
30
|
-
<div class="tab-panel" id="tab-overview" role="tabpanel">
|
|
31
|
-
<div class="admin-section">
|
|
32
|
-
<h2>Quick Actions</h2>
|
|
33
|
-
<div class="quick-actions">
|
|
34
|
-
<button class="btn btn-danger" onclick="clearQueue()">Clear Queue</button>
|
|
35
|
-
<button class="btn btn-primary" onclick="triggerSync()">Sync Catalog</button>
|
|
36
|
-
<button class="btn" onclick="clearActiveSchedule()">Clear Active Schedule</button>
|
|
37
|
-
</div>
|
|
38
|
-
</div>
|
|
39
|
-
<div class="admin-section">
|
|
40
|
-
<h2>Queue Status</h2>
|
|
41
|
-
<div id="queue-status">Loading...</div>
|
|
42
|
-
</div>
|
|
43
|
-
</div>
|
|
44
|
-
|
|
45
|
-
<div class="tab-panel" id="tab-jobs" role="tabpanel" hidden>
|
|
46
|
-
<div class="admin-section">
|
|
47
|
-
<h2>Jobs</h2>
|
|
48
|
-
<div id="jobs-list" class="jobs-list">Loading...</div>
|
|
49
|
-
<h3>Recent Job Runs</h3>
|
|
50
|
-
<div id="job-runs">Loading...</div>
|
|
51
|
-
</div>
|
|
52
|
-
</div>
|
|
53
|
-
|
|
54
|
-
<div class="tab-panel" id="tab-sync" role="tabpanel" hidden>
|
|
55
|
-
<div class="admin-section">
|
|
56
|
-
<h2>Sync Logs</h2>
|
|
57
|
-
<div id="sync-logs">Loading...</div>
|
|
58
|
-
</div>
|
|
59
|
-
</div>
|
|
60
|
-
|
|
61
|
-
<div class="tab-panel" id="tab-feedback" role="tabpanel" hidden>
|
|
62
|
-
<div class="admin-section">
|
|
63
|
-
<div class="queue-head">
|
|
64
|
-
<h2>Feedback</h2>
|
|
65
|
-
<div class="queue-head-actions">
|
|
66
|
-
<label class="queue-filter"><input type="checkbox" id="feedback-unread-only"> Unread only</label>
|
|
67
|
-
<button class="btn btn-sm" id="feedback-refresh">Refresh</button>
|
|
68
|
-
</div>
|
|
69
|
-
</div>
|
|
70
|
-
<div id="feedback-list">Loading…</div>
|
|
71
|
-
</div>
|
|
72
|
-
</div>
|
|
73
|
-
|
|
74
|
-
<div class="tab-panel" id="tab-suggestions" role="tabpanel" hidden>
|
|
75
|
-
<div class="admin-section">
|
|
76
|
-
<div class="queue-head">
|
|
77
|
-
<h2>Movie Suggestions</h2>
|
|
78
|
-
<div class="queue-head-actions">
|
|
79
|
-
<label class="queue-filter"><input type="checkbox" id="suggestions-unread-only"> Unread only</label>
|
|
80
|
-
<button class="btn btn-sm" id="suggestions-refresh">Refresh</button>
|
|
81
|
-
</div>
|
|
82
|
-
</div>
|
|
83
|
-
<div id="suggestions-list">Loading…</div>
|
|
84
|
-
</div>
|
|
85
|
-
</div>
|
|
86
|
-
|
|
87
|
-
<div class="tab-panel" id="tab-moderation" role="tabpanel" hidden>
|
|
88
|
-
|
|
89
|
-
<!-- Service status ------------------------------------------------>
|
|
90
|
-
<div class="admin-section">
|
|
91
|
-
<div class="queue-head">
|
|
92
|
-
<h2>Service Status</h2>
|
|
93
|
-
<button class="btn btn-sm" id="mod-status-refresh">Refresh</button>
|
|
94
|
-
</div>
|
|
95
|
-
<div id="mod-status-content">Loading…</div>
|
|
96
|
-
</div>
|
|
97
|
-
|
|
98
|
-
<!-- Moderation entries --------------------------------------------->
|
|
99
|
-
<div class="admin-section">
|
|
100
|
-
<div class="queue-head">
|
|
101
|
-
<h2>Moderation List</h2>
|
|
102
|
-
<div class="queue-head-actions">
|
|
103
|
-
<div class="mod-filter-btns">
|
|
104
|
-
<button class="btn btn-sm mod-filter-btn active" data-filter="">All</button>
|
|
105
|
-
<button class="btn btn-sm mod-filter-btn" data-filter="ban">Ban</button>
|
|
106
|
-
<button class="btn btn-sm mod-filter-btn" data-filter="smute">SMute</button>
|
|
107
|
-
<button class="btn btn-sm mod-filter-btn" data-filter="mute">Mute</button>
|
|
108
|
-
</div>
|
|
109
|
-
<button class="btn btn-sm" id="mod-entries-refresh">Refresh</button>
|
|
110
|
-
</div>
|
|
111
|
-
</div>
|
|
112
|
-
<div id="mod-entries-list">Loading…</div>
|
|
113
|
-
<details class="mod-add-details">
|
|
114
|
-
<summary class="btn btn-sm">Add entry…</summary>
|
|
115
|
-
<div class="mod-add-form">
|
|
116
|
-
<label class="field"><span>Username</span>
|
|
117
|
-
<input type="text" id="mod-add-username" placeholder="username" autocomplete="off">
|
|
118
|
-
</label>
|
|
119
|
-
<label class="field"><span>Action</span>
|
|
120
|
-
<select id="mod-add-action">
|
|
121
|
-
<option value="ban">Ban</option>
|
|
122
|
-
<option value="smute">Soft mute (smute)</option>
|
|
123
|
-
<option value="mute">Mute</option>
|
|
124
|
-
</select>
|
|
125
|
-
</label>
|
|
126
|
-
<label class="field"><span>Reason (optional)</span>
|
|
127
|
-
<input type="text" id="mod-add-reason" placeholder="reason" autocomplete="off">
|
|
128
|
-
</label>
|
|
129
|
-
<button class="btn btn-primary" onclick="submitAddModEntry()">Add</button>
|
|
130
|
-
</div>
|
|
131
|
-
</details>
|
|
132
|
-
</div>
|
|
133
|
-
|
|
134
|
-
<!-- Patterns -------------------------------------------------------->
|
|
135
|
-
<div class="admin-section">
|
|
136
|
-
<div class="queue-head">
|
|
137
|
-
<h2>Username Patterns</h2>
|
|
138
|
-
<button class="btn btn-sm" id="mod-patterns-refresh">Refresh</button>
|
|
139
|
-
</div>
|
|
140
|
-
<div id="mod-patterns-list">Loading…</div>
|
|
141
|
-
<details class="mod-add-details">
|
|
142
|
-
<summary class="btn btn-sm">Add pattern…</summary>
|
|
143
|
-
<div class="mod-add-form">
|
|
144
|
-
<label class="field"><span>Pattern</span>
|
|
145
|
-
<input type="text" id="mod-pat-pattern" placeholder="e.g. spam|hate or ^bot\d+" autocomplete="off">
|
|
146
|
-
</label>
|
|
147
|
-
<label class="check"><input type="checkbox" id="mod-pat-regex"> Treat as regex</label>
|
|
148
|
-
<label class="field"><span>Action</span>
|
|
149
|
-
<select id="mod-pat-action">
|
|
150
|
-
<option value="ban">Ban</option>
|
|
151
|
-
<option value="smute">Soft mute (smute)</option>
|
|
152
|
-
<option value="mute">Mute</option>
|
|
153
|
-
</select>
|
|
154
|
-
</label>
|
|
155
|
-
<label class="field"><span>Description (optional)</span>
|
|
156
|
-
<input type="text" id="mod-pat-desc" placeholder="e.g. Nazi hate symbol" autocomplete="off">
|
|
157
|
-
</label>
|
|
158
|
-
<button class="btn btn-primary" onclick="submitAddModPattern()">Add</button>
|
|
159
|
-
</div>
|
|
160
|
-
</details>
|
|
161
|
-
</div>
|
|
162
|
-
|
|
163
|
-
<!-- Recent users ----------------------------------------------------->
|
|
164
|
-
<div class="admin-section">
|
|
165
|
-
<div class="queue-head">
|
|
166
|
-
<h2>Recent Users</h2>
|
|
167
|
-
<div class="queue-head-actions mod-recent-window-row">
|
|
168
|
-
<span>Window:</span>
|
|
169
|
-
<input type="number" id="mod-recent-window" value="60" min="1" max="10080">
|
|
170
|
-
<span>min</span>
|
|
171
|
-
<button class="btn btn-sm" onclick="loadRecentUsers()">Load</button>
|
|
172
|
-
</div>
|
|
173
|
-
</div>
|
|
174
|
-
<div id="mod-recent-list"><p class="empty-state">Click Load to fetch recent users.</p></div>
|
|
175
|
-
</div>
|
|
176
|
-
|
|
177
|
-
</div>
|
|
178
|
-
</div>
|
|
179
|
-
</div>
|
|
180
|
-
{% endblock %}
|
|
181
|
-
|
|
182
|
-
{% block scripts %}
|
|
183
|
-
<script>
|
|
184
1
|
async function clearQueue() {
|
|
185
2
|
if (!confirm('Clear the entire queue? All pay items will be refunded.')) return;
|
|
186
3
|
const resp = await fetch('/admin/queue/clear', {method: 'POST'});
|
|
@@ -439,12 +256,6 @@ function connectAdminWebSocket() {
|
|
|
439
256
|
}, 30000);
|
|
440
257
|
}
|
|
441
258
|
|
|
442
|
-
function escapeHtml(str) {
|
|
443
|
-
const div = document.createElement('div');
|
|
444
|
-
div.textContent = str;
|
|
445
|
-
return div.innerHTML;
|
|
446
|
-
}
|
|
447
|
-
|
|
448
259
|
// Summarize a job_runs.detail JSON blob for the history table. Prefers a
|
|
449
260
|
// failure message, otherwise a compact success summary.
|
|
450
261
|
function summarizeRunDetail(detail) {
|
|
@@ -817,6 +628,14 @@ async function submitAddModPattern() {
|
|
|
817
628
|
|
|
818
629
|
document.getElementById('mod-patterns-refresh').addEventListener('click', loadModPatterns);
|
|
819
630
|
|
|
631
|
+
// Delegated click listener for the "Moderate…" buttons in the recent-users
|
|
632
|
+
// table. The table is rebuilt on each load so we delegate from the stable
|
|
633
|
+
// container div instead of attaching per-row handlers.
|
|
634
|
+
document.getElementById('mod-recent-list').addEventListener('click', (e) => {
|
|
635
|
+
const btn = e.target.closest('[data-mod-username]');
|
|
636
|
+
if (btn) openModUserModal(btn.dataset.modUsername);
|
|
637
|
+
});
|
|
638
|
+
|
|
820
639
|
// -- Recent users --
|
|
821
640
|
async function loadRecentUsers() {
|
|
822
641
|
const el = document.getElementById('mod-recent-list');
|
|
@@ -839,7 +658,7 @@ function renderRecentUsers(el, users) {
|
|
|
839
658
|
: '<span class="muted">—</span>';
|
|
840
659
|
const actionBtn = u.moderation_action
|
|
841
660
|
? ''
|
|
842
|
-
: `<button class="btn btn-sm"
|
|
661
|
+
: `<button class="btn btn-sm" data-mod-username="${escapeHtml(u.username)}">Moderate…</button>`;
|
|
843
662
|
return `<tr>
|
|
844
663
|
<td>${escapeHtml(u.username || '')}</td>
|
|
845
664
|
<td>${u.first_seen ? formatLocalDateTime(u.first_seen) : '—'}</td>
|
|
@@ -914,6 +733,4 @@ connectAdminWebSocket();
|
|
|
914
733
|
// tab is visible to reflect running/finished status without a reload.
|
|
915
734
|
setInterval(() => {
|
|
916
735
|
if (document.visibilityState === 'visible') loadJobs();
|
|
917
|
-
}, 5000);
|
|
918
|
-
</script>
|
|
919
|
-
{% endblock %}
|
|
736
|
+
}, 5000);
|
|
@@ -265,3 +265,22 @@ function closeAdminQueueModal() {
|
|
|
265
265
|
const existing = document.getElementById('admin-queue-modal');
|
|
266
266
|
if (existing) existing.remove();
|
|
267
267
|
}
|
|
268
|
+
|
|
269
|
+
// ---------- generic admin modal ----------
|
|
270
|
+
function showModal(html) {
|
|
271
|
+
closeModal();
|
|
272
|
+
const o = document.createElement('div');
|
|
273
|
+
o.className = 'modal-overlay'; o.id = 'admin-modal';
|
|
274
|
+
o.innerHTML = `<div class="modal-box">${html}</div>`;
|
|
275
|
+
o.addEventListener('click', e => { if (e.target === o) closeModal(); });
|
|
276
|
+
document.body.appendChild(o);
|
|
277
|
+
}
|
|
278
|
+
function closeModal() { const m = document.getElementById('admin-modal'); if (m) m.remove(); }
|
|
279
|
+
|
|
280
|
+
// ---------- duration formatter ----------
|
|
281
|
+
function fmtDur(sec) {
|
|
282
|
+
if (!sec && sec !== 0) return '';
|
|
283
|
+
const m = Math.floor(sec / 60);
|
|
284
|
+
const s = Math.round(sec % 60);
|
|
285
|
+
return `${m}:${s.toString().padStart(2, '0')}`;
|
|
286
|
+
}
|