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.
Files changed (127) hide show
  1. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/CHANGELOG.md +8 -0
  2. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/PKG-INFO +1 -1
  3. 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
  4. 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
  5. 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
  6. 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
  7. kryten_webqueue-0.28.0/kryten_webqueue/templates/admin/index.html → kryten_webqueue-0.29.0/kryten_webqueue/static/js/admin.js +10 -193
  8. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/static/js/main.js +19 -0
  9. kryten_webqueue-0.29.0/kryten_webqueue/templates/admin/index.html +184 -0
  10. kryten_webqueue-0.29.0/kryten_webqueue/templates/admin/playlists.html +69 -0
  11. kryten_webqueue-0.29.0/kryten_webqueue/templates/admin/promos.html +45 -0
  12. kryten_webqueue-0.29.0/kryten_webqueue/templates/admin/queue_mgmt.html +35 -0
  13. kryten_webqueue-0.29.0/kryten_webqueue/templates/admin/schedules.html +25 -0
  14. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/pyproject.toml +1 -1
  15. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/.github/workflows/python-publish.yml +0 -0
  16. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/.github/workflows/release.yml +0 -0
  17. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/.gitignore +0 -0
  18. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/README.md +0 -0
  19. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/config.example.json +0 -0
  20. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/deploy/kryten-webqueue.service +0 -0
  21. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/deploy/nginx-queue.conf +0 -0
  22. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  23. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/docs/IMPL_API_GATE.md +0 -0
  24. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/docs/IMPL_ECONOMY.md +0 -0
  25. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  26. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/docs/IMPL_ROBOT.md +0 -0
  27. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
  28. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
  29. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/docs/Log for incorrect promo insertion problem.txt +0 -0
  30. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  31. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/docs/PRE_PLAN_GAPS.md +0 -0
  32. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/docs/PRODUCT_PLAN.md +0 -0
  33. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  34. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/docs/UX_POLISH_PLAN.md +0 -0
  35. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/__init__.py +0 -0
  36. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/__main__.py +0 -0
  37. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  38. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/api_gate/client.py +0 -0
  39. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/app.py +0 -0
  40. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/auth/__init__.py +0 -0
  41. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/auth/otp.py +0 -0
  42. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  43. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/auth/session.py +0 -0
  44. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/catalog/__init__.py +0 -0
  45. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/catalog/db/__init__.py +0 -0
  46. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/catalog/db/_catalog.py +0 -0
  47. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/catalog/db/_connection.py +0 -0
  48. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/catalog/db/_feedback.py +0 -0
  49. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/catalog/db/_playlists.py +0 -0
  50. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/catalog/db/_queue.py +0 -0
  51. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/catalog/images.py +0 -0
  52. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/catalog/mediacms.py +0 -0
  53. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/catalog/sync.py +0 -0
  54. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/config.py +0 -0
  55. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/integrations/__init__.py +0 -0
  56. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  57. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  58. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  59. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  60. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  61. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  62. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  63. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  64. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/jobs/__init__.py +0 -0
  65. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  66. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/jobs/manager.py +0 -0
  67. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/jobs/tasks.py +0 -0
  68. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/logging_config.py +0 -0
  69. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/playlists/__init__.py +0 -0
  70. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
  71. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/playlists/fire.py +0 -0
  72. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/playlists/importer.py +0 -0
  73. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/playlists/ordering.py +0 -0
  74. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/playlists/scheduler.py +0 -0
  75. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/promos/__init__.py +0 -0
  76. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/promos/director.py +0 -0
  77. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/queue/__init__.py +0 -0
  78. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/queue/ordering.py +0 -0
  79. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/queue/poller.py +0 -0
  80. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/queue/presence.py +0 -0
  81. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/queue/race_poller.py +0 -0
  82. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/queue/shadow.py +0 -0
  83. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/routes/__init__.py +0 -0
  84. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
  85. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/routes/admin_feedback.py +0 -0
  86. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
  87. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/routes/admin_moderation.py +0 -0
  88. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
  89. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/routes/admin_promos.py +0 -0
  90. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/routes/admin_queue.py +0 -0
  91. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
  92. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/routes/auth.py +0 -0
  93. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/routes/catalog.py +0 -0
  94. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/routes/feedback.py +0 -0
  95. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/routes/pages.py +0 -0
  96. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/routes/queue.py +0 -0
  97. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/routes/user.py +0 -0
  98. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/static/css/main.css +0 -0
  99. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/templates/auth/login.html +0 -0
  100. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/templates/base.html +0 -0
  101. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
  102. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  103. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  104. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/templates/feedback/index.html +0 -0
  105. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/templates/queue/index.html +0 -0
  106. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/templates/race.html +0 -0
  107. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
  108. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/ws/__init__.py +0 -0
  109. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/ws/handler.py +0 -0
  110. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/kryten_webqueue/ws/manager.py +0 -0
  111. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/tests/__init__.py +0 -0
  112. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/tests/test_config_persistence.py +0 -0
  113. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/tests/test_feedback.py +0 -0
  114. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/tests/test_fetchurls_sharepoint.py +0 -0
  115. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/tests/test_phase1.py +0 -0
  116. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/tests/test_phase2_jobs.py +0 -0
  117. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/tests/test_phase3_jobs.py +0 -0
  118. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/tests/test_phase4_live_fixes.py +0 -0
  119. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/tests/test_playlist_import.py +0 -0
  120. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/tests/test_presence_refund.py +0 -0
  121. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/tests/test_promo_director.py +0 -0
  122. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/tests/test_promo_pool_exclusion.py +0 -0
  123. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/tests/test_queue_announce.py +0 -0
  124. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/tests/test_race_poller.py +0 -0
  125. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/tests/test_save_results_to_playlist.py +0 -0
  126. {kryten_webqueue-0.28.0 → kryten_webqueue-0.29.0}/tests/test_schedule_lock.py +0 -0
  127. {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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.28.0
3
+ Version: 0.29.0
4
4
  Summary: Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube
5
5
  Author: grobertson
6
6
  License-Expression: MIT
@@ -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">&larr; 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()">&larr; 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 &amp; 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&#10;https://youtu.be/dQw4w9WgXcQ?t=42 # start time is stripped&#10;yt:dQw4w9WgXcQ&#10;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
- function showModal(html) {
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">&larr; 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 &rarr;</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">&larr; 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">&larr; 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" onclick="openModUserModal(${JSON.stringify(u.username)})">Moderate…</button>`;
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
+ }